资讯动态

Frida Stalker寄存器监控实战:用putCallout逐指令追踪ARM状态

发布时间:2026/10/22 6:20:02 来源:尧图企业网站定制
1. 从一次“看寄存器改到怀疑人生”说起先交代一个场景。某次拿到一个模拟项目X里的native校验函数表面逻辑很简单输入一串flag经过一个本地方法处理返回布尔值。可我反复下断点、读内存就是看不明白结果怎么算出来的——函数很短但有个循环里连续做了位移、位与、乘加运算光凭静态反汇编根本推不出状态变化。后来换了个思路既然是ARM指令我直接逐条看寄存器变化问题不就摊开在眼前了吗于是用上了Frida的Stalker配合putCallout把函数执行过程中每一条指令前后的寄存器快照全打了出来几分钟就锁定了关键变换步骤。Frida Stalker这个工具简单说就是Frida内置的代码追踪执行引擎能够在运行时把目标代码“改写”成带探针的版本逐条指令地执行、观测而不打断程序本身。Instruction.prototype.putCallout是它提供的最灵活的一个钩子允许在每一条指令执行时插入一段JavaScript回调回调里能直接读到当前指令地址、操作数以及完整寄存器上下文。这意味着你不再需要捏着一堆断点手动对比状态而是把“看寄存器”这件事变成可编程的流水线操作。这篇内容适合谁移动安全方向的逆向分析人员、做混淆对抗或通杀脚本的开发者还有那些刚接触动态插桩想搞懂“Stalker到底能干嘛”的新手。看完你不仅能跑通一个完整脚本还能真正理解为什么putCallout能替代一大堆手动的断点操作以及在实战中它的性能和注意点在哪里。2. 为什么要用Stalker putCallout做实时寄存器监控2.1 先看常见的几种寄存器读取方案在ARM平台上想拿到某个时刻的寄存器值最容易想到的是下断点。用Process.setExceptionHandler配合断点指令或者直接用调试器比如lldb、gdb停在目标位置后读x0到x28、sp、lr、pc。断点方案的问题很明显你得先确定“停在哪”也就是必须预先知道关键指令或关键分支的位置。可逆向分析的前期往往恰恰是不知道哪里关键。一次断点只能看一个位置的状态。如果想看某条循环内几十次迭代的寄存器演化要么设多个断点手动记录要么就真的逐次单步执行繁琐、容易漏。被调试进程会被反复暂停。对带反调试、有时间检查的样本这基本等于自爆。内存dump也是常见手段。把整个栈sp附近的区域或者局部变量区读出来然后人工分析。但内存dump和寄存器有本质区别寄存器是CPU当前正在操作的数据载体尤其像暂存中间结果的w8、w9它只活在指令流里你dump内存时它可能已经改写了甚至压根不会出现在内存里。StalkerputCallout解决的正是“在指令流中观测瞬时状态”这个需求。它在每条指令执行前或执行后插入一个回调回调中拿到的context就是那一瞬间的完整寄存器快照。因为整个过程是由Frida生成的中继指令代码来完成的目标线程没有被真正停住不需要你去猜断点位置也天然适配循环体。2.2 putCallout与Stalker整体机制的关系要理解putCallout得先知道Stalker怎么工作。Stalker的核心是指令块basic block重编译它拦截目标线程的执行流把原始指令块逐个读取出来经过它的“transform”管线生成一份修改过的指令块副本。这份副本里你可以插入额外指令、调整分支、加入回调然后让目标线程去跑这份副本。因为程序实际执行的是“翻译后”的指令块原始指令一条没丢所以行为保持一致。举个生活化类比你请了个翻译把你的一段话逐句翻译给外国人听。原始话是原来的代码翻译的版本是Stalker生成的新代码而putCallout就相当于你在每两句翻译之间悄悄塞给翻译一张小纸条“记一下说话人现在的情绪状态”。外国人听到的还是不折不扣的完整内容但你已经额外记录了每一句前后的状态变化。putCallout实际上是在Instruction.prototype上挂的方法所以在Stalker的transform回调里对每一个instruction对象都可以调用instruction.putCallout(function(context) { // context 是 CpuContext });在ARM64架构下context会暴露x0到x28以及pc、sp等。对ARM32则会暴露r0到r14。如果你给Frida传了指定指针或符号参数context还会包含lr等额外字段。2.3 对比一下其他监控手段的坑在哪再说一个容易踩的坑很多新手喜欢在每个指令上加Interceptor.attach。这个方案也不是不行但那是函数级别的hook粒度不够细而且对不同架构、不同调用约定都得分别处理。Interceptor拿到的context虽然也是寄存器但只能表示函数入口/出口的瞬间运行中途的中间状态完全拿不到。如果你跟踪的是被混淆的代码函数内部可能有大量垃圾指令和虚拟化调度逻辑仅靠入口和出口的寄存器状态你根本看不出路径。Stalker是全流程、指令级的你甚至可以只针对某一段地址范围做follow精确到某几条指令。后面我会给一个实用的范围过滤写法。2.4 使用场景什么情况下非它不可总结几个我用下来觉得“只有Stalker能干净搞定”的场景观察条件跳转的判定过程比如某个cbz、tbz指令前的x0到底经历了哪些运算才变为0。破解简单的时间戳校验很多加固会用当前时间参与计算并存入寄存器你可以跟踪x0的赋值过程。协议解析的字段变换通信协议里的随机数、MAC计算逻辑往往是一串连续的位运算和查表单步跟踪寄存器变化就能还原算法。自动化的API行为记录配合Stalker.follow写一个通用脚本批量记录某个so中所有函数的寄存器级行为再离线分析。3. 核心API与完整脚本实战3.1 准备环境与前置知识实战前先把基础条件理清一个已root或越狱/模拟器可注入的ARM设备或模拟器。真机调试时建议用USB连接Frida通过frida-server远程桥接。安装Frida CLI工具pip install frida-tools。有一个待分析的目标App。我的样例里用一个模拟项目Java层调用一个nativeCheck方法内部逻辑是几个简单的位运算加乘法但为了演示不预先提示逻辑。先确认目标进程能正常attachfrida-ps -U找到目标App的进程名或PID后准备一个stalker_demo.js脚本用frida -U -f 包名 -l script.js --no-pause启动。3.2 putCallout完整脚本逐指令寄存器快照下面直接给出一个可用的脚本骨架。这个脚本里我封装了三个实用能力只跟踪指定地址范围、记录每条指令前的寄存器快照、按条件过滤输出有效变化。// stalker_demo.js use strict; const TARGET_MODULE libnative_check.so; const START_SYMBOL Java_com_example_check_MainActivity_nativeCheck; const END_SYMBOL Java_com_example_check_MainActivity_nativeCheck_exit_probe; function getModuleRange() { const mod Process.findModuleByName(TARGET_MODULE); if (!mod) throw new Error(module not found: TARGET_MODULE); return mod; } // 取函数起始地址与结束地址用符号表基址 function getRangeBySymbol(module) { const exports module.enumerateExports(); let startAddr null; let endAddr null; for (const exp of exports) { if (exp.name START_SYMBOL) startAddr exp.address; // 实际项目中如果没有明确结束符号可以这样取 // 用下一个导出符号的最小地址来近似 if (exp.name END_SYMBOL) endAddr exp.address; } if (!startAddr) throw new Error(symbol not found: START_SYMBOL); if (!endAddr) { // 退化方案手动指定偏移或者取 startAddr 0x400 endAddr startAddr.add(0x400); } return { start: startAddr, end: endAddr }; } // ARM64 寄存器名称列表 const REG_NAMES [ x0, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11, x12, x13, x14, x15, x16, x17, x18, x19, x20, x21, x22, x23, x24, x25, x26, x27, x28, fp, lr, sp, pc ]; let lastRegs null; let insnCount 0; const outputLines []; function snapshotRegs(context) { const snap {}; for (const name of REG_NAMES) { // 兼容读取有些字段在context上是直接属性 let v context[name]; if (v undefined name fp) v context.x29; if (v undefined name lr) v context.x30; snap[name] v; } return snap; } function formatRegSnapshot(snap) { const parts []; for (const name of REG_NAMES) { const val snap[name]; parts.push(name (val ? val.toString(16) : 0x0)); } return parts.join( ); } // 比较两次快照返回发生变化的寄存器 function diffSnapshots(prev, curr) { const changes []; for (const name of REG_NAMES) { if (!prev[name] || !curr[name]) continue; if (!prev[name].equals(curr[name])) { changes.push({ name, from: prev[name].toString(16), to: curr[name].toString(16) }); } } return changes; } function startStalkerTrace(startAddr, endAddr) { console.log([*] Stalker tracing from startAddr to endAddr); // 注意follow当前线程。真实场景如果是在native函数里调用 // 需要确保脚本在目标线程运行。这里配合Interceptor在函数入口线程中follow。 Stalker.follow({ events: { compile: false, call: false, block: false, exec: false }, transform: (iterator) { let instruction; while ((instruction iterator.next())) { const addr instruction.address; if (addr.compare(endAddr) 0) { // 超出范围后停止插入callout iterator.keep(); continue; } if (addr.compare(startAddr) 0) { instruction.putCallout((context) { insnCount; const snap snapshotRegs(context); if (lastRegs) { const changes diffSnapshots(lastRegs, snap); if (changes.length 0) { const line [insn insnCount addr ] changes.map(c c.name c.from - c.to).join( | ); outputLines.push(line); if (outputLines.length % 100 0) { console.log(outputLines.slice(-100).join(\n)); } } } lastRegs snap; }); } iterator.keep(); } } }); } function stopStalkerTrace() { Stalker.unfollow(); console.log([] Stalker unfollowed); const logFileName /data/local/tmp/stalker_ Date.now() .log; const file new File(logFileName, w); file.write(outputLines.join(\n)); file.close(); console.log([] Log written to logFileName); } // 在native函数入口hook住抓取当前线程并启动Stalker Interceptor.attach(Module.findExportByName(TARGET_MODULE, START_SYMBOL), { onEnter(args) { const mod getModuleRange(); const range getRangeBySymbol(mod); // 记录当前线程id console.log([*] Entering target, threadId Process.getCurrentThreadId()); startStalkerTrace(range.start, range.end); }, onLeave(retval) { setTimeout(() { stopStalkerTrace(); }, 10); } }); // 如果希望手动触发可以保留一段RPC调用 rpc.exports { startTrace() { const mod getModuleRange(); const range getRangeBySymbol(mod); startStalkerTrace(range.start, range.end); }, stopTrace() { stopStalkerTrace(); } };脚本说明几个关键点putCallout的回调里context是CpuContext。对ARM64直接context.x0即可。但一部分字段在Frida的JS绑定里是NativePointer所以比较时我用.equals()而不是。我做了差异快照每条指令执行后对比上一个快照只输出发生变化了的寄存器。这在寄存器多、指令多的时候非常有用。否则每一条指令打印33个寄存器输出会爆炸人也看不过来。用Iterator配合while遍历对putCallout插入的指令调用iterator.keep()保留。keep()的含义是保留当前指令不加额外跳转。我在函数onLeave后用setTimeout延迟10毫秒再停止跟踪。为什么因为onLeave回调执行时Stalker可能还在最后几条指令的callout回调中立刻unfollow会导致异步回调还没跑完寄存器快照少了一截。加10毫秒延时能稳定收尾。运行时用这样的命令frida -U -f com.example.check -l stalker_demo.js --no-pause3.3 脚本的改进方向按指令类型和地址过滤上面脚本全量输出寄存器差异指令一多照样信息量大。实际用时我会再加两个过滤条件只关注写某个特定寄存器的指令。比如你想知道x0何时被赋值可以在callout里判断当前指令助记符比如mov、ldr或者直接解析instruction.opStr。只关注地址落在某个子区域的指令比如某个循环体的起始和结束区间。一种简化做法是直接在transform里过滤指令地址if (addr.compare(startAddr) 0 addr.compare(loopEndAddr) 0) { instruction.putCallout(...) }配合指令文本检查if (instruction.mnemonic.startsWith(ldr) || instruction.mnemonic.startsWith(mov)) { instruction.putCallout(...); }不过要提醒一点Instruction对象在transform回调里才能拿到完整解析结果在putCallout的callout回调里虽然也能拿到this上下文但instruction.mnemonic不一定能访问到——因为到了实际执行时那块中继代码已经被编译优化过了原始指令对象已经不在作用域。所以事先在transform阶段把要匹配的指令信息存到数组里再在callout中查表是稳妥做法。4. 核心细节解析与实操要点4.1 寄存器的“瞬时快照”到底准不准使用putCallout时一个关键认知要建立你在callout中看到的寄存器是原始指令执行“之后”的状态。我特意说“之后”因为Frida的实现中putCallout是在原始指令之后插入的callout不是之前。如果你想观察某条指令执行前的寄存器那么应该在前一条指令的callout里拿快照或者用flush相关的机制。// 如果你想看某条指令执行前的状态 // 把callout挂在上一条指令上 instruction.prev.putCallout((ctx) { // 此时 ctx 是上一条指令执行完的状态 // 对下一条指令而言即为“执行前” });这个细节很关键。我的脚本里之所以能对比前后寄存器变化就是因为lastRegs保存的是上一条指令执行完的快照而当前callout里拿到的是当前指令执行完的瞬时状态两者相减就是当前指令带来的影响。实际写日志时如果你打印的“地址”是当前指令地址但寄存器内容是执行后的容易造成误解。我习惯在输出行里额外标明“post-insn”或者把lastRegs的指令地址也记下来。4.2 为什么不能一次性打印所有寄存器ARM64通用寄存器特殊寄存器一共33个看起来不多但每条指令都打印33个16字节的十六进制数1000条指令就是GB级文本。这还不说终端输出本身的开销会拖慢Stalker的翻译执行速度。所以我脚本里用了diff机制只输出变化的部分。还有一个常被忽略的事x16、x17、x18这类临时寄存器在ARM64的调用约定里被视作“临时垃圾”任何一次函数调用都可能被改得面目全非。跟踪时它们的变化特别频繁但通常是噪音。相反x19~x28是callee-saved如果不该变却变了那说明代码有猫腻这类变化才是高价值信号。实操建议默认diff输出就可以但如果想快速看关键逻辑手动在REG_NAMES里把x16、x17、x18注释掉保留业务相关的x0~x7和x19~x28。4.3 Stalker的“翻译”不等于“逐条执行”一个常见误解是Stalker.follow后目标代码是逐条指令执行的。其实Stalker以基本块为单位做翻译把一串指令打包成一块“中继代码”然后整体执行这个块。块中的指令顺序不变但执行粒度是块级的。也就是说你的putCallout虽然挂在了每条指令上但Frida会在块内为每条指令生成对应的callout调用不会跳出块到外部去调用JS——它把callout编译进块里了。这块机制带来的直接影响是callout回调里的JavaScript执行不能在本地修改指令流。你可以在回调里读寄存器、做日志、做条件判断但你想改变寄存器内容来影响后续逻辑没门——除非你主动在transform阶段用iterator.putCallout(inst {...})并配合iterator.keep()插入额外指令或者使用Stalker.replace之类的变换接口。4.4 多线程场景怎么办默认Stalker.follow只跟踪当前线程。如果你要监控的native函数会在多个线程并发调用而你在Java层hook入口的线程只有一个那么其他线程内部发生了什么你是看不到的。实战中有两种处理方式在onEnter里获取目标线程id对每个调用线程都执行Stalker.follow。这需要小心Stalker.follow每个线程只能调用一次重复调用会抛异常。可以维护一个线程集合用线程id去重。只跟踪固定线程。分析目标App的运行规律比如某个解密逻辑总是由同一条工作线程执行那用线程过滤反而更干净。我的脚本里直接Stalker.follow当前线程对一个函数入口的hook来说是够用的。但如果你遇到“函数被调用两次一次在A线程一次在B线程”记得在启动条件里加Process.getCurrentThreadId()判断。4.5 性能开销与执行时间的量化Stalker的代价肯定有。指令块翻译本身有CPU开销而每个putCallout都是一次JS回调上下文快照创建开销不小。实测过对一个约2000条指令的功能区做全量diff跟踪单次调用耗时从原来没跟踪时的几百微秒增加到几十毫秒差不多两个数量级。对绝大多数分析场景可以接受但如果目标函数每秒被调用上千次必然影响行为甚至触发超时。我的习惯是先让脚本跑一次把日志存文件然后就停掉或者限制只跟踪一小段地址范围缩小翻译块规模。调优时的几个有效手段在transform里用iterator.putCallout前先判断地址范围缩小跟踪窗口。callback里不要做太复杂的字符串操作更不要做console.log直接打印每一行。把日志行push到数组每N条一次性输出最后落盘。我在脚本里就是每100条flush一次实际执行速度会快不少。如果只是想要“每条指令后x0的变化”不需要diff全部寄存器那就只定义x0相关的快照能省一大堆内存和compare运算。5. 实测案例用脚本定位混淆函数中的关键变换5.1 案例一定位魔改加密的密钥生成某模拟的加密SDKnativeCheck内部有大量无意义的分支、死代码看着像做了控制流平坦化。我直接Stalker.follow整段函数输出的diff日志里x2在高位和低位规律性变化且每次遇到特定指令地址时x2都会被同一个常数异或。日志片段大概长这样[insn 131 0x7420ec998c] x2 0xb4d3e211 - 0x28f4a722 [insn 132 0x7420ec9994] x3 0x0 - 0x28f4a722 [insn 133 0x7420ec9998] x2 0x28f4a722 - 0x738a12d6虽然我没看反汇编但看到x2反复被x3覆盖且x3是同一地址的加载结果就能判断出某处存在查表运算。用diff日志的地址去IDA里看果然是一个LDR从全局表里取值再EOR到x2。后续我用同样的方式把整个密钥派生流程的十几条关键变换全部还原出来了。5.2 案例二观察分支条件的形成绕过校验另一个模拟样本在函数末尾有一段if (checksum ! 0) return 0;但反汇编看不出checksum是怎么算出来的。我用脚本跟踪重点看x0从进入函数到最后返回之间的变化。日志显示x0在一段循环里经历了12次add w0, w0, w9而且w9的取值每次来自内存表。拿到这12个常数后直接把算法写出来了某验证逻辑等效于一个多项式求和。这个案例里x0/w0的低32位和x9/w9的配合关系一目了然省掉了大量的反汇编分析时间。5.3 案例三定位反调试中的自校验反调试逻辑常用ptrace或/proc/self/status读取来检测调试状态但有些高级做法会直接用SVC指令做系统调用然后把返回值写入某个寄存器。对这类逻辑静态分析不容易定位调用点但用Stalker跟踪后可以发现某条指令执行前x8被设置成某个系统调用号执行后x0被写入错误码。我脚本里diff输出的x0一次突变为-1紧跟着就是对该值的判断跳转直接定位到反调试分支。注意对于真实样本跟踪系统调用时如果发现目标存在反调试可能需要配合Frida的Process.setExceptionHandler或模拟系统调用避免程序被系统机制kill。这部分属于后续进阶内容这里只做提醒。5.4 日志落盘与离线分析我把输出直接写到/data/local/tmp/下然后pull到电脑上离线分析adb pull /data/local/tmp/stalker_xxxx.log .然后配合文本工具或简单脚本做分析。有一个小技巧因为diff日志里有地址我会用Python脚本把连续同一个地址的变化聚合形成“该地址的指令写入过哪些寄存器”的直方图用来快速判断某段代码的主要数据流。6. 常见问题与排查技巧实录6.1 putCallout回调不触发最典型的原因没有在目标地址范围内的指令上挂回调。我见过有人把startAddr和endAddr算错方向或者用add(0x400)把end地址算出了模块范围导致整个遍历都没命中。排查时在transform回调里打印一下addr对比预期的范围。另一个可能目标函数是个inline函数没有独立的符号导出。你用Module.findExportByName找不到入口Stalker自然没东西可跟踪。这种情况需要先用Interceptor.attach对附近某个导出函数下钩再手动计算出内联函数的偏移。还有一个容易忽视的问题ARM64的pc在context里指向的是当前指令的地址但Frida有时把callout挂在翻译块末尾。如果你挂的指令全在一个大块末尾回调数量会减少。这种情况用iterator.keep()配合iterator.putCallout的顺序调整可以缓解但通常是地址范围没选对。6.2 跟踪开始后App卡死或闪退多半是回调太重。全量打日志会让Stalker生成的翻译块变得巨大执行太慢。先减少输出只diff少量寄存器还不行就缩小跟踪范围。如果问题是某条指令导致异常用Process.setExceptionHandler捕获一下看看是不是在callout里访问了无效的地址。我自己的经验是context里的字段访问基本安全但如果你自己写了读取内存快照就要小心空指针。6.3 在ARM32上脚本跑不通用标题讲的是ARM但ARMv732位和ARM64的寄存器结构完全不同。ARM64的context暴露的是x0~x28ARMv7则是r0~r12、sp、lr、pc。如果你用我脚本里的x0去跑32位context.x0会是undefined直接导致diff全空。需要判断进程位数if (Process.pointerSize 4) { // 使用 r0-r12 } else { // 使用 x0-x28 }6.4 回调里没拿到预期的指令地址注意putCallout回调里addr变量的来源。如果在transform阶段闭包捕获了instruction.address那么在回调里还可以访问。但如果你在回调里访问this.context.pc它也可能指向翻译后的地址。Frida有个Thread.backtrace和this.returnAddress等手段但对Stalker的回调最好用闭包捕获地址不要依赖pc字段。6.5 停止跟踪后部分日志丢失我前面提到过onLeave里立刻Stalker.unfollow会造成最后几条callout没执行完。更稳的做法是延迟停止或记录一个标记在最后一个callout里输出complete再停止。日志落盘前也做一次flush。6.6 与静态分析结合快速定位语义用Stalker找到一个关键寄存器变化后不要只盯着寄存器值。把地址回填到IDA或Ghidra里看指令上下文理解它为什么这么变化效果更好。我自己的流程是Stalker跟踪找出“哪些指令在改关键寄存器”→把地址列表导出→在静态工具里检查这几条指令的前后指令构建语义。7. 我的几点实战体会多用Stalker几次你会明显感觉到动态插桩在逆向里的不可替代。静态分析看的是“可能的执行路径”而Stalker给的是“实际发生的数据流”尤其在碰到混淆、VMP、壳的时候后者往往更接近真相。如果给新手一个建议我会说别一上来就追求全量跟踪。先选定目标函数先用Interceptor在入口打印一次寄存器基线再用Stalker做小范围diff。拿到几十条日志后把地址和IDA里的指令列对照着看一遍很快就能建立“寄存器值变化 → 代码行为”的直觉。等你熟悉了这套流程再写通用型脚本就水到渠成了。最后分享一个我踩了好几次坑后的习惯把脚本里寄存器的名称列表抽成常量并同时支持ARM32和ARM64把日志文件名带时间戳每次分析完随手清空/data/local/tmp下的旧日志。这三个小习惯能让你的分析过程干净不少。Stalker这工具越用越顺手值得多花时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑