Unidbg学习笔记(十二):Trace 的三个层次Trace 是 Unidbg 区别于其他方案的核心竞争力。在 Unidbg 里做 Trace,比 Frida 更底层、更完整、也更可控。本篇教你怎么从一团黑盒里“看见”程序在干什么。上一篇把你留在了哪里第十一篇我们解决了“函数返回 null”的初始化问题 —— 但解决的姿势其实有点土:用 Frida 拿标准答案用 Frida 逐个 Call 找关键 init用 IDA 静态分析你可能已经发现了:整套流程里,真正用 Unidbg 的部分,只有最后“按序调用”那一步。为什么 Unidbg 没在定位过程中起作用?因为我们没用上它最强的能力 ——Trace。这一篇就是把 Unidbg 的“内视镜”打开。读完之后你会明白:为什么 Unidbg 的 Trace 比 Frida 完整三个层次的 Trace 各自适合什么场景怎么从百万行 Trace 里捞出有用信息一句话定位:补环境是让 SO 跑起来,Trace 是让 SO 跑给你看。为什么 Unidbg 的 Trace 比 Frida 强先回答一个常见问题:Frida 也能 Trace 啊,为什么还要用 Unidbg?维度FridaUnidbgTrace 粒度函数级 (Stalker 可指令级,但代价高)指令级,默认能力干扰程度注入到真机,可能触发反 hook完全在虚拟机内,零干扰执行控制真机异步执行,难暂停单步可控,断点随便下可重复性受真机时序影响完全确定性Trace 数据完整性受 Frida 自身限制直接拿 backend 给的全部信息简单说:Frida 是“在真机上观察”,Unidbg 是“把样本搬到实验室解剖”。前者快,后者细。第一层:指令级 Trace最底层的 Trace。Unidbg 在每条 ARM 指令执行前后,都会回调一次,你能拿到:当前 PC 地址指令编码 (4 字节)反汇编后的助记符全部寄存器的值怎么开// 简单粗暴版: trace 整个 SOemulator.traceCode();// 推荐版: 只 trace 一段感兴趣的地址longbase=module.base;emulator.traceCode(base+0x1234,base+0x2000);典型输出[14:32:01 256ms][libxxx] [0x40001234][libxxx.so][0x00001234] "ldr w8, [x0, #0x10]" x0=0x70000000 x8=0x12345678 [14:32:01 256ms][libxxx] [0x40001238][libxxx.so][0x00001238] "add w8, w8, #0x1" x8=0x12345679 [14:32:01 256ms][libxxx] [0x4000123c][libxxx.so][0x0000123c] "str w8, [x0, #0x10]" x0=0x70000000 x8=0x12345679 [14:32:01 256ms][libxxx] [0x40001240][libxxx.so][0x00001240] "ldr x9, [x20, #0x28]" x20=0x70100400 x9=0x70110800 [14:32:01 256ms][libxxx] [0x40001244][libxxx.so][0x00001244] "cmp x9, x8" x9=0x70110800 x8=0x12345679 [14:32:01 256ms][libxxx] [0x40001248][libxxx.so][0x00001248] "b.ne 0x40001280" nzcv=0x60000000一行就是一条指令的“快照”。把每一列拆开看:[14:32:01 256ms]是 Unidbg 打的时间戳,分析时一般用不上,但如果你在 debug 一个“偶发走错分支”的问题,时间戳能帮你对齐 trace 里的事件顺序[0x40001234]是当前指令的绝对虚拟地址(Unicorn 视角的地址空间),实际取决于 Unidbg 给这个模块分配的基址[libxxx.so][0x00001234]是解析出的模块内偏移——这一列至关重要,它就是你在 IDA 里看到的那个偏移,可以直接双击跳过去对照"ldr w8, [x0, #0x10]"是 Capstone 反汇编出的助记符,基本等同于 IDA 里显示的汇编x0=0x70000000 x8=0x12345678是变化后的寄存器值,Unidbg 只打出“这条指令读/写了哪些寄存器”,没变化的寄存器不打,保持 trace 紧凑注意第 5 行cmp x9, x8指令后面出现了nzcv=0x60000000——这是 ARM64 的标志寄存器,6的二进制是0110,代表Z=1, C=1, N=0, V=0,即“相等且无借位”。第 6 行的b.ne 0x40001280根据这个nzcv判断不跳(因为Z=1意味着相等,而b.ne是“不等才跳”),所以下一行会是0x4000124c而不是0x40001280——这条信息在排查“我以为会跳但实际没跳”的场景里特别有用。学会读这几列之后,整份 trace 就是一本“程序自述”:什么时候读了哪块内存、比较了什么、分支走了哪一条、返回值填到哪个寄存器,全都写得清清楚楚。适用场景算法分析。比如你想知道一个加密函数对输入做了什么 —— 静态看 IDA 反汇编很费劲,因为你不知道哪些分支真的走到了。但指令级 Trace 直接告诉你:这次执行实际经过了哪 1000 条指令,每一步寄存器是什么。配合 diff 工具,你可以做这种事:用两份不同的输入跑两次,得到两个 Trace 文件。diff 一下,就能看出 “哪些指令的寄存器值不同” —— 这些就是真正受输入影响的指令,其他都是常量计算。这在还原白盒 AES 之类的混淆算法时几乎是唯一可行的办法。数据量警告指令级 Trace数据量极大。一个普通的 encrypt 函数可能产生:5 万到 50 万行 Trace100 MB 以上的文本直接卡死 IDE所以必须缩范围。先用 IDA 看出关键代码段大致在什么偏移,然后只 Trace 那一小段。// 不要这样emulator.traceCode();// 全量, 文件能爆磁盘// 这样emulator.traceCode(funcStart,funcStart+0x800);// 只看关键 2KB输出重定向默认 Trace 输出到 stdout,会把控制台刷得无法阅读。务必重定向到文件:PrintStreamredirect=newPrintStream(newFileOutputStream("trace_encrypt.txt"));emulator.traceCode().setRedirect(redirect);这样 Trace 进文件,你的 println 还能正常用,两不耽误。第二层:函数级 Trace指令级 Trace 太细,有时你只想知道:这个函数被调了几次?谁调的?参数是什么?这就是函数级 Trace 的用武之地。怎么开traceFunctionCall不在Emulator接口上,而在Debugger接口上 —— 所以必须先 attach 一个 debugger,再注册 listener:Debuggerdebugger=emulator.attach();// 不传参数 = 拿默认的 SimpleARM*Debugger, 不会真的进交互式断点debugger.traceFunctionCall(module,newFunctionCallListener(){@OverridepublicvoidonCall(Emulator?emulator,longcallerAddress,longfunctionAddress