起因
这其实不是一个太好的故事。前两年我跟着别人做过一点二进制 fuzzing 相关的工作,里面夹着一个商业项目和一个科研方向。商业那边后来因为一些合作上的问题直接没下文了,科研这边我这边倒是做出来了一些东西,但整个过程并不怎么愉快,最后也没有落成一个让我愿意继续往论文方向推的结果。
合作结束之后,我想过把这部分单独整理成论文,但越往后看越觉得不合适。一方面它的应用并不完整,另一方面它也没有那么强的科研味,在一些场景下甚至还存在错误 patch 的风险。与其硬凑成一篇不太像样的论文,不如老老实实写成技术博客,至少把思路、实现和坑都记下来,免得过一阵子连我自己都忘了当时到底是怎么做的。
这篇里要讲的事情,说到底就是想办法在最终二进制里找到 sanitizer 相关的检查代码,然后把它对应的那段机器码关掉,并且在需要的时候再恢复回来。它并不是一个已经成熟到可以放心拿去到处用的方案,但里面有一些实现细节我觉得还是值得单独记一下。
难点
表面上看,想关掉一段 sanitizer 检查,好像只要找到对应的几条 __asan_report 调用,然后把它们 NOP 掉就完了。真做起来就会发现完全不是这么回事。你得先分清楚哪部分机器码是 sanitizer 为了做检查插进去的,哪部分还是程序自己的正常逻辑;还得在优化、指令选择和链接都做完之后,重新在最终 ELF 里把那一段找回来;最后还不能随便按几个字节硬裁,不然 x86 指令一旦切断,程序当场就要去世。
最开始我也想过偷个懒,直接靠 function:line、debug info,甚至某些 IR 位置信息回推最终的字节区间。但这条路很快就发现不太行:optimizer 会重排,instruction selection 会扩展,linker 还会继续改布局。想在最终二进制里稳定定位,就不能指望“它原来大概在这里”,而是得自己留一个能认出来的记号。
所以我最后走的是这么一条路:编译期先把检查认出来,再在前面埋一个我自己能认出来的标记;等最终二进制出来之后,再顺着这个标记回头找那段机器码,最后按文件偏移把对应的字节改掉。前半段是一个 LLVM pass 做的,后半段则是一个链接后的小工具在做。
IR 识别
这个 pass 的运行时机很关键:它必须在 sanitizer pass 之后执行。因为只有到那时,ASan、UBSan 已经把自己的检查逻辑插进 IR 里了,后面的分析才有东西可找。
最直接的入口当然是 sanitizer 的失败路径。判断条件大概就是这样:
if (name.startswith("__ubsan_handle") ||
name.startswith("__asan_report")) {
return true;
}也就是说,只要某个 basic block 里最终会调用 __ubsan_handle* 或 __asan_report*,它就先会被当成一个失败块。但只知道失败块还不够。真正想关掉的,不只是最后那一条 call __asan_report...,而是整段“为了做这次检查而存在”的代码:前面的比较、分支、失败块里的辅助计算,甚至只服务于检查条件的数据依赖。
实际做的时候,就是从这些失败块开始,反着往前追。先把失败块里的指令收进来,再把通往这些块的条件分支一起记下来;如果某条指令只被已经判定为检查逻辑的指令使用,那么这条指令也会被继续吞进去;如果一个 block 里的指令最后全都被吞进去了,那它的前驱也要继续往前追。
这里卡得最死的地方在于,不能把正常程序逻辑一起卷进去。如果一条 IR 指令一边服务 sanitizer 检查,一边又被正常程序逻辑使用,那它就不能被算进来。只有在“这条指令存在的唯一用途,就是服务这次检查”的时候,分析过程才会继续把它往前吞。这一步很重要,因为它基本决定了后面那段要改的机器码到底有多大。如果这里放松了,后面就非常容易把本来属于用户逻辑的代码一起抹掉。
最后,编译期会给这些被圈出来的 IR 指令统一挂一个 metadata。到这一步为止,编译器视角里我们已经知道“谁是检查、谁不是检查”了,但二进制视角里还没有地址。
埋点
做法也不复杂,在每段连续的检查区域开头插一次调用,把一个随机数带进去:
__mark_check(random_id);与此同时,再把这个随机数和对应的源代码位置记到一份映射文件里,大概长这样:
1804289383 = parse_config:117
846930886 = decode_header:52这份文件做的事情很简单,就是先记个账,把“随机数”和“源代码位置”绑起来。真正的二进制位置,要等链接完成之后再去恢复。
标记函数
如果只是随手塞一个空函数调用,编译器大概率会想办法把你收拾掉。所以这个辅助函数一方面得真的活到最终二进制里,另一方面又不能改变程序语义,还最好能在调用点附近稳定地留下一个常量。实现上大概会写成这样:
__attribute__((noinline, optnone))
void __mark_check(int x) {
if ((((x ^ -1) * x) & 1) == 0) {
return;
}
__builtin_unreachable();
}这个判断永远成立,因为 x 和 ~x 里一定有一个是偶数,它们的乘积最低位必然是 0。所以这个函数在语义上永远只会 return,但从编译器视角看,它又不是一个一眼就能完全消掉的空壳。
更重要的是,在 x86_64 SysV ABI 下,第一个 int 参数会放进 edi。这样一来,调用点附近就很容易长出下面这种序列:
bf 67 45 23 01 mov edi, 0x01234567
e8 xx xx xx xx call __mark_check_xxx这个 mov edi, imm32 里的立即数,就是前面故意埋进去的那个随机数。后面重新定位的时候,靠的就是这个特征。
重定位
链接之后会先做一次预分析。输入就是两样东西:一份编译期留下来的映射文件,再加上最终链接完成的 ELF 可执行文件。程序先把 .text section 找出来,拿到 .text 的文件偏移、大小和虚拟地址基址,后面的扫描都只在这段代码区里做,省得把数据段里偶然撞上的相同字节模式误认成代码。
接着就可以线性扫描 .text,去找下面这个模式:
0xBF <imm32> 0xE8 <rel32>也就是 mov edi, imm32; call rel32。单看这个模式其实可能撞车,因为普通程序里也可能恰好出现“给 edi 塞立即数然后 call”的序列。所以这里还得多做两层确认:先拿 imm32 去对映射文件里的随机数,再把 call 解析出来的目标地址拿去对符号表里那个标记函数。两边都对上,才能认为这个位置真的是前面埋下去的标记。
到这里之后,就已经能把“源码里的某个检查”重新对应到“最终二进制里的某个位置”上了。为了方便后面继续处理,我这里会把这些信息存成一个 json,里面除了随机数和源代码位置之外,还会慢慢补齐最终虚拟地址、文件偏移之类的东西。
Patch 边界
找到标记之后,真正麻烦的问题才刚开始:到底应该改掉多少字节?
只改 call __mark_check 本身当然不够,因为真正昂贵的是后面的 sanitizer 检查逻辑;但如果只按固定字节数去裁,又很容易把 x86 指令切断。这里我用了一个比较偷懒、但目前够用的办法:从标记后面开始,顺着反汇编一直走到后面的第一条 callq 为止,然后把从 mov edi, imm32 开始到这条 callq 结束的整段都记下来。
最后落下来的不是一个点,而是这样一段完整的区间:
bf 67 45 23 01 mov edi, 0x01234567
e8 .. .. .. .. call __mark_check_xxx
... ; 比较、分支、辅助计算
e8 .. .. .. .. call __asan_report_load8这段区间会一起写进前面那个 json 里。除了源代码位置、最终虚拟地址和文件偏移之外,还会记下这一段一共多长,以及原始字节本身。原始字节会先做一次 base64,这样后面恢复的时候就可以直接按原样写回去,不用重新做一次编译。
改写与恢复
一旦有了这个 json,后面的事情就比较直接了。要关掉某一批点位的时候,就把对应范围里的那几段字节全部改成 0x90,也就是 NOP;要恢复的时候,再把保存下来的原始字节解码出来,原样写回去。
这里有个小坑,范围判断现在其实是按文件偏移比较,而不是按运行时虚拟地址比较。接口上如果直接把这个东西叫“地址”,严格来说并不算特别老实,后面还得再收拾一下。还有一点需要说清楚,这里的“运行时动态开关”更接近 fuzzing 过程中按轮次切换目标程序,而不是进程内部真的去做自修改代码。它解决的是执行策略问题:下一轮运行之前,我要不要让某一批 sanitizer 检查继续存在。
局限
这套设计已经能工作了,但它明显还是一套研究原型。它现在基本写死在 x86_64 ELF 上,而且默认 mov edi, imm32; call rel32 这套形状不会变;二进制如果 strip 得太狠,符号表里那个标记函数没了,后面的重新定位就会很难继续做;patch 边界现在靠“后面的第一条 callq”来收尾,说到底还是个启发式;__ubsan_handle* 目前也被我一股脑当成了失败路径,recover 型的 UBSan 还没有单独区分;映射文件现在还是 append 方式,开发时多跑几次最好自己清一下,而且它记的也只是 function:line,如果 debug info 不完整,映射信息就会比较弱。
