2025 年末,计算机考古学迎来了一次重要发现:唯一已知的 Unix v4 版本从磁带上被成功恢复。这个 1973 年的版本标志着 UNIX 操作系统从汇编语言向 C 语言重写的关键转折点。当安全研究人员在 PDP-11 模拟器中运行这个历史系统并审计其源代码时,他们在su(1)程序中发现了一个经典的缓冲区溢出漏洞。这个跨越半个世纪的安全漏洞不仅揭示了早期 UNIX 开发的安全意识状态,更为我们理解现代缓冲区溢出防御策略提供了宝贵的历史视角。
Unix v4:从汇编到 C 的关键转折
Unix v4 诞生于 1973 年,是 UNIX 发展史上的里程碑版本。在此之前,UNIX 主要用汇编语言编写,而 v4 版本首次大规模采用 C 语言重写。这一转变不仅提高了代码的可移植性和可维护性,也为后续 UNIX 的广泛传播奠定了基础。然而,早期的 C 语言编译器缺乏现代的安全检查机制,程序员需要手动管理内存边界,这为缓冲区溢出漏洞埋下了隐患。
在恢复的 Unix v4 系统中,研究人员发现了完整的源代码树,包括核心实用程序的实现。这种 "自带源代码" 的设计哲学体现了早期 UNIX 的开放精神:用户不仅可以运行系统,还可以修改和重新编译它。正是这种开放性,使得我们今天能够深入分析并修复其中的安全漏洞。
su 程序:特权提升的经典实现
su(switch user)程序是 UNIX 系统中用于切换用户身份的核心工具。在 Unix v4 中,su的实现与现代版本功能相似:作为一个 setuid-root 可执行文件,它验证 root 密码,如果凭证正确,则生成一个 root shell,允许非特权用户提升权限。
让我们仔细分析 v4 中su.c的源代码(约 50 行):
/* su -- become super-user */
char password[100];
char pwbuf[100];
int ttybuf[3];
main()
{
register char *p, *q;
extern fin;
if(getpw(0, pwbuf))
goto badpw;
(&fin)[1] = 0;
p = pwbuf;
while(*p != ':')
if(*p++ == '\0')
goto badpw;
if(*++p == ':')
goto ok;
gtty(0, ttybuf);
ttybuf[2] =& ~010;
stty(0, ttybuf);
printf("password: ");
q = password;
while((*q = getchar()) != '\n')
if(*q++ == '\0')
return;
*q = '\0';
ttybuf[2] =| 010;
stty(0, ttybuf);
printf("\n");
q = crypt(password);
while(*q++ == *p++);
if(*--q == '\0' && *--p == ':')
goto ok;
goto error;
badpw:
printf("bad password file\n");
ok:
setuid(0);
execl("/bin/sh", "-", 0);
printf("cannot execute shell\n");
error:
printf("sorry\n");
}
程序逻辑相对直接:
- 调用
getpw()从/etc/passwd获取 root 用户的密码条目 - 禁用 TTY 回显模式并提示输入密码
- 逐字节从 TTY 读取到缓冲区,直到遇到换行符或 NUL 字符
- 重新启用回显模式,使用
crypt()库函数哈希输入并与存储的哈希值比较 - 如果匹配则生成 shell,否则终止
漏洞分析:缺失的边界检查
问题的核心在于第 3 步的输入循环。password缓冲区被声明为 100 字节的字符数组:
char password[100];
然而,读取循环完全没有边界检查:
q = password;
while((*q = getchar()) != '\n')
if(*q++ == '\0')
return;
如果用户输入超过 100 个字符,程序将继续写入password数组之外的内存区域,导致缓冲区溢出。这种溢出可能覆盖相邻的内存结构,包括返回地址、函数指针或其他关键数据。
研究人员通过输入长字符串成功触发了崩溃:
# su
password:<long input>Memory fault -- Core dumped
值得注意的是,并非所有长字符串都会导致核心转储。结果取决于覆盖了哪个相邻内存区域。有时su只是静默退出,这增加了漏洞检测的难度。
1973 年的修复工程:使用 ed 和 cc
修复这个漏洞的过程本身就是一次历史工程实践。在 1973 年,既没有 vi 也没有 emacs,系统提供的是ed—— 一个为电传打字机设计的行编辑器。研究人员使用ed编辑su.c,添加边界检查逻辑。
修复方案是在读取循环中添加一个计数器i,并在每次迭代时检查是否超过缓冲区大小:
--- a/s2/su.c
+++ b/s2/su.c
@@ -7,6 +7,7 @@ main()
{
register char *p, *q;
extern fin;
+ register int i;
if(getpw(0, pwbuf))
goto badpw;
@@ -22,9 +23,13 @@ main()
stty(0, ttybuf);
printf("password: ");
q = password;
- while((*q = getchar()) != '\n')
+ i = 0;
+ while((*q = getchar()) != '\n') {
+ if (++i >= sizeof(password))
+ goto error;
if(*q++ == '\0')
return;
+ }
*q = '\0';
ttybuf[2] =| 010;
stty(0, ttybuf);
修复过程展示了早期 UNIX 开发的工作流程:
- 使用
ed编辑源代码:# ed su.c - 定位到适当位置插入变量声明
- 修改 while 循环添加边界检查
- 写入文件并退出编辑器
- 使用 C 编译器重新编译:
# cc su.c - 部署修复后的二进制文件并设置正确的权限
整个修复过程完全在系统内部完成,无需外部工具链,这体现了早期 UNIX 的自包含设计哲学。
历史背景下的安全意识
Unix v4 中的缓冲区溢出漏洞反映了 1970 年代计算环境的安全假设。当时的系统通常运行在受信任的、隔离的环境中,安全不是首要考虑因素。正如原始 Bell Labs UNIX 团队成员 Doug McIlroy 所指出的:"可溢出的缓冲区在当时很常见。"
这种设计选择有几个历史原因:
- 性能考虑:早期计算机资源极其有限,边界检查会增加运行时开销
- 信任模型:系统假设用户都是可信的,或者物理访问受到严格控制
- 知识限制:利用缓冲区溢出执行任意代码的概念在当时尚未成熟
- 开发优先级:功能实现和系统稳定性优先于安全性
然而,即使在当时,一些安全风险已被认知。Ron Natalie 指出,通过消耗资源使getpw()调用失败的攻击向量在当时是已知的。这表明安全考虑已经开始进入系统设计者的视野。
现代缓冲区溢出防御策略
半个世纪后的今天,缓冲区溢出仍然是严重的安全威胁。CISA 和 FBI 在 2025 年仍就活跃利用的缓冲区溢出攻击发出警告。现代防御策略已从单纯的代码检查发展为多层次防护体系:
1. 编译时防护
栈金丝雀(Stack Canaries):在函数返回地址前插入随机值,在函数返回前检查该值是否被修改。如果金丝雀值被改变,程序立即终止。
数据执行保护(DEP/NX):标记内存页为不可执行,防止攻击者在栈或堆上执行注入的代码。
地址空间布局随机化(ASLR):随机化内存地址布局,使攻击者难以预测关键数据的位置。
2. 运行时防护
控制流完整性(CFI):验证间接跳转和调用的目标地址是否在预期的目标集合中。
影子栈(Shadow Stack):维护返回地址的副本,防止返回地址被篡改。
内存安全语言:逐步迁移到 Rust 等内存安全语言,从根本上消除缓冲区溢出风险。
3. 开发实践改进
安全编码规范:制定并强制执行安全编码标准,禁止使用不安全的字符串函数。
静态分析工具:集成静态分析工具到 CI/CD 流程,自动检测潜在的缓冲区溢出。
模糊测试:对输入接口进行模糊测试,发现边界条件错误。
历史漏洞的现代重现风险
尽管现代防御措施大大增加了缓冲区溢出利用的难度,但历史漏洞在现代环境中仍可能重现,原因包括:
- 遗留代码库:许多关键系统仍包含数十年前编写的 C/C++ 代码
- 嵌入式系统限制:资源受限的嵌入式设备可能无法启用所有防护机制
- 供应链风险:第三方库和组件可能包含未检测到的缓冲区溢出漏洞
- 配置错误:安全功能可能被错误配置或禁用
2024-2025 年发现的多个高危漏洞(如 CVE-2025-0282、CVE-2024-38812)证明,缓冲区溢出仍然是现代企业产品中的现实威胁。
可落地的防御参数与监控清单
基于 Unix v4 漏洞分析,我们提出以下可落地的防御策略:
编译参数配置(GCC/Clang)
# 启用栈保护
-fstack-protector-all
# 启用强化保护
-D_FORTIFY_SOURCE=2
# 启用位置无关可执行文件(支持ASLR)
-fPIE -pie
# 启用立即绑定
-Wl,-z,now
# 启用只读重定位
-Wl,-z,relro
运行时监控清单
- 栈溢出检测:监控程序异常终止,特别是与内存访问相关的信号(SIGSEGV, SIGBUS)
- 金丝雀值变化:记录栈金丝雀检查失败的日志事件
- ASLR 有效性:定期检查 /proc/[pid]/maps 确保地址随机化生效
- DEP/NX 状态:验证内存页权限设置,确保栈和堆不可执行
- 控制流异常:监控非预期间接跳转和函数返回
代码审查检查点
- 字符串操作:检查所有 strcpy、strcat、sprintf 等函数的使用
- 缓冲区声明:验证缓冲区大小是否足够容纳最大预期输入
- 循环边界:确保所有输入循环都有明确的边界检查
- 整数溢出:检查可能导致缓冲区大小计算错误的整数运算
- 指针算术:验证指针运算不会导致越界访问
从历史中学习的工程实践
Unix v4 缓冲区溢出修复案例为我们提供了几个重要的工程启示:
1. 安全演进是渐进过程
安全不是一次性添加的功能,而是随着威胁认知和技术能力发展而逐步演进的。从 1973 年的无边界检查,到今天的多层次防护,安全实践经历了数十年的积累和改进。
2. 工具链的重要性
早期 UNIX 的自包含设计(源代码 + C 编译器)使得安全修复能够在系统内部完成。现代开发环境应保持类似的敏捷性,确保安全工具集成到开发工作流中。
3. 向后兼容与安全权衡
维护遗留代码的安全需要平衡向后兼容性和安全性。有时需要接受一定的性能开销来启用安全功能。
4. 防御深度策略
单一防护措施不足以保证安全。现代系统需要实施防御深度策略,结合编译时检查、运行时防护和开发实践改进。
结论:跨越半个世纪的安全对话
Unix v4 缓冲区溢出漏洞的发现和修复,不仅是一次历史代码的考古发掘,更是一次跨越半个世纪的安全对话。这个 1973 年的漏洞提醒我们,某些安全问题具有惊人的持久性。
现代防御策略已经从单纯的代码修复发展为系统性的防护体系,但核心挑战依然存在:如何在性能、兼容性和安全性之间找到平衡点。历史告诉我们,安全意识的培养需要时间,安全实践的改进需要持续投入。
对于今天的工程师而言,研究历史漏洞的价值在于理解安全问题的本质,而不是简单地应用现成的解决方案。每个时代都有其特定的约束条件和设计选择,真正的安全智慧在于根据当前环境做出适当的权衡。
正如我们在 Unix v4 修复过程中看到的,即使是最基本的边界检查,也能显著提高系统的安全性。在追求复杂防御机制的同时,不应忽视这些基础但有效的安全实践。
资料来源:
- sigma-star.at 博客文章《Fixing a Buffer Overflow in UNIX v4 Like It's 1973》
- 现代缓冲区溢出防御策略相关技术文档
- CISA/FBI 关于缓冲区溢出攻击的安全公告