unidbg 的 trace 速度太慢了?别急,我来助你
我们这一篇文章主要是来改进一下 unidbg 的自带 trace 功能,对于这一功能,我的评价是目前的 trace 能用但是并不好用;
先说一下速度吧,实测比原版unidbg的快几十倍,然后混淆恶心的ollvm+vmp可以达到一百多倍,也就是需要几小时的可以i直接变成几分钟。
找到我的魔改unidbg 8a2K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6x3N6h3&6r3k6h3&6Y4b7$3S2W2L8W2)9J5c8Y4g2F1K9h3c8T1k6H3`.`. 然后 git clone,之后正常使用就行。
然后这里我是提供了类似 traceCode 的 API,叫做 traceCodeText,因为我后续会支持一波二进制格式的 trace,那个速度更快,不过需要和桌面端配合使用;
注意:我保留了原版的 traceCode,这样不影响你项目迁移后的编译问题;
会用了对吧,对优化理论不感兴趣的话,那就可以直接跳到最后了。
这期文章我们就来解决这个问题,并且试图给大家讲明白我都做了哪些优化;
Unidbg 的 Trace 功能本质是一个 逐指令回调钩子(CodeHook):Unicorn(或 Dynarmic/Hypervisor)引擎在执行每一条 ARM/ARM64 指令前,都会回调一次 Java 层的 hook() 方法。在这个回调里,原版引擎需要做:
对于正常的 SO,一次函数调用可能产生几百到几万条 Trace,unidbg 原版这套流程完全够用。但 OLLVM 和 VMP 保护会让函数的执行路径急剧膨胀——OLLVM 的状态机调度让每个基本块的跳转都要经过 dispatcher 中转,VMP 更是把每条原始指令翻译成数十条 VM Handler 指令逐条解释执行:
unidbg 做这一步其实是没问题的,主要是为了从机器码到汇编指令,这样可以知道哪些寄存器发生了变化,以便于后续读取变化的寄存器打印。
如 "stp x29, x30, [sp, #-0x20]!" 这一条指令就读取了 sp 寄存器的值,然后偏移地址,然后读 x29、x30 的值,然后把值写入到 sp-0x20 的地址处;unidbg 通过 Capstone 可以得知,read 的寄存器有 sp、x29、x30,write 的寄存器有 sp;
但是这一步对于 OLLVM/VMP 的 trace 来说,会有很大的性能浪费,因为 OLLVM/VMP 的指令膨胀效果,其实存在着大量的重复指令,这些重复指令的机器码都是一样的;此时如果依旧每一条指令都调用一次 Capstone 的 disassemble(),那么就会非常慢。
如何解决这个问题:很简单,直接上缓存,你 OLLVM/VMP 再强,能有多少不同的指令?
这里我直接使用了 L1/L2 两级缓存,这里为啥不只用一级缓存?比如为什么不直接用一个 HashMap<地址, 反汇编结果> 来搞定?
因为:这在 OLLVM/VMP 场景下效率不是最优的,原因是 HashMap 每次查找需要:
如果是上亿级别的指令,上亿次 × 这套流程 = 一笔可观的开销。
所以这里我们再搞一级缓存,用数组来代替,直接使用地址取模来定位,这样可以省去 HashMap 的开销。
ok 接下来拿 VMP 举个例子;这里假设 VMP 的 VM Handler 循环执行 3 条指令,地址分别是 0x1000、0x1004、0x1008,循环 N 次:
第 1 轮(全部 cache miss)
第 2 轮(全部命中)
第 3 轮到 一亿轮:全部 L1 hit,Capstone 调用次数 = 0。
举个冲突的例子。假设 OLLVM 有两个 Basic Block,地址恰好冲突(算出来的 L1 格子编号一样):
执行过程:
第 1 步:执行 Block_A 的 0x00001000
第 2 步:跳到 Block_B,执行 0x02001000
第 3 步:跳回 Block_A,又执行 0x00001000
如果没有 L2,第 3 步就要重新调 Capstone。OLLVM 的 Block 之间来回跳转,没有 L2 的话就会反复 miss 反复调 Capstone。L2 就是干这个的——接住被 L1 挤掉的条目。
实测上了这个方案,速度大幅提升;
如果 SO 运行时,同一个地址的机器码发生变化,缓存的反汇编结果就会失效。解决方案是读取当前机器码并与缓存中的做比较:
实测这个方案,只会带来一部分性能损耗,对我个人而言是可以接受的;目前还没想到比较好的方案,主要是不动 Unicorn 的话……
其实,对于确认没有 SMC 的场景(绝大多数情况),可以通过 setDisableSMC(true) 跳过这个检查,这样连 mem_read 都省掉。
---
在解决了 Capstone 重复调用后,性能 Profiling 显示新的热点转移到了 Java 对象分配。上亿次回调中,以下操作产生了海量临时对象:
1. SMC 检测中的 ByteBuffer
上亿次 × ByteBuffer.allocate() = 海量堆内存分配 → Young GC 频繁触发。
2. findModuleByAddress 模块查表
String.format 本身也会创建 Formatter、StringBuilder、char 数组等大量临时对象。
3. Hexdump 格式化
1. 位运算替代 ByteBuffer
2. 预计算前缀(Precomputed Prefix)
将 findModuleByAddress 和字符串格式化从热循环移到缓存构建时。每个 CachedInstruction 在首次创建时就固化好完整的前缀文本:
同样的思路也应用到了寄存器映射:Capstone 的 ins.mapToUnicornReg() 和 ins.regName() 调用都被预计算并存入 HashMap,避免在热循环中重复通过 JNI 调用 Capstone。
3. HEX 静态速查替代 String.format
这个短板不容易被想到,但实际上作用很明显,之所以不在前面讲,是因为这个其实太基础了,本来都不打算提。
经过前两步优化后,瓶颈转移到了 I/O 写入。原版的写入方式是:
PrintStream.println() 内部有 synchronized 锁。上亿次同步写入意味着上亿次锁获取/释放。而且文件 I/O 本身就有系统调用开销。
代码如下:
此外,我们还加了函数级别的跟踪,后面会提到;其中,函数调用日志(Call/Ret 的分隔线)也被重定向到 asyncBlockBuffer。原版中 Call/Ret 的输出是通过独立的 PrintStream 直接写的,在多线程批处理模式下,会导致函数调用链与底层汇编指令的时序错乱。新版通过 queueOut(一个伪 PrintStream,实际追加到 asyncBlockBuffer)统一了所有输出的序列化:
在性能问题解决后,顺便大幅增强了 Trace 的语义可读性吧。加了这些功能实际上会拖慢速度,但是由于我们速度很快了,所以不差这点了,要更多侧重分析方便性了;
引入了 TraceCallParser 接口和四个默认实现,这样大家也可以自己改格式:
这一点大家仔细 trace 体验吧,这东西讲了也没啥价值,就是如何通过 PLT/GOT 表获取符号的事情,其中 ARM32 和 ARM64 的实现方式还不太一样,本来都打算懒得支持 ARM32 了,但是想了一下既然要开源,就都做好吧;
返回值中的 jclass/jmethodID 会被缓存到 jniIdMap,后续使用这些 ID 的调用会自动附上对应的类名/方法签名。
针对 ld/st 指令操作的绝对地址,进行内存 hexdump 打印,这样分析时可以根据值最早出现去追踪,相当于就是类似龙哥 DataSearch 的插件吧,这里除了 hexdump 我还贴心的打印了大端序,因为这是个实际使用的小痛点,哈哈哈哈。
经过以上优化,Java 层已经基本不再是瓶颈了。但仍然存在一些改进空间,这里分析一下:
无论 Java 层多快,每条指令都要从 C/C++ 引擎穿越到 Java 层这件事本身就有不可消除的开销:
每条指令的回调中,还需要读取寄存器的当前值(用于显示变化)。每次 backend.reg_read() 也是一次 JNI 调用。一条 add x0, x1, x2 就需要读 3 个寄存器,一亿条指令 × 平均 3 次 reg_read = 3 亿次 JNI 调用。
可能的解决方向:
上亿行 Trace,由于写入的字节过多,即使用了异步批量写入,磁盘的顺序写入带宽仍然是一个物理上限。
可能的解决方向:
Unicorn 是基于 QEMU 的翻译执行引擎,每条 ARM 指令需要先翻译成 TCG IR,再翻译成宿主机指令。这个翻译 + 执行的过程本身就比原生执行慢 10~100 倍。如果后续能解决这个问题,或者在宿主机上随便跑arm指令,那么我个人觉得速度就不再是问题了;
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 2天前
被x1a0f3n9编辑
,原因: