长期以来,程序员界存在一个"常识":内存安全和线程安全是两码事。内存安全指的是不会出现use-after-free或数组越界这类问题,线程安全则是防止并发编程中的竞态条件。大多数人认为这是两个独立的安全领域。
但事实是:这种区分毫无意义,因为线程不安全的语言根本无法提供真正的内存安全。
为了证明这个观点,我写了段Go代码,直接让它segfault崩溃在0x2a地址(就是十进制42)。注意这不是普通的Go panic,而是真正的段错误。
先看问题代码
package main
// 随便定义个接口
type Thing interface {
get() int
}
// 两个实现,字段类型完全不同
type Int struct {
val int
}
func (s *Int) get() int {
return s.val
}
type Ptr struct {
val *int
}
func (s *Ptr) get() int {
return *s.val
}
// 全局接口变量,会在Int和Ptr之间切换
var globalVar Thing = &Int{val: 42}
// 不停调用接口方法
func repeat_get() {
for {
x := globalVar
x.get()
}
}
// 不停切换动态类型
func repeat_swap() {
var myval = 0
for {
globalVar = &Ptr{val: &myval}
globalVar = &Int{val: 42}
}
}
func main() {
go repeat_get()
repeat_swap()
}
运行这代码,很快就会看到:
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x2a pc=0x468863]
注意那个地址0x2a,正好是42的十六进制。程序试图把整数42当指针解引用了。
攻击原理:接口的致命弱点
Go接口的内部秘密
很多Go新手以为接口就是个简单的变量,其实不然。Go把接口类型存储为一个叫iface的结构体,里面有两个指针:
type iface struct {
tab *itab // 指向类型信息和方法表
data unsafe.Pointer // 指向实际数据
}
想象一下,接口变量就像一张双面名片:
- 正面(tab):写着"我是什么类型,有哪些方法"
- 反面(data):指向真正的数据在哪里
问题出在哪里?
当你给接口变量赋新值时,Go需要分别更新这两个指针。关键来了:这两次更新不是原子操作!
// 当执行这行代码时
globalVar = &Ptr{val: &myval}
// Go实际做的是:
// 步骤1:更新data指针 -> 指向Ptr的数据
// 步骤2:更新tab指针 -> 指向Ptr的方法表
// 问题:步骤1和2之间有时间缝隙!
时序攻击的精妙之处
我设置了两个并发goroutine制造竞争:
- goroutine A(repeat_get):疯狂读取
globalVar并调用方法 - goroutine B(repeat_swap):疯狂在Int和Ptr之间切换
时序就是一切。假设发生这样的交错:
时刻1: goroutine B开始更新globalVar = &Ptr{...}
-> 只更新了data指针,tab还是旧的Int类型
时刻2: goroutine A读取globalVar
-> 拿到了Ptr的data + Int的tab(混搭!)
时刻3: goroutine A调用x.get()
-> 用Int的方法表,但操作Ptr的数据
灾难是怎么发生的
现在程序彻底懵了:
- 它以为自己在处理
Int类型(因为方法表是Int的) - 但实际数据是
Ptr类型的结构 Int.get()方法期望直接返回val字段的值- 但现在
val字段是个指针(Ptr的结构) - 程序把这个指针的值(42)当作内存地址去访问
- 尝试访问地址
0x2a(42的十六进制) → 段错误!
为什么这么危险?
这就像医院搞错了病历:拿着张三的CT片,按李四的手术方案做手术。结果可想而知。
更可怕的是,稍加修改这个攻击,你可以:
- 读取任意内存地址的内容
- 往任意地址写入数据
- 完全绕过Go的类型系统保护
这就是为什么我说Go的"内存安全"其实建立在"没有数据竞争"这个不稳定的前提上。
其他语言怎么处理
Java:付出巨大代价的完美方案
Java开发者花费巨大努力确保即使有数据竞争,程序依然保持完全定义良好的行为。他们甚至开发了第一个工业级部署的并发内存模型,比C++11内存模型早很多年。
结果就是,并发Java程序中你可能看到意料之外的旧值(比如本该初始化的引用变成null),但永远不可能真正"破坏"语言本身,不会解引用无效指针然后段错误。
从某种意义上说,所有Java程序都是线程安全的。
Rust:类型系统的革命
Rust用强大的类型系统完全排除大多数访问上的数据竞争,只为少数内存访问承担安全处理竞争的成本。Swift现在也在用类似的"严格并发"方法。
Go:两头不靠的尴尬
很不幸,Go两条路都没选。严格来说算不上内存安全语言。Go能承诺的最好情况是:如果程序没有数据竞争(或者更准确地说,在接口、切片、映射这些问题类型上没有数据竞争),那么内存访问不会出错。
这就像卖保险说:"只要你不出意外,我们就赔意外险。"
🔍 Go的真实现状
检测工具的局限
公平地说,Go确实提供了开箱即用的竞争检测工具,能快速发现问题。但在真实程序中,你必须希望测试套件覆盖所有可能情况——而这正是强类型系统和静态安全保证要避免的问题。
go test -race ./...
go build -race
官方文档的"春秋笔法"
Go内存模型文档强调"大多数竞争只有有限数量的结果",说Go不像"C和C++那样,任何有竞争的程序含义都完全未定义"。
你可以说"大多数"是在暗示什么,但这节没列出任何结果无限的情况,很容易被忽略。他们甚至说Go"更像Java或JavaScript",考虑到这些语言为实现线程安全所做的努力,这种说法相当不公平。
只有后面的小节才明确承认Go中某些竞争确实有完全未定义的行为(这与Java或JavaScript非常不同)。
🎭 重新定义"内存安全"
我认为人们谈论内存安全时真正关心的是程序不能破坏语言本身。所有安全漏洞都是代码做了语言规范中不可能的事情,比如跳转到用户数组并当汇编执行。
我们通常用 未定义行为 Undefined Behavior 描述这种破坏语言的程序。一旦程序有UB,一切都没保障;攻击者能否控制UB的具体表现并利用,主要是实现细节。
C11和C++11标准明确规定,包含数据竞争的C或C++程序具有未定义行为。Java的关键差异是,数据竞争仅影响"线程间操作"而不是未定义行为,这意味着Java程序可能产生不期望的并发行为但仍然安全。
在我看来,存在明确界线将"安全"语言(程序不能有未定义行为)和"不安全"语言分开。没意义进一步细分为内存安全、线程安全、类型安全等等——不管为什么有UB,重要的是有UB的程序违背语言基本抽象,这是漏洞的完美温床。
因此,如果一门语言不能系统性防止未定义行为,我们就不应该称其"内存安全" 。
最后
这不是要黑Go,而是要把语言的鲜为人知弱点放在聚光灯下。在选择编程语言时,我们需要明确了解每种语言提供的安全保证。
记住:真正的安全不是部分的,而是整体的。
引用来源:
Ralf Jung - There is no memory safety without thread safety - ralfj.de
www.ralfj.de/blog/2025/0…
The Go Team - The Go Memory Model - The Go Programming Language
go.dev/ref/mem
Dave Cheney - Ice cream makers and data races
dave.cheney.net/2014/06/27/…
Russ Cox - Go Data Structures: Interfaces
research.swtch.com/interfaces
The Go Team - Data Race Detector - The Go Programming Language
go.dev/doc/article…