咱先不谈多线程竞争、空指针、野指针、数字溢出这些常见的坑,我们就拿很多人盯着的“边界检查”来说。
char *gets(char *s);为什么 gets 函数从根本上就是不安全的?因为它读取输入时不知道提供的缓冲区有多大,只能一直写入缓冲区,直到碰到换行符或 EOF。
如果黑客特意构造超长的输入,则完全可以写坏缓冲区,写坏某些数据结构,覆盖函数指针,从而控制你的程序行为。最终结果是什么?权限被黑客获取,系统被入侵,你的隐私数据被公开。这叫缓冲区溢出漏洞。
C 语言是“可移植汇编”,你的内存操作不会带有检查。你 996 哈欠连天时一不小心写错逻辑,忘记证明了一处操作不会越界,测试也恰巧没覆盖到这种边缘情况。漏洞穿越为数不多的几层质量管理体系,留到了产品上线,后果可想而知。
2014 年的心脏滴血,OpenSSL 实现 TLS 的心跳扩展时没有对输入进行适当验证,没有边界检查,黑客可以越界读取服务器内存中的敏感信息。福布斯网络安全专栏作家约瑟夫·斯坦伯格写道:“有些人认为,至少就其潜在影响而言,‘心脏滴血’是自互联网商用以来所发现的最严重的漏洞。”
2017 年的永恒之蓝,美国 NSA 开发的网络攻击武器,利用了 Windows SMB 协议中的缓冲区溢出漏洞,恰好就是算错了数字大小导致越界写入。该漏洞被黑客组织披露后,WannaCry 勒索病毒全球大爆发,至少 150 个国家、30 万名用户中招,造成损失达 80 亿美元。
有一次我给 Redis 提交性能改进 PR 时,有一处漏写了指针赋值,造成错误写入。review 的大佬们没看出来,原有的测试也没覆盖到。后来还是我亲手发现和修复,补上了对应的测试。相信这种情况并不少见。
自 1970 年代发明以来,C 语言已有五十年的历史,而现代编程语言几乎都已配置默认的边界检查,为的就是减少此类漏洞损失,又能让程序员更快更轻松地写出业务逻辑,资本家也得到了更多的剩余价值。
甚至连 C++ 都接受了标准库加固,从 C++26 开始,启用标准库加固时将带有默认的边界检查。谷歌的生产部署中报告,加固模式仅有 0.3% 的性能影响,却帮助发现了上千个 bug。
如果不能接受边界检查,你可以选择绕过,自行证明这里不可能越界。如果默认边界检查,你会得到对缓冲区溢出漏洞的防御,但代价是损失一点性能。这就是 Rust 采取的策略,编译器甚至还能自动抹除不必要的边界检查。
除了操作系统、音视频处理、密码学、嵌入式等需要极高性能和资源受限的领域,你会发现 C 语言在其他领域已被淘汰,没人写个商城或者脚本还要用 C 语言吧。
这就是现代编程语言改进的成果,让坑人的地方再少一点,让自动化工具取代“编程仙人”,让广大程序员少掉头发。