一串合法数字,musl 把 0 写到了 x[-1]
字数 3830
更新时间 2026-10-25 20:13:32

一串合法数字,musl 把 0 写到了 x[-1]

一句话结论:musl 的 long double 十进制解析函数在把定点块折算成浮点值时,有一行把「取环形尾指针的旧值」和「让尾指针前进」揉进了一个只对加法取模、把减法留在模外的表达式;当尾指针恰好处在环的最大下标时,这个表达式算出写入下标负一,一次四字节的零值写入由此落在尾数缓冲区起点下方的一格。编译器不告警、静态分析不出声、valgrind 零错误——这越界的 4 个字节只能靠自己去读内存才能看见。本文完整保留成因、落点、检测与修复,不复述可直接照抄的触发输入串。

排版说明:正文按「现象 / 缓冲结构 / 成因 / 可达状态 / 正确写法对照 / 落点 / 影响范围 / 工具为何沉默 / 修复与验证 / 检测缓解」组织;索引表达式、宏名、十六进制哨兵按原样书写(不含转义符),引号统一用直角引号,触发输入只描述其特征、不列成品串。

一、一行单字符修复

musl 作者推送过一个只改一行的补丁:改动前后是同一个判断分支,只差运算符的摆放。要看清这个缺陷,真正需要弄明白的是三件事——那个负一下标是怎么算出来的、它写进了内存里的哪一格、什么样的数据能把它逼到那一步。它没有 CVE 编号、没进官网安全公告,修复当时只在 git 主分支,官方发布通道上的最新版本仍然受影响。

二、decfloat 与它的尾数环形缓冲

strtod、strtold 以及 scanf 的浮点转换最终都落到内部的浮点扫描函数,由它为 long double 准备一个解析十进制数字串的分支。定点化的第一步不是浮点运算,而是把数字按每 10 的 9 次方一组切成块,逐块写进栈上的一个定长数组,每个元素是 32 位、承载一个十进制块。

关键在于这个数组不是顺序使用的,而是一条环形缓冲。尾数位的处理会把数据块的相对位置整体搬移,为避免每次都把约 8 KiB 从头挪一遍,实现用「回绕游标」而不是「搬数据」的方式工作:一个游标 a 指向有效数据起点、向低位方向增长、随时可能从 0 回绕到环尾;另一个游标 z 指向有效数据结束后的那一格、取值遍及整个环。环长由 long double 的表示法经条件编译决定,掩码 MASK 是环长减一——x86-64 的 80 位扩展精度落到掩码 2047、double 档落到掩码 127。

三、成因:只对加法取模、把减法漏在模外

出问题的赋值把三件事压进了同一个表达式:更新 z、取更新前的 z、用该值减一作为写入下标。它的用意是「环形尾指针追上读取位置时,补一个零块并让尾指针前进」,而取模只落在加法上、减法留在了外面:形如「z 等于(z 加一与掩码相与)再减一」的写法。

它是「z 加一再减一」的取模版本,但只对加法做了取模。当 z 取到掩码那一刻,中间子表达式走到临界:掩码加一与掩码相与得到 0,再减一得到负一,写入地址随之落在数组起点下方一个 32 位整数的位置。整个过程全是整数索引运算,不涉及类型转换、不依赖任何未定义行为或编译器优化——只要这行被命中,越界写就是必然的。

四、这个临界状态真实可达

尾指针 z 的合法取值本就遍及整个环,停在最大值是完全正常的游标位置,不是纸面上的极值假设。它是否恰好停在环尾,由放大与收缩两个阶段搬运数据块的结果决定:收缩阶段会直接把 z 赋成一个在环上反向移动的游标值、可取到掩码;回绕后两游标相遇时还会再做一次减一取模。把一段真实触发输入的几轮放大连起来看,走向很清楚:第一轮把起点游标推到环的高端、尾指针收缩到 1;第二轮让尾指针退到 0、两游标分居环的两端;第三轮开头那个反向游标落在空的环尾格上、于是尾指针被定在掩码、起点游标回绕;此后进入折算阶段,读取位置正好与尾指针重合、补零分支被触发,写入下标即为负一。

五、同一个函数里就有正确写法

收缩阶段也需要把进位块写回环尾并前移指针,那处的写法是稳妥的:先取模、后赋值,写入与指针更新彼此独立。折算阶段要做的本是同一类操作,却把「取旧值」和「前移指针」揉进一个带取模的表达式——这就是缺陷与正确写法之间唯一的差别。

六、越界写落到了哪里

数组起点下方那一格存的是什么,取决于编译器如何排布栈帧。在未优化构建里,那里恰好压着一个函数参数的保存槽高位,这次越界写把传入的文件指针上半部清成了零;之所以没被察觉,是因为该参数在折算阶段之后不再被读取,返回值与崩溃检测都看不到异常——写入发生了,被写坏的变量却恰好退出了使用范围。换一个优化级别,数组落到栈上别处、越界写覆盖的就是编译器另作安排的栈内容;换句话说,被覆盖的槽位完全随栈布局而变。

必须把「越界写发生」与「产生可察觉后果」分成两件事:这次破坏的栈槽恰好不被后续使用,缺陷因此表现为静默的内存破坏。它在目标代码变化、内联决策变化或编译器版本升级之后,随时可能翻转为崩溃或错误结果——不能拿「当前跑不出问题」当安全结论。实测中,插桩(把数组下移一格、起点下方埋一个哨兵值)和调试器的硬件写观察点,是唯一能直接看到这次越界的手段:观察点让调试器在写入真正发生的那一刻停住,读到那一格从哨兵值被改成零、被覆盖对象的高位从合法值归零,而进程随后照常退出、返回值仍是正确结果。

七、影响范围

环长由 ABI 决定、而环长直接决定那个下标能否为负。80 位扩展精度与 quad 精度共用掩码 2047、double 档用掩码 127,前两档在本次测试里都走到了越界写。x86-64、aarch64、mips64 这些平台的 long double 落在这些档,静态链接 musl 的容器镜像、嵌入式固件与相应发行版是其现实载体。

触发面完全落在「把字符串交给 C 库做十进制浮点解析」这条路径上:这条路径没有协议、没有文件格式,唯一前置条件就是被解析的数字串本身——标准库的浮点转换入口、scanf 家族的浮点格式符、以及各语言运行时代转发到 C 库浮点解析的路径(脚本解释器的数字字面量、JSON 与配置解析器),只要允许攻击者控制被解析的字符串,构造触发输入的余地就存在。命中与否由尾数位在环上的落点决定、只有特定一族的尾数能让折算阶段的尾指针停在掩码、命中的指数成窄区间分布。本文有意不列出任何一条成品触发串:给出「某族幂次尾数配特定量级负指数」这一特征已足以说明可达性,照抄清单只会放大风险。

版本方面,从较早的若干发布版一直到当前最新版都保留了修复前的写法,逐版本编译产物去掉调试信息与时间戳后逐字节相同——补零写入落在同一位置、指令序列连机器码都一样,这段代码多年未变,二进制比对反而看不出它曾有问题。修复当时只在源码主线上,官方发布通道仍是受影响版本,这构成一个窗口期。

八、为什么所有检测工具都沉默

这是本缺陷最值得记的一课。按标准编译参数打开与越界相关的告警重编一遍,会得出一堆括号与符号比较类告警,但数组越界相关的告警一条也没有:写入下标由运行期的取模运算决定,编译器无从把游标收敛到掩码,于是这条语句在编译期看起来与普通环形缓冲写法毫无区别。主流静态分析器返回码为零、输出为空;动态检测工具在这套目标上同样接不上——内存错误检测器与静态链接互斥、未定义行为检测器的运行时依赖另一套 C 库接口,静态 musl 目标两者都用不了,连跑带越界写的输入也不报任何错。三条静态路径加动态路径,在「越界写」这一项上全部静默。

九、修复与验证

补丁本身只有一行,就是把「取旧值」和「前移指针」拆成两步:先把零写进当前位置,再让游标加一取模。两种写法在游标不等于掩码时算出同一个下标,只有停在掩码的那一次不同:修复前把零写到数组起点下方一格,修复后把零写进数组末尾的合法格、再把游标复位。缺陷只在特定输入下暴露,其余情况两版行为一致。在探针源码上应用这个改动重编译,越界计数从零恢复、哨兵保持完好、返回值与修复前一致——修复没触碰数值语义,只把下标运算摆回了它该在的位置。两版写法的差别浓缩成一句:回绕那一步的减法,必须也在模内。

十、检测与缓解

对构建方而言,最省事的核对是不依赖任何高级工具链的两处静态检查:一是比对源码里这一判断分支是不是修复前的揉合写法,二是读浮点类型的尾数位宽宏判断本平台落在哪一档环长,掩码为 2048 或 128 两档的都要继续排查。串成一条顺序是:先语句比对确认构建树里的源文件是不是修复前写法,再读位宽宏判断环长,落进受影响档的再用「把数组下移一格、起点下方埋哨兵」的插桩或在写入指令前设条件断点,看游标能否在补零分支里取到掩码。判断发行版是否已回移补丁有个直接办法:编译出目标文件、在反汇编里找那段下标计算,修复前带一条有符号索引减法加符号扩展指令、修复后它随语句一起消失。

修补路径两条:等待受影响版本之后的官方发布版,或自行应用这一单行改动;用发行版打包时要确认打包方是否已回移。应用侧兜底可以对进入浮点解析的字符串做约束——触发输入有可辨识的形态特征(较长的十进制尾数配上绝对值与位数大致相当的负指数),限制有效位数或指数绝对值都能收窄触发面。这个缺陷的通用教训是:安全语义的漏洞不只在「逻辑判断写错」,也在「索引运算在回绕处的边界」;而这类靠运行期取值才暴露的越界,恰恰是编译器告警、静态分析与常规动态检测最容易集体漏掉的一类——此时唯一可靠的,是自己去读那被破坏的一格内存。

相似文章
相似文章
小程序二维码
 全屏