那天我看到的只是一个计算器弹窗。Windows桌面冷不防冒出来的Calc窗口,来自一段躺在GitHub角落里已经四个月的PoC。研究者“bikini”把一个精心构造的64x1 PAL8帧喂给了FFmpeg,然后什么都没做——只是等着解码器自己踩进了堆里的那一小片空地。屏幕捕获工具RemotelyAnywhere早就没人用了,它留下的RASC解码器却还活在全世界的FFmpeg里,活得无声无息,活了整整八年。
FFmpeg几乎触碰过互联网上每一段视频。你上传的片段、你观看的直播、你截取的缩略图,它们大概率都经过这个库的处理。而RASC这种格式,很少有人会在转码前多看一眼,也就很少有人会在解码函数里多查一遍边界。这恰恰是这类漏洞最舒服的藏身之处:被所有人需要,又被所有人遗忘。
PoC的玩法很干净。decode_dlta()会把delta帧一行一行写入画面缓冲区。bikini只用了一个64x1的帧,就驱动了一次32位写操作——本该落在64字节缓冲区末尾的那次写入,实际上往边界外多伸了一只脚。相邻堆块里恰好躺着回调指针,最后3个字节被改写了,计算器跳了出来。这不是一次崩溃,这是一次精确的利用,它证明了写操作真的穿越了保护墙。
但我读完报告之后没有直接把它归档。一个64字节的pop只能说明某个特定宽度会触发越界,我却很想知道这是巧合,还是整个行边界逻辑本身就带着一道裂缝。如果根本原因埋得更深,那8年之后依然会有新的变异体等着被挖出来。
decode_dlta()用光标cx在每一行上移动,它用w乘以s->bpp算出当前的字节宽度,然后在每一次行切换时动用了一个名叫NEXT_LINE的宏来做边界检查。定义看起来不复杂:当cx超过行宽时,光标归零,y坐标减一,两个基址指针b1和b2各自回退一行。最后再让循环计数器len减一。放在代码结构里,它就像一个尽职的安检员,应该站在每一轮读写的入口。
但读源码的时候,我注意到了那个要命的顺序。NEXT_LINE的调用总是放在case块的最末尾。DLTA有四种运行类型——4、7、12和13——它们都会在进入while循环之后,先做一次32位的读写,然后才轮到NEXT_LINE判断是否需要换行。也就是说,安检员不是站在门口,而是跟在你身后,等你把东西搬进去之后再问“超重了吗”。
以运行类型7为例,它会先用bytestream2_get_le32取一个填充值,然后在前一行和当前行对应位置做一次32位复制,接着写入填充值,最后才把cx加4再执行NEXT_LINE。在PAL8帧里s->bpp是1,所以行宽的字节数等于像素宽。一旦攻击者把delta区域的起始位置设置在width减去1的地方,那这4个字节就会从一个合法坐标起步,把1个字节埋进行尾,剩下3个字节直接越过画面平面的分配边界。
我把这个现象叫做RowSpill——行溢出。它不是堆块整体长度不够,而是每一行的平面分配刚好在帧所定义的行尾结束,写入动作却无视了这个边界。这就像一个电梯每次都在你跨出去之后才关门,总有一天你会踩空。
为了排除宽度特例,我用ASAN重新验证了两种几何尺寸:width=64, height=1时会报告64字节区域后面第一个非法字节在plane+64;换成width=128, height=1后,ASAN同样在128字节区域的边界外报出非法写入。宽度变了,溢出的字节位置始终紧贴着每一行的平面尾部。根因和特定宽度无关,是行边界检查的滞后把每一行都变成了潜在的跳板。
这个发现最让我兴奋的并不是“找到了一个8年老洞”,而是它展示了宏设计中的一种常见错觉:把检查语义写在循环体的末尾,看起来比写在一开始更简洁,因为不用额外处理初始状态的跳转。可一旦循环体里存在一次或多次不受保护的操作,那个简洁就变成了一个延迟8年的陷阱。每一条分支、每一个case,都没有在读写之前重新验证cx的位置,它们信赖那个总会到来的NEXT_LINE,而NEXT_LINE只会对已经完成的事情点头。
修复做起来反而不复杂:把读写动作挪到边界检查之后,或者让NEXT_LINE在进入分支时先执行一次初始判断。但让我着迷的是为什么这么明显的模式能在Git提交历史里躺这么久。RASC解码器足够冷门,冷门到几乎不会收到模糊测试的样本,也冷门到每一次代码审计的目光都顺着更热门的解码器滑过去了。而它的结构偏偏足够“正常”,正常到没有人怀疑那个宏的顺序错了。
当bikini把PoC公开的时候,RowSpill才终于从一个安静的假设变成了一个被实证的风险。八年的时间不是因为它藏得深,而是因为它看起来太像正确的东西了。
热门跟贴