钓起一条 37 年的老鱼:OpenBSD 里的 16 位计数器,如何一步步换来 root shell
这周内核安全圈有个漂亮的活儿:安全公司 Calif 公开了一套完整的 OpenBSD 本地提权利用,代号 'Big Fish'。起因是一个 37 年前就写进 BSD 源码的共享内存挂载计数器——一个 signed 16 位 short,上限 32767,反复 fork 就能把它绕成负数,再配合一处越界写,在

这周内核安全圈有个漂亮的活儿:安全公司 Calif 公开了一套完整的 OpenBSD 本地提权利用,代号 "Big Fish"。起因是一个 37 年前就写进 BSD 源码的共享内存挂载计数器——一个 signed 16 位 short,上限 32767,反复 fork 就能把它绕成负数,再配合一处越界写,在 KARL、SMAP、SMEP 全开的情况下拿到 root。HN 评论区很快分成两派:一派感叹"被审计最多的代码库之一,bug 能活 37 年";另一派直接开问——fuzzing 和静态分析能发现这种 bug 吗?我请来老朋友陆沉——内核攻防研究员,干过操作系统安全加固,漏洞链的每一步都是他的舒适区。我们聊了聊这条链、这个计数器,以及它和你我写代码的关系。
本文由每日自动管线生成:选题与素材来自 Hacker News 公开讨论,访谈内容由 AI 基于原文整理,嘉宾为角色设定,观点不代表任何真实人物。
小听: 陆沉,先从你的第一反应开始。一个 16 位有符号计数器,最大值 32767,放在 OpenBSD 内核里 37 年,被人绕成负数拿下 root——最先戳中你的是哪一点?
陆沉: 是"37 年"和"OpenBSD"这两个词撞在一起。OpenBSD 是以偏执审计著称的项目,代码审查的密度在开源世界里排得上号。可这个计数器从上古 BSD 源码一路活到今天,说明了一个残酷事实:审计能发现的 bug,和攻击者能利用的 bug,不是一个集合。审计看的是"这段代码有没有错",攻击者看的是"这个错能不能连成链"。两者的思考方向完全不一样。
小听: HN 最高赞的评论之一就在讲这个:"在被审计最狠的代码库之一里活了 37 年,说明有些内核 bug 光靠 review 根本抓不到。"评论者直接追问——fuzzing 或静态分析现实中能发现这类 bug 吗,还是说非得靠人类想到 exploit primitive?
陆沉: 这是个好问题,分两层看。静态分析其实有机会:一个 16 位 short 被 fork 循环反复递增,任何带整数溢出检查的分析器理论上都会报警。但现实是内核里这类"理论上会溢出"的计数器成百上千,误报率把人都淹没了。fuzzing 更难——要触发溢出,得先做"映射 127 个 SysV 共享内存段、munmap 掉、再 fork 259 个进程"这种高度结构化的操作序列,随机 fuzz 几乎不可能恰好走出这条路。这就是为啥需要人类:漏洞本身不难,难的是"想象出一条能走到它的路"。
小听: 展开讲讲这条路。文章里列的利用步骤我看得有点晕:计数器先被绕到 -32643,然后是堆布局、竞态、IP socket、多播 TTL……这条链是怎么一节节接上的?
陆沉: 核心逻辑其实就四步。第一步,制造"悬空":把计数器绕成负数后,内核对"还有多少进程挂着这块共享内存"的记账就错了,这就埋下了一个 use-after-free 的竞态窗口——用 IPC_RMID 在一个进程里删,另一个进程同时 attach 一块"尺寸被搞乱"的段,就能赢下竞态。第二步,堆 grooming:用 sysctl(KERN_MALLOCSTATS) 看内核堆的分配情况,把内存布局摆成自己想要的样子,相当于钓鱼前先打窝。第三步,拿到"写一笔"的能力:通过 IP socket 的多播选项分配一块 16 字节的结构体,用一处条件性的 -1 越界写,把它的两个 16 位计数字段一起改掉。第四步,把这一笔写在最关键的地方:close() socket 触发成员遍历,原子递减操作两次改写页表自映射,把用户态映射翻成内核态可访问,最后改掉一个位置可预测的内核指针,拿到代码执行。

有些计数器,三十七年都没人多看一眼。
小听: 评论区有个细节我觉得很有意思:有人指出 NetBSD 在 1999 年重写那部分代码的时候,无意中把这个 bug 修掉了。OpenBSD 这边反而因为"保守"把它留到了今天。这是不是说明,重构有时候比审计更管用?
陆沉: 有一定道理,但别过度解读。NetBSD 那次是运气好——重写顺手换了数据类型,不是有人发现了 bug。这恰恰说明这个 bug 的隐蔽性:它不是逻辑错,是"类型选小了",代码本身跑得好好的,37 年没炸过。重构能顺手消灭它,靠的也是运气。真正系统性的解法 Calif 在文章后半部分给了:消除整个漏洞类别,而不是追着单个 bug 跑。
小听: 说到这个,文章里最让我佩服的一句是:整个利用是在 KARL、SMAP、SMEP 全开、且没有任何信息泄露漏洞的情况下完成的。KARL 可是 OpenBSD 的看家本领,内核地址随机化——他们是怎么绕过去的?
陆沉: 这就是这条链最漂亮的地方。KARL 随机化的是内核代码和数据的地址,但内核的内存分配器本身是确定性的——早期启动时的分配在同一硬件上每次都落在可预测的位置。Calif 把这次利用抽象成一个 "write-what-where" 原语:write 来自链表删除操作 TAILQ_REMOVE 的内存写,what 是他们自己那块被翻成内核态可访问的用户内存(顺手绕过 SMAP/SMEP),where 就是那个位置可预测的内核指针——比如 lo0 和 enc0 的 ioctl 处理函数,改一个拿控制流,用另一个恢复现场,干净利落。现代缓解机制每一层都被尊重了,但攻击者走的是缓解机制之间的缝隙。
小听: 所以 Calif 给的缓解建议也都是"堵缝隙"式的:链表安全删除、页表自映射基址随机化、分配器基址和分配顺序随机化,还有编译器的数组边界检查器。这思路和 OpenBSD 一贯的"主动加固"哲学是一脉相承的。
陆沉: 对,OpenBSD 的哲学从来不是"修掉这个 bug",而是"让这一类 bug 再也形不成利用链"。safe-unlinking 让链表破坏直接 panic;PTE 自映射随机化让攻击者算不准要改的位置;分配器随机化打破可预测的指针位置;数组边界检查器直接掐掉越界写这一整类。文章最后还点了一句:在 AI 辅助漏洞挖掘的时代,单个 bug 会被越挖越多,唯一可持续的防御就是系统性地抬高整类利用的成本。这句话值得所有做系统的人抄下来贴墙上。

现代内核的每一层装甲,都挡不住一处上古遗留的裂缝。
小听: 评论区还有条玩笑:"可怜的 OpenBSD,他们现在得在主页上承认总共有三个 exploit 了。"OpenBSD 主页那个著名的"仅有两个远程漏洞"的宣传,这次是本地提权,不算数——但玩笑背后是不是有点心酸?
陆沉: 一点不心酸,反而是勋章。你想,37 年的老 bug,被发现后 OpenBSD 照单全收发了两个 errata,Calif 还顺手给了四个系统性缓解方案。这种"被公开处刑还笑着接招"的生态,恰恰是 OpenBSD 安全声誉的来源。真正该心酸的是那些 bug 更多、但没人去挖也没人敢公开的项目。漏洞数量从来不是安全指标,修复速度和加固意愿才是。
小听: 最后一个问题,拉回我们普通开发者。这条 37 年的 bug,对今天写业务代码、写基础设施的人,有什么拿得走的教训?
陆沉: 三个。第一,整数类型不是小事:short 还是 int,在计数器场景下是安全决策,不是风格问题,code review 里看到窄类型计数器就该多问一句上限。第二,"跑得好好的"不等于"没 bug":这个计数器 37 年没炸过,不是因为它对,是因为没人走那条路——测试覆盖的是"正常路径",攻击者走的是"可能的路径"。第三,也是最重要的:别只修 bug,要修 bug 的"形状"。你今天修掉一个越界写,明天还会有;但如果你加上边界检查、随机化、类型收紧,整类问题就消失了。从"修点"到"修面",这是 Calif 这篇文章真正想教的东西。