一串合法数字,musl 把 0 写到了 x[-1]
编译器不告警,静态分析不出声,valgrind 零错误——这 4 个字节只能自己去读。
编译器不告警,静态分析不出声,valgrind 零错误——这 4 个字节只能自己去读。
一、一行单行修复
2026 年 9 月 22 日,musl 作者 Rich Felker 向 git 主分支推送了一个只有一行改动的补丁(cgit 提交页):

改动前后是同一个判断分支,只差一点运算符的摆放:

x 是 decfloat() 里承载尾数位的栈上数组,z 是环形游标,MASK 是环长减一。当 z 恰好取到 MASK 时,修复前的 (z=(z+1 & MASK))-1 会把 z 更新为 0,给写入下标留下 -1,一次落在数组起点下方的 4 字节写入由此成立。
补丁说明里附了一个已经确认能触发的输入:

这个缺陷没有分配 CVE,也没有进入 musl 官网的安全公告列表,修复目前只存在于 git 主分支,官方发布通道上最新版本仍然受影响。所以真正需要看清楚的是三件事:那个 -1 是怎么算出来的,它写进了内存里的哪一格,以及什么样的数据能把它逼到那一步。
二、decfloat 与它的尾数缓冲
2.1 从字符串到定点块
strtod、strtold 以及 scanf 的浮点转换最终都会落到 __floatscan(),由它为 long double 准备一个叫 decfloat() 的分支来解析十进制数字串。定点化的第一步不是浮点运算,而是把数字按 10^9 一组切成块,逐块写进栈上的数组:
数组的规模由 long double 的表示法决定,decfloat() 所在文件的上半部分把这层选择写成了条件编译:
# 打印 floatscan.c 第 12 至 34 行:条件编译在这里决定 KMAX 与 MASK
nl -ba src/internal/floatscan.c | sed -n '12,34p'
x86-64 上 long double 是 80 位扩展精度,落进第 18 行分支:LD_B1B_DIG=3、KMAX=2048、MASK=2047。缓冲区是栈上的一个定长数组,每个元素 32 位,承载一个 10^9 进制块:
# 打印第 65、66 行:数组声明与 10^9 进制块的上限表
nl -ba src/internal/floatscan.c | sed -n '65,66p'
2.2 两个游标围着同一段内存转
x 在这段代码里不是顺序使用的数组,而是一条环形缓冲。尾数位的处理过程会把数据块的相对位置整体搬移,为了避免每次都把 8 KiB 从头挪一遍,实现用回绕的方式搬游标而不是搬数据:
尾数解析完成后,游标在这里初始化:
# 打印第 161 至 164 行:尾数解析结束处的游标初始化
nl -ba src/internal/floatscan.c | sed -n '161,164p'
两个游标分工明确:a 指向有效数据的起点,向低位方向增长,随时可能从 0 回绕到 2047;z 指向有效数据结束后的那一格,取值遍及整个环。这里 z 接过当前块数,a 从 0 开始。
2.3 出问题的那行赋值
文件第 257 行进入 assemble 阶段,把定点块折算成最终的浮点值:
# 打印第 257 至 261 行:assemble 阶段的折算循环
nl -ba src/internal/floatscan.c | sed -n '257,261p'
第 259 行把三件事压进了同一个表达式:更新 z、取更新前的 z、用该值减一作为写入下标。它的用意是“环形尾指针追上读取位置时,补一个零块并让尾指针前进”,而取模只落在加法上,减法被留在了外面。
2.4 补零分支为谁而设
z 的角色在这些阶段里一直在变:upscale 时它是有效块的结束位置,第 209 行的收缩会把更多格并入其中;进入 assemble 之后它又被当作读取终点。补零分支处理的正是“读取位置已经抵达数据尾”的情形,也正因为如此,它是把越界写带进执行路径的那扇门。
assemble 要把 LD_B1B_DIG 个块折算进尾数位,如果有效数据不足这么多块,读取游标 a+i 就会先撞上数据尾后位置 z,那一格里没有真实数据可读,(a+i & MASK)==z 承担的正是“读到数据尾”的判据,后面的写入负责把这一格清零并把尾指针前移。
三、回绕那一格
3.1 z 等于 MASK 时的求值
把 a=2045、z=2047、MASK=2047 代入第 259 行的判据:i=0 时 (a+i)&MASK=2045 不命中,i=1 时 =2046 不命中,i=2 时 =2047 命中,写入下标为 ((2047+1)&2047)-1 = -1,与靶机实测记录下的 last_z=2047 一致。
z 的合法取值是 0 到 2047,2047 本身是完全正常的游标位置。它停在这里时,第 259 行的中间子表达式走到临界:(z+1 & MASK) 得到 2048 & 2047,也就是 0;再减一,得到 -1。写入地址随之落在 x[-1],位于缓冲区起始地址下方一个 uint32_t 的位置。
整个过程都是整数索引运算,不涉及类型转换,也不依赖任何未定义行为或编译器优化。换句话说,只要这行语句被命中,越界写就是必然的。
3.2 z 为什么会停在 MASK
z 的最终取值由 upscale 与 downscale 两个阶段搬运数据块的结果决定,其中有两处会改写它:
# 打印第 209 至 220 行:upscale 与进位处理中改写 z 的两处
nl -ba src/internal/floatscan.c | sed -n '209,220p'
第 209 行直接把 z 赋成 k,而 k 是一个在环上反向移动的游标,它可以取到 2047;第 214 行 a 反向回绕之后,如果与 z 相遇,还要再做一次 (z-1 & MASK)。也就是说,z == MASK 不是纸面上的极值假设,而是这条路径上真实可达的状态。
把触发输入 710542735760100185871124267578125e-33 的三轮放大连起来看,走向很清楚:
第一轮放大把 a 推到环的高端 2047,z 收缩到 1;第二轮让 z 退到 0,两个游标分居环的两端;第三轮开始时 k=(z-1 & MASK)=2047,该格为空且不等于 a,第 209 行的赋值把 z 定在 2047,进位把 a 带回 2045。此后 downscale 不再改动两者,assemble 读到第三个位置时正好与 z 重合,条件成立,写入下标为 -1。
3.3 同一个函数里的正确写法
downscale 阶段也需要把进位块写回环尾并前移指针,那里的写法是稳妥的:
# 打印第 249 至 254 行:downscale 里进位块回写环尾、指针前移的稳妥写法
nl -ba src/internal/floatscan.c | sed -n '249,254p'
第 252 行先取模、后赋值,写入与指针更新彼此独立。assemble 阶段要做的本是同一类操作,却把“取旧值”和“前移指针”揉进一个带取模的表达式,写成 (z=(z+1 & MASK))-1——它看起来像 (z+=1)-1 的取模版本,实际只对加法做了取模,回绕那一步的减法漏在了外面。
四、动态验证
4.1 实验环境与构建
Kali GNU/Linux Rolling
Linux 6.19.11+kali-amd64 x86_64
gcc (Debian 15.2.0-16) 15.2.0被审源码取自官方发布包 musl-1.2.6.tar.gz,按官方流程解开并构建:
# 取官方发布包并解开
wget https://musl.libc.org/releases/musl-1.2.6.tar.gz
tar xf musl-1.2.6.tar.gz
cd musl-1.2.6
# 静态库构建,安装到 /home/nl/musl-lab/install
./configure --disable-shared --prefix=/home/nl/musl-lab/install
make -j"$(nproc)"
make install
# 让 musl-gcc 指向本次构建的工具链
export PATH=/home/nl/musl-lab/install/bin:$PATH
工具链来自本次构建,musl-gcc 指向 install/bin,此后各节的编译与链接都走它;被审文件单独重编译时复用构建树里的头文件搜索路径。
源码指纹:
# 行数与 md5,后续版本对照都用这份指纹
wc -l src/internal/floatscan.c
md5sum src/internal/floatscan.c
测试程序全部用 musl-gcc -static 静态链接,不涉及与 glibc 混链。被审文件的插桩版本用发行版 gcc 单独重编译,再用 ar r 替换进构建树中的 lib/libc.a,既保留原始编译流程,又能插入探针代码:
# 用发行版 gcc 把插桩版被审文件编成目标文件,头文件路径取自构建树
gcc -c -O2 -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude floatscan_probe.c -o floatscan_probe.o
# 复制一份静态库,把这个目标文件换进去
cp /home/nl/musl-lab/install/lib/libc.a libc_probe.a
ar r libc_probe.a floatscan_probe.o
# 两个驱动都静态链接这份插桩库:单条输入用 t_probe,批量语料用 t_fuzz_probe
musl-gcc -static -O2 -o /tmp/t_probe t_probe.c libc_probe.a
musl-gcc -static -O2 -o /tmp/t_fuzz_probe t_fuzz.c libc_probe.a
4.2 插桩探针
要判断"某次写入是否越过数组下界",光靠打印数值看不出来,需要在紧贴数组起点的下方放一个哨兵。改动集中在三点,被审语句本身逐字符保留:
uint32_t x_storage[KMAX+2];
uint32_t *const x = x_storage + 1;数组向上扩两个元素、x 指向下标 1,于是 x[-1] 落进 x_storage[0],一次本该破坏相邻栈槽的写入变成能被记下的事件。函数入口把 x_storage[0] 置为哨兵 0xDEADBEEF,返回前检查:
for (y=i=0; i<LD_B1B_DIG; i++) {
if ((a+i & MASK)==z) {
if ((int)((z+1 & MASK) - 1) < 0) { __floatscan_oob_write++; __floatscan_last_z = z; }
x[(z=(z+1 & MASK))-1] = 0;
}
y = 1000000000.0L * y + x[a+i & MASK];
}
if (x_storage[0] != 0xDEADBEEFu) { __floatscan_oob_write++; __floatscan_guard_broken = 1; }探针设有两个相互独立的检测点。一是对 (z+1 & MASK)-1 的符号判定,只依赖 a、z、MASK 的取值,与数组放在哪里无关,因此在未插桩的原始构建里同样成立;二是哨兵检查,把"确实落到了数组外"这一事实固定下来。数组下移不改变任何一步索引运算,a、z 的演化与原始代码逐位一致。
测试程序把输入交给 strtold,再打印探针导出的计数变量:
extern int __floatscan_oob_write, __floatscan_last_z, __floatscan_guard_broken;
int main(int argc, char **argv)
{
char *end;
long double v = strtold(argv[1], &end);
printf("len=%-5zu oob=%d guard_broken=%d last_z=%d consumed=%zu val=%.22Le\n",
strlen(argv[1]), __floatscan_oob_write, __floatscan_guard_broken,
__floatscan_last_z, (size_t)(end - argv[1]), v);
return 0;
}4.3 触发结果
# 用上游点名的输入跑单条驱动
/tmp/t_probe '710542735760100185871124267578125e-33'
oob=2 表示两个检测点各记一次:索引判定命中一次,哨兵被覆盖一次。last_z=2047 是触发瞬间的 z,guard_broken=1 说明写入确实越过了数组起点。返回值 7.10542735760100185871e-01 与 710542735760100185871124267578125e-33 的精确值一致,也就是说这一次内存破坏没有改变计算结果。
4.4 阶段轨迹
在探针基础上加阶段打点,逐轮记下 z 从 4 到 2047 的过程:
# 带阶段打点的驱动,把 upscale / downscale / assemble 各轮游标状态写向标准错误
/tmp/t_diag '710542735760100185871124267578125e-33'
三行 UP:end 是理解触发条件的关键:第一次放大后 a 已经绕到环的高端,第二次放大把 z 退到 0,第三次放大让 k=(z-1 & MASK) 落在空的 2047 上,z 因此被赋值为 2047,a 回绕到 2045。此后两个游标不再变化,assemble 读到第三个位置时与 z 重合。
downscale 的 24 次进入里,a、z、rp 恒为 2045、2047、27,状态没有变化,输出块中未逐行列出。
结果行由驱动写向标准输出,阶段打点写向标准错误;标准输出为块缓冲,进程退出时才落盘,因此结果行在打点之后出现。
4.5 触发输入的分布
触发条件由尾数位在环上的落点决定,不是任意数字串都能命中。用 5 的幂次尾数配 141 档指数(-120 至 20)拼成 112800 条语料,逐条过探针版 strtold:
# 生成语料:800 个 5 的幂次尾数 × 141 档指数(-120 至 20),每行一条输入
python3 - <<'PYEOF'
with open('pow5.txt', 'w') as f:
for k in range(800):
for e in range(-120, 21):
f.write('%se%d\n' % (5**k, e))
PYEOF
# 逐条过探针,结果落进 pow5.out
/tmp/t_fuzz_probe < pow5.txt > pow5.out
# 语料总条数
wc -l pow5.txt
# 命中条数:每行前两个字段是写入计数与哨兵状态,非零即为命中
awk '$1+$2>0' pow5.out | wc -l
112800 条输入命中 457 条,覆盖 77 个尾数,且这 77 个尾数全部是 5 的幂次,编号从 530 到 5106 把尾数随机化到 5 至 90 位、指数铺开到 -600 至 60,100 万条输入一条也没有命中。
命中集中在同一族尾数上不是巧合:十进制展开的位数与进位位置决定尾数位在环上的落点,只有 5 的幂这类尾数能让 assemble 阶段的 z 停在 MASK。命中的指数也成区间分布,同一尾数下相邻几档指数都能触发,六档可用窗口就在其中:
710542735760100185871124267578125e-33
710542735760100185871124267578125e-34
710542735760100185871124267578125e-35
710542735760100185871124267578125e-36
710542735760100185871124267578125e-37
710542735760100185871124267578125e-38这个尾数与上游补丁点名的触发输入一致,可用窗口为 e-33 至 e-38 共六档指数。触发与否由指数决定:指数改变尾数位在环形缓冲中的落点,也就决定 assemble 阶段 z 是否停在 MASK。补丁说明对此的表述是,条件是否成立取决于尾数位处理完之后落在循环缓冲区的位置。
4.6 gdb 下的逐次读取
插桩解决了"有没有写到数组外",落点则要直接读内存才看得实。用未插桩的原版源码重新编译一份带 -O0 -g 的 libc:
# 用原版源码编一份带调试信息、不开优化的目标文件,替换进静态库
gcc -c -O0 -g -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude src/internal/floatscan.c -o fs_O0.o
cp /home/nl/musl-lab/install/lib/libc.a libc_O0.a
ar r libc_O0.a fs_O0.o
musl-gcc -static -O0 -g -o /tmp/t_gdb_o0 t_gdb.c libc_O0.a
驱动只做一件事,把参数交给 strtold 并打印结果:
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv){ char *e; long double v = strtold(argv[1], &e); printf("val=%.22Le\n", v); return 0; }在 assemble 循环的写入语句前后各下一个断点,反复读取 f 与被写入位置的内容:

三组快照对应 assemble 循环的三次迭代,也正好复现了前面推演过的三条索引:i=0 读到 2045、i=1 读到 2046,都没有撞上 z=2047,条件不成立,f 与 x[-1] 保持原样;i=2 读到 2047,与 z 重合,写入发生——x[-1] 从 0x00007fff 变成 0x00000000,同一时刻 f 从 0x00007fffffffdca0 变成 0x00000000ffffdca0,高 32 位被清零。
x[0] 全程为零,说明这次写入没有碰到数组内部任何一格有效数据,被改掉的完全落在缓冲区起点下方。进程随后正常退出,返回值仍是 0.710542735760100185871124267578125,与修复前的取值一致。
4.7 写入瞬间的硬件观察点
上面的读取停在语句上,写入前后之间还隔着几条指令。把硬件写观察点架在被改动的 4 字节上,可以让 gdb 在写入真正发生的那一刻停住:

首停发生在 assemble 的第一次迭代,此时 f 还是 0x7fffffffdca0,高 32 位为 0x00007fff(十进制 32767)。观察点架在 f 的高 32 位上,第三次迭代的写入一落地就把它从 32767 改成 0,同一次停顿里读到 f 已经变成 0x00000000ffffdca0,x[-1] 归零。写入动作、被覆盖的对象、越界的下标三者在这一刻对上了。
两次读取落在同一个地址上,是同一环境下栈布局稳定的结果;换一个运行环境,地址会随 ASLR 与调用上下文浮动,判断依据始终是高 32 位的取值变化。
4.8 优化构建下的复现
换成 -O2 重新编译被审文件并链接同一个驱动:
# 同样的流程再来一遍,这次被审文件开 -O2
gcc -c -O2 -g -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude src/internal/floatscan.c -o fs_O2.o
cp /home/nl/musl-lab/install/lib/libc.a libc_O2.a
ar r libc_O2.a fs_O2.o
musl-gcc -static -O2 -g -o /tmp/t_gdb_o2 t_gdb.c libc_O2.a
这次断点处连参数都印不出来了:

f 与 c 已经被优化进寄存器,栈上不再有它们的独立副本,观察点也就没有落点。要在这套构建下拿到落点,可以在数组起点下方预先写一个哨兵:-O2 把数组放在 $rsp+0x50,$rsp+0x4c 正好是它的前一个元素:

数组基址 $rsp+0x50 不是猜出来的:-O2 的目标码里数组的存取统一写成 0x50(%rsp, 下标, 4) 的寻址形式,下标寄存器随取值来源在 %rax、%rcx、%rbp 之间变化,基址部分固定为 $rsp+0x50,反汇编里能直接读到。哨兵从 0xdeadbeef 回到 0,说明优化构建下这条写入依然越过数组起点,落点正是数组前一格;-O0 下这个位置存放的是 f 的高 32 位,-O2 下换成了编译器另作安排的栈内容,被写坏的永远是数组起点下方的那一格。整个过程里进程没有崩溃,驱动照常打出 val=7.1054273576010018587112e-01。
4.9 double 档的复现与两档对照
环长由 long double 的表示法决定,double 平台落在 KMAX=128 这一档,LD_B1B_DIG 也从 3 变成 2。把探针源码里这两个宏改成这一档的取值,其余部分不动:
# 先建立输出目录
mkdir -p /tmp/dbl/relay
# 把探针源码里的三个宏换成 double 档取值(环长 128、块数 2、10^9 进制上限表)
sed -e 's/#define LD_B1B_DIG 3/#define LD_B1B_DIG 2/' -e 's/#define KMAX 2048/#define KMAX 128/' -e 's/#define LD_B1B_MAX 18, 446744073, 709551615/#define LD_B1B_MAX 9007199, 254740991/' floatscan_probe.c > probe_dbl.c
# 编译并替换进静态库,再链接出 double 档的批量驱动
gcc -c -O2 -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude probe_dbl.c -o probe_dbl.o
cp /home/nl/musl-lab/install/lib/libc.a libc_dbl.a
ar r libc_dbl.a probe_dbl.o
musl-gcc -static -O2 -o /tmp/dbl/relay/t_fuzz_dbl t_fuzz.c libc_dbl.a
驱动按参数逐条解析,每次输出四个字段:写入计数、哨兵状态、命中瞬间的 z、输入长度。
# 三条输入:前两条是本档已知命中,第三条是 80 位档的触发输入
/tmp/dbl/relay/t_fuzz_dbl 2910383045673370361328125e-26 14551915228366851806640625e-27 710542735760100185871124267578125e-33
前两条命中,命中瞬间的 z 是 127,也就是这一档的 MASK;guard_broken=1 说明写入落在 x_storage[0],越过了数组起点。同一条 37 位输入在 80 位档命中、在 double 档反而不命中(第三行输出 0 0 -1),两档的触发条件并不相同。
用 5 的幂次尾数配指数窗格做成语料,逐条过 double 档探针:112800 条输入命中 230 条,涉及 53 个尾数,命中指数落在 e-26 至 e-69;把尾数随机化到 5 至 90 位、指数铺开到 -600 至 60,100 万条输入一条未命中。命中全部集中在 5 的幂次这类尾数上:指数变化改变尾数位在环上的落点,也改变 assemble 阶段 z 是否停在 127。
把两档的命中窗口并排看:
python3 - <<'PYEOF'
EK = list(range(-120, 21))
def load(path):
hit = {}
rows = [l.split() for l in open(path) if l.strip()]
for idx, r in enumerate(rows):
k, j = divmod(idx, len(EK))
if int(r[0]) + int(r[1]) > 0:
hit.setdefault(k, []).append(EK[j])
return hit
dbl, wide = load('pow5dbl.out'), load('pow5.out')
def rng(v):
if not v: return '-'
v = sorted(v)
return 'e%d' % v[0] if len(v) == 1 else 'e%d 至 e%d' % (v[0], v[-1])
print('5^k 尾数位数 double 档(KMAX=128) 80 位档(KMAX=2048)')
for k in range(30, 107):
print('5^%-6s %-10d %-22s %s' % (k, len(str(5**k)), rng(dbl.get(k, [])), rng(wide.get(k, []))))
print('命中尾数 double=%d 80 位档=%d;命中条数 double=%d 80 位档=%d' %
(len(dbl), len(wide), sum(map(len, dbl.values())), sum(map(len, wide.values()))))
PYEOF5^k 尾数位数 double 档(KMAX=128) 80 位档(KMAX=2048)
5^30 21 - e-21
...
5^59 42 e-49 至 e-43 e-50 至 e-40
...每一行是同一尾数在两档下的命中指数区间。double 档命中的 53 个尾数全部落在 80 位档命中的 77 个尾数之内;double 档窗口最宽 7 档,集中在 556 至 567 一带,80 位档最宽 11 档,落在 558、559 两行(e-49 至 e-39 与 e-50 至 e-40)。另有 24 个尾数只在 80 位档命中,例如 5^30 配 e-21,double 档输出 0 0 -1,80 位档输出 2 1 2047。两档的差别与环长有关:double 档的环只有 128 格,游标回绕一圈所需的数据块数更少,能让 z 停在 127 的指数窗格因此更窄。
4.10 一组可直接使用的触发输入
按 4.5 的语料思路取 5 的幂作尾数,下面八条都能稳定命中,尾数的十进制位数从 21 位到 75 位:
# 八条成品输入,逐条跑单条驱动
/tmp/t_probe '931322574615478515625e-21'
/tmp/t_probe '2910383045673370361328125e-26'
/tmp/t_probe '710542735760100185871124267578125e-33'
/tmp/t_probe '173472347597680709441192448139190673828125e-45'
/tmp/t_probe '132348898008484427979425390731194056570529937744140625e-61'
/tmp/t_probe '807793566946316088741610050849573099185363389551639556884765625e-71'
/tmp/t_probe '986076131526264756764660706603482787091508043886278755962848663330078125e-80'
/tmp/t_probe '123259516440783094595582588325435348386438505485784844495356082916259765625e-83'len=25 oob=2 guard_broken=1 last_z=2047 consumed=25 val=9.3132257461547851562500e-01
len=29 oob=2 guard_broken=1 last_z=2047 consumed=29 val=2.9103830456733703613281e-02
len=37 oob=2 guard_broken=1 last_z=2047 consumed=37 val=7.1054273576010018587112e-01
len=46 oob=2 guard_broken=1 last_z=2047 consumed=46 val=1.7347234759768070944119e-04
len=58 oob=2 guard_broken=1 last_z=2047 consumed=58 val=1.3234889800848442797943e-08
len=67 oob=2 guard_broken=1 last_z=2047 consumed=67 val=8.0779356694631608874161e-09
len=76 oob=2 guard_broken=1 last_z=2047 consumed=76 val=9.8607613152626475676466e-09
len=79 oob=2 guard_broken=1 last_z=2047 consumed=79 val=1.2325951644078309459558e-09double 档对其中三条同样命中,命中瞬间的 z 是 127:
# 同一批输入里,三条在 double 档也命中
/tmp/dbl/relay/t_fuzz_dbl '2910383045673370361328125e-26'
/tmp/dbl/relay/t_fuzz_dbl '173472347597680709441192448139190673828125e-45'
/tmp/dbl/relay/t_fuzz_dbl '132348898008484427979425390731194056570529937744140625e-61'
八条输入里,所需负指数的绝对值与尾数的十进制位数之比落在 1.00 到 1.13 之间,指数绝对值随尾数位数增大缓慢上移。这个比例关系可以直接用来为其它长度生成候选输入,再交给探针筛出命中项。
五、越界写落到了哪里
5.1 栈帧布局
-O0 下 decfloat() 的序言:
# 反汇编 fs_O0.o,只取 decfloat 序言的前 7 条指令:栈帧大小与参数保存槽
objdump -d --no-show-raw-insn fs_O0.o | sed -n '/<decfloat>:/,+6p'
sub $0x20f0,%rsp 就是这段栈帧的尺寸:gcc 15.2.0 在 -O0 下给 decfloat() 分配约 8 KiB,函数序言先把参数存进靠近帧顶的槽位。尾数数组的基址在 rbp-0x20c0,x[-1] 因此落在 rbp-0x20c4,而参数保存槽就压在紧邻的上方:
越界写覆盖的是 f 保存槽的高 32 位,也就是把传入的 FILE * 指针上半部清零。这一次没有被察觉,原因在于 decfloat() 在 assemble 之后不再读取 f,返回值与崩溃检测都看不到异常——写入发生了,被写坏的变量却恰好退出了使用范围。
反过来说,被覆盖的槽位内容完全取决于编译器如何排布栈帧。同一个缺陷换一个编译器、换一个优化级别、换一处调用上下文,落点就可能变成别的变量,后果也随之改变:这次返回值保持正确,下一次可能破坏的正是被后续代码读取的关键数据。
-O2下栈帧换成另一套排布,数组落在$rsp+0x50,越界写落到$rsp+0x4c,被覆盖的对象不再是那个FILE *,而是编译器安排在同一段栈上的其它局部状态。
5.2 三个构建下的表现
-O0、-O2 与 double 档探针三个构建里,触发输入都返回 7.10542735760100185871e-01 这一正确结果,既没有崩溃,也没有可察觉的数值偏差;只有探针版(数组下移加哨兵)和调试器观察点能看到写入越过了数组起点。这里要分清"越界写发生"与"产生可察觉的后果"两件事:被破坏的栈槽恰好不被后续使用,缺陷因此表现为静默的内存破坏。这类问题在目标代码变化、内联决策变化或编译器版本升级之后随时可能翻转为崩溃或错误结果,不能拿"当前跑不出问题"当安全结论。
5.3 环长由 ABI 决定
KMAX 取决于 long double 的表示法,而它直接决定第 259 行的下标能否为负:
80 位扩展精度与 double 两档在本次测试里都走到了越界写,命中瞬间的 z 分别停在 2047 与 127;IEEE quad 一档的 KMAX 同为 2048,与 80 位档共用同一套环长,上游补丁把它列入确认可触发的范围。x86-64、aarch64、mips64 这些平台的 long double 落在后两档,静态链接 musl 的容器镜像、嵌入式固件与 Alpine 系发行版是其现实载体。
5.4 输入面
decfloat() 的调用者是 __floatscan(),凡是把字符串交给 C 库做十进制浮点解析的入口都在它的覆盖范围内:
这条路径上没有协议、没有文件格式,唯一的前置条件就是被解析的数字串本身。strtod、strtof、strtold、atof,scanf 家族的 %f、%lf、%Lf,以及各语言运行时代转发到 C 库浮点解析的路径——脚本解释器的数字字面量、JSON 与配置文件解析器——只要允许攻击者控制被解析的字符串,构造触发输入的余地就存在。
六、版本区间与目标码对照
6.1 哪些版本带着这行语句
六个历史版本的源码,加上本机 1.2.6 构建树里的那一份,摘要一次算完:
# 六个发布版本的解包目录,加上本机构建树里的那一份,逐个算源码摘要
md5sum /home/nl/musl-lab/versions/musl-1.2.0/src/internal/floatscan.c /home/nl/musl-lab/versions/musl-1.2.1/src/internal/floatscan.c /home/nl/musl-lab/versions/musl-1.2.2/src/internal/floatscan.c /home/nl/musl-lab/versions/musl-1.2.3/src/internal/floatscan.c /home/nl/musl-lab/versions/musl-1.2.4/src/internal/floatscan.c /home/nl/musl-lab/versions/musl-1.2.5/src/internal/floatscan.c src/internal/floatscan.c
从 musl 官方发布包中逐一提取 src/internal/floatscan.c,核对同一个判断分支的写法,结果是 1.2.0 到 1.2.6 六个发布版本全部保留了修复前的形式,而 1.2.6 正是当前最新的发布版本。cgit 的提交记录里,floatscan.c 可追溯的最早一次改动是 2012-04-10,IEEE binary128 分支由 2015-02-09 的提交加入;这行语句具体随哪一次提交成形,没有做考古级核查,不作推断。
6.2 六个发布版本的目标文件逐字节对照
源码相同只说明文本一致,编译产物是否也一致还要单独过一遍。用同一组参数把七个版本各编一份,去掉调试信息与时间戳的干扰:
# 七个版本各编一份 -O0 目标文件:1.2.0 至 1.2.5 取自 versions 目录,1.2.6 取当前构建树
gcc -c -O0 -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude /home/nl/musl-lab/versions/musl-1.2.0/src/internal/floatscan.c -o /tmp/fs_1.2.0.O0.o
gcc -c -O0 -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude /home/nl/musl-lab/versions/musl-1.2.1/src/internal/floatscan.c -o /tmp/fs_1.2.1.O0.o
gcc -c -O0 -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude /home/nl/musl-lab/versions/musl-1.2.2/src/internal/floatscan.c -o /tmp/fs_1.2.2.O0.o
gcc -c -O0 -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude /home/nl/musl-lab/versions/musl-1.2.3/src/internal/floatscan.c -o /tmp/fs_1.2.3.O0.o
gcc -c -O0 -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude /home/nl/musl-lab/versions/musl-1.2.4/src/internal/floatscan.c -o /tmp/fs_1.2.4.O0.o
gcc -c -O0 -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude /home/nl/musl-lab/versions/musl-1.2.5/src/internal/floatscan.c -o /tmp/fs_1.2.5.O0.o
gcc -c -O0 -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude src/internal/floatscan.c -o /tmp/fs_1.2.6.O0.o
# 七份产物的摘要
md5sum /tmp/fs_1.2.0.O0.o /tmp/fs_1.2.1.O0.o /tmp/fs_1.2.2.O0.o /tmp/fs_1.2.3.O0.o /tmp/fs_1.2.4.O0.o /tmp/fs_1.2.5.O0.o /tmp/fs_1.2.6.O0.o
再把六份历史产物与 1.2.6 的产物逐字节比一遍:
# 六份历史产物与 1.2.6 的产物逐字节比较,输出差异字节数
for f in /tmp/fs_1.2.{0,1,2,3,4,5}.O0.o; do printf '%s ' "$f"; cmp -l "$f" /tmp/fs_1.2.6.O0.o | wc -l; done
七份目标文件逐字节相同。补零写入在 1.2.0 与 1.2.6 里落在同一位置,指令序列连机器码都一样:
# 在两版目标的 decfloat 里定位这条 4 字节写指令,连同前三条指令一起打印
objdump -d /tmp/fs_1.2.0.O0.o | grep -B3 'movl \$0x0,-0x20c0(%rbp,%rax,4)'
objdump -d /tmp/fs_1.2.6.O0.o | grep -B3 'movl \$0x0,-0x20c0(%rbp,%rax,4)'
sub $0x1,%eax 之后的地址计算直接拿 %rax 当有符号索引使用,cltq 把 -1 符号扩展到 64 位,写入位置落在数组基址前 4 字节,两支发布版本在这段代码上完全相同。版本的堆叠只能靠源码指纹或这一条指令的比对来区分,二进制比对看不出六年间有过变化。
6.3 上游的修复
补丁说明(cgit 提交页)把问题讲得很直白,原文为:
the code to implement a modular postincrement was too clever, and essentially did it as a reducing version of (z+=1)-1, but only applied the redution after the addition not the subtraction. as a result, it evaluated to -1 when wrapping, causing a fixed value of zero to be stored out of bounds below the buffer.
whether this condition is triggered depends on where the mantissa bits end up in the circular buffer after processing.
for archs with 80-bit extended long double or IEEE quad long double, the input "710542735760100185871124267578125e-33" was reported to and has been confirmed to trigger it.
圈划:
as a result, it evaluated to -1 when wrapping与has been confirmed to trigger it两处。
改动本身只有一行,就是把"取旧值"和"前移指针"拆成两步:x[z] = 0, z = z+1 & MASK;。提交与说明原文见 cgit 提交页。
6.4 公告里还没有它
musl 官网首页的安全公告区块当前列着四条与本缺陷无关的条目:CVE-2026-40200 影响 32 位平台、覆盖到 1.2.6;CVE-2026-6042 涉及 iconv 在不可信输入下可被拒绝服务、同样覆盖到 1.2.6;CVE-2025-26519 覆盖到 1.2.5;CVE-2020-28928 覆盖到 1.2.1。本次的越界写不在其中,也没有 CVE 编号。
这一状态对使用方意味着一个窗口期:修复已经进了 git 主分支,官方发布通道上仍是受影响版本。以发行版打包方式静态链接 musl 的项目,需要自行核对构建时所用的 musl 版本,或者给源码打上这一个补丁。官网与安全公告区块在 musl.libc.org,被审源码为 musl-1.2.6/src/internal/floatscan.c,507 行,md5 efaa65abff84d078f06aa5e73efe9d00。
七、修复与验证
在探针版源码上应用上游改动后重编译,再跑同一个输入。改动落在探针源码的两处:写入语句换成 x[z] = 0, z = (z+1 & MASK);,同时去掉探针里按修复前表达式判定负下标的那一行,修复后 oob 只由哨兵被破坏来计数:
# 在探针源码上落地两处改动:写入语句换成修复后的写法,并删掉按修复前表达式判定负下标的那一行
sed -e '/__floatscan_last_z = z;/d' -e 's|x\[(z=(z+1 & MASK))-1\] = 0;|x[z] = 0, z = (z+1 \& MASK);|' floatscan_probe.c > floatscan_fix.c
# 编出修复版目标文件,替换进静态库副本,再链接同一个驱动
gcc -c -O2 -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude floatscan_fix.c -o floatscan_fix.o
cp /home/nl/musl-lab/install/lib/libc.a libc_fix.a
ar r libc_fix.a floatscan_fix.o
musl-gcc -static -O2 -o /tmp/t_probe_fix2 t_probe.c libc_fix.a
# 同一个触发输入再跑一遍
/tmp/t_probe_fix2 '710542735760100185871124267578125e-33'
oob 从 2 归零,哨兵保持完好,写入不再越界,返回值与修复前一致——修复没有触碰数值语义,只把下标运算摆回了它该在的位置。
两版写法在 z != MASK 时算出同一个下标:z 自增后的值减一,正是自增前的 z。只有 z == MASK 这一次,自增回绕到 0,减一得到 -1,0 被写到数组起点下方的一格;修复后的写法在同样的位置把 0 写进数组末尾的 2047 格,再把 z 复位到 0。缺陷只在特定输入下暴露,其余情况两版行为一致。
八、检测手段
8.1 源码与 ABI 两处核对
对构建方而言,最直接的核对方式是看源码与 ABI 两处:
# 在构建树里找那行语句
grep -n 'z=(z+1 & MASK)' src/internal/floatscan.c
# 读本机 gcc 预定义宏里 long double 的表示法参数
echo | gcc -dM -E - | grep -E 'LDBL_MANT_DIG|LDBL_MAX_EXP'
LDBL_MANT_DIG 为 64 或 113 时落在 KMAX=2048 分支,为 53 时落在 KMAX=128 分支;本次测试把前两档里的 64 位扩展精度与 53 位 double 都走到了越界写。语句比对与宏判断两步都能在几秒内完成,不依赖任何工具链能力。
8.2 编译器告警与静态分析
按 musl 自己的编译参数把被审文件编一遍,额外打开与越界相关的告警:
# 按 musl 的编译参数编一遍,额外打开数组越界与字符串溢出告警
gcc -c -O2 -Wall -Wextra -Warray-bounds=2 -Wstringop-overflow=4 -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude src/internal/floatscan.c -o /tmp/rep_gcc.o
一共 35 条告警,按类别归拢:
# 同一条编译命令,输出按告警类别计数排序
gcc -c -O2 -Wall -Wextra -Warray-bounds=2 -Wstringop-overflow=4 -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude src/internal/floatscan.c -o /tmp/rep_gcc.o 2>&1 | grep -o '\[-W[a-z=-]*\]' | sort | uniq -c | sort -rn
括号告警针对的正是这一族表达式:

数组越界相关的告警一条也没有。原因不难理解:写入下标由运行期的 z+1 & MASK 决定,编译器无从把 z 收敛到 2047,于是这条语句在编译期看起来与普通环形缓冲写法没有任何区别。
clang 21.1.8 走标准告警编一遍,得到 12 条诊断,11 条 -Wsign-compare、1 条 -Wunused-parameter,越界相关一条都没有;它连 -Wstringop-overflow 这个选项都不认,直接回一条 unknown warning option:
# clang 21.1.8 走同一套标准告警
clang -c -O2 -Wall -Wextra -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude src/internal/floatscan.c -o /tmp/rep_clang.o 2>&1 | tail -1
同样按类别归拢一遍:
# clang 的诊断也按类别计数
clang -c -O2 -Wall -Wextra -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude src/internal/floatscan.c -o /tmp/rep_clang.o 2>&1 | grep -o '\[-W[a-z=-]*\]' | sort | uniq -c
gcc 的 -fanalyzer 返回码 0,输出为空。三条静态路径在越界写这一项上全部静默。
8.3 动态检测工具
valgrind 3.27.1 跑同一个触发输入:
# valgrind 跑批量驱动上的同一个触发输入:-q 只输出错误,--error-exitcode=9 便于看返回码
valgrind -q --leak-check=full --error-exitcode=9 /tmp/t_fuzz_probe 710542735760100185871124267578125e-33
探针照常报出 oob=2、guard_broken=1,而 valgrind 在 -q 模式下没有打印任何一条错误,退出码为 0。换修复版的探针再跑一遍,输出回到干净状态:
# 修复版驱动再跑一遍
valgrind -q --error-exitcode=9 /tmp/t_probe_fix2 710542735760100185871124267578125e-33
插桩构建器的两种净化器在这套目标上都没有成型:
# 试试给静态 musl 目标加 ASan
musl-gcc -static -O0 -g -fsanitize=address -o /tmp/t_as t_gdb.c
# 给被审文件编一份 UBSan 版本,替换进静态库副本后链接
gcc -c -O0 -g -fsanitize=undefined -std=c99 -ffreestanding -nostdinc -D_XOPEN_SOURCE=700 -Iarch/x86_64 -Iarch/generic -Iobj/src/internal -Isrc/include -Isrc/internal -Iobj/include -Iinclude src/internal/floatscan.c -o fs_ub.o
cp /home/nl/musl-lab/install/lib/libc.a libc_ub.a
ar r libc_ub.a fs_ub.o
musl-gcc -static -O0 -g -fsanitize=undefined -o /tmp/t_ub t_gdb.c libc_ub.a
ASan 与静态链接互斥,UBSan 的运行时依赖 glibc 侧的接口,静态 musl 目标两者都接不上。把这几条汇总:
gcc -Wall -Wextra -Warray-bounds=2 -Wstringop-overflow=4 35 条告警,越界相关 0 条
clang 21.1.8 -Wall -Wextra 12 条告警,越界相关 0 条
gcc -fanalyzer 无诊断
valgrind 3.27.1 --leak-check=full 错误 0 条(探针已报 oob=2,工具未拦)
musl-gcc -static -fsanitize=address 静态链接不受支持
musl-gcc -static -fsanitize=undefined 链接期失败8.4 修补与输入侧约束
- 能落地的核对方式有三条:源码与 ABI 两处静态检查(8.1)、插桩探针(4.2)、调试器硬件观察点(4.7、4.8)。三条路都不依赖工具对栈上数组的越界检测能力,也都不受优化级别影响。
- 把三条路串成一条核对顺序更省事:先用语句比对确认构建树里的
floatscan.c是不是修复前的写法,再读LDBL_MANT_DIG判断所在平台的KMAX,落在 2048 或 128 两档的都要继续;接着按 4.2 的做法把数组下移一格、在起点下方埋一个哨兵,或者用调试器在写入指令前设条件断点,看z能否在补零分支里取到MASK。 - 构造输入时,随机数字串的效率很低——百万条随机语料在 80 位档零命中,把 5 的幂当尾数再配不同量级的负指数,命中率会高得多。本次 112800 条 5 幂语料在 80 位档命中 457 条、覆盖 77 个尾数,
double档命中 230 条、覆盖 53 个尾数,语料规模与命中分布见 4.5、4.9,可直接使用的成品输入见 4.10。 - 修补这块,等待 1.2.6 之后的官方发布版本,或者自行应用 commit
dd856098的单行改动都可以;使用构建型发行版时,需要确认打包方是否已经回移该补丁。判断回移与否有一个直接办法:按 6.2 的编译方式生成floatscan.o,在decfloat的反汇编里找下标计算序列,修复前的写法带着一条有符号索引减法加cltq符号扩展,修复后它随语句一起消失。 - 应用侧兜底可以对进入
strtod、strtold的字符串做约束:触发输入的特征是尾数 21 位以上、负指数绝对值与尾数位数之比落到 1.0 附近的十进制串,限制有效位数或指数绝对值都能收窄触发面。