Frida学习笔记(二十四):Stalker%20指令级追踪
原创
泡泡以安
泡泡以安
泡泡以安
2026年6月29日%2008:34
浙江
在小说阅读器读本章
去阅读
本篇目标:掌握%20Frida%20内置的代码追踪引擎%20Stalker,实现指令级别的执行跟踪。前面几篇教的%20
Interceptor%20是「方法级」Hook——它能告诉你函数被调用了、参数是什么、返回值是什么,但看不到函数内部的执行细节。当你面对%20OLLVM%20混淆的加密函数、VMP%20保护的关键逻辑、或者需要分析代码覆盖率时,Stalker%20才是真正的\"杀手锏\"——它能像放慢镜头一样逐条回放每一条汇编指令、每一次%20CALL/RET、每一个寄存器的值变化。本篇从原理到实战,全面讲解%20Stalker%20的使用方法和优化技巧。
一、Stalker%20概述
1.1%20什么是%20Stalker
Stalker%20是%20Frida%20提供的线程级代码追踪引擎,它能够:
- 跟踪线程执行:捕获目标线程执行的每一条指令
- 记录函数调用:追踪所有%20CALL/RET%20指令
- 实时代码插桩:在指令执行前后插入自定义逻辑
- 修改执行流程:通过%20transform%20回调动态修改代码行为
简单来说,如果%20Interceptor%20是在高速公路入口/出口设置的检查站,那么%20Stalker%20就是一架全程跟拍的无人机——它能看到车辆在高速上的每一个动作:变道、加速、转弯,一览无余。
1.2%20底层原理
Stalker%20基于%20动态重编译(Dynamic%20Recompilation)%20机制工作,这种机制也被称为%20copy-on-exec:
Stalker%20动态重编译工作原理
具体流程如下:
- 拦截执行:当目标线程即将执行一个基本块(Basic%20Block,即一段没有分支的连续指令序列)时,Stalker%20拦截执行
- 分配新内存:Stalker%20分配一块新的内存区域
- 复制并重定位:将原始基本块的指令复制到新位置,在复制过程中进行指令重定位(Relocation),处理%20PC%20相关寻址(如%20
adr、adrp、PC-relative%20load%20等) - 插入插桩代码:在指令副本的适当位置插入事件记录代码、回调调用代码等
- 修改跳转:修改基本块末尾的分支指令,使其跳回%20Stalker%20引擎,以便对下一个基本块进行同样的处理
- 执行副本:线程转而执行插桩后的代码副本
什么是基本块(Basic%20Block)?%20基本块是编译原理中的一个基础概念,指一段连续的指令序列,其中只有第一条指令是跳转目标(入口),只有最后一条指令是分支/跳转指令(出口)。换句话说,进入基本块后,程序一定会顺序执行到最后一条指令,中间不会有跳转。
这种%20copy-on-exec%20机制的巧妙之处在于:它不修改原始代码,因此不会被简单的代码完整性校验检测到。已编译的基本块会被缓存在内存中,避免重复编译带来的开销——第二次执行同一个基本块时,直接执行缓存的副本即可。
类比一下:Stalker%20像是一家代工厂——原版代码不动,工厂照着原版复制一份「代工货」,在复制过程中给每条指令贴上小标签(插桩代码),把代工货装回去让%20CPU%20跑。CPU%20跑的全是代工货,完全不知道工厂的存在——所以代码完整性校验(对原版做%20hash)抓不到它。后面要讲的%20Stalker.invalidate(让代工货作废重新生产)、SMC%20兼容(原版自己改了,要重新代工)、反检测(原版没动%20=%20代码%20hash%20校验过得了),三处都是这条「代工厂」机制的直接推论。
1.3%20支持架构
| 架构 | 支持状态 | 典型场景 |
|---|---|---|
| AArch64 | 完整支持 | Android/iOS%20手机和平板 |
| ARM%20(32-bit) | 支持 | 旧版%20Android%20设备 |
| Intel%20x64 | 完整支持 | 桌面应用、模拟器 |
| IA-32%20(x86) | 支持 | 32位桌面应用 |
提示:在实际逆向工作中,AArch64%20是最常用的架构。现代%20Android%20设备(Android%207.0+)和所有%20iOS%20设备基本都是%2064%20位%20ARM。如果你的目标%20App%20仍然只提供%2032%20位%20SO,可以考虑使用%2064%20位设备的%2032%20位兼容模式运行。
1.4%20Stalker%20vs%20Interceptor
Interceptor%20vs%20Stalker%20对比
| 特性 | Interceptor | Stalker |
|---|---|---|
| 粒度 | 函数级 | 指令级 |
| 性能开销 | 低 | 较高(exec%20全开%20~80-100x、call/ret%20~10-15x、稳态%20~3-5x,详见第%205%20章) |
| 使用场景 | Hook%20特定函数 | 追踪执行流程 |
| 修改能力 | 修改参数/返回值 | 修改任意指令 |
| 数据量 | 少量精确数据 | 大量追踪数据 |
| 适用阶段 | 已知目标函数 | 探索未知逻辑 |
最佳实践:先用%20Interceptor%20定位关键函数,再用%20Stalker%20深入分析函数内部的指令执行流程。这种\"先粗后细\"的策略能在效率和精度之间取得最佳平衡。
二、核心%20API%20详解
下面这%209%20个%20API%20不必现在全记。按使用频率分三档:
- 4%20件套(任何%20Stalker%20脚本都会用到):
follow%20/%20unfollow%20/%20flush%20/%20exclude - 按场景使用(知道有就行,等遇到问题再回头查):
invalidate(处理自修改代码)、addCallProbe(函数调用探针)、parse(解析事件二进制) - 配置项(写完一次基本不动):
queueCapacity%20/%20queueDrainInterval%20/%20trustThreshold
读本章时不要逐%20API%20死记;混个脸熟,等%20第%203%20章%20看到完整脚本时再回头查表。下图先给个%20API%20关系全景图:
Stalker%20核心%20API%20全景图
2.1%20Stalker.follow(threadId,%20options)
这是%20Stalker%20最核心的%20API,用于开始跟踪指定线程。它接受线程%20ID%20和一个配置对象,配置对象中可以指定要捕获的事件类型和回调函数。
//%20Stalker.follow%20-%20开始跟踪指定线程
//%20threadId:%20目标线程的%20ID(可通过%20Process.enumerateThreads()%20或%20this.threadId%20获取)
Stalker.follow(threadId, {
//%20事件配置:指定要捕获哪些类型的事件
events: {
call: true, //%20捕获%20CALL%20指令(函数调用,如%20BL/BLR)
ret: false, //%20捕获%20RET%20指令(函数返回)
exec: false, //%20捕获所有执行的指令(慎用!数据量极大,可能导致卡死)
block: false, //%20捕获基本块执行(每次进入一个基本块时触发)
compile: false //%20捕获基本块编译(仅在首次编译时触发,适用于代码覆盖率收集)
},
//%20方式一:接收原始事件数据(二进制格式,数据更完整)
onReceive(events) {
//%20events%20是%20ArrayBuffer,包含%20GumEvent%20结构体数组
//%20需要使用%20Stalker.parse()%20解析为可读格式
const parsed = Stalker.parse(events, {
annotate: true, //%20在结果中标注事件类型(call/ret/exec/block/compile)
stringify: true //%20将%20NativePointer%20格式化为十六进制字符串(减少%20send()%20的序列化开销)
});
console.log(JSON.stringify(parsed));
},
//%20方式二:接收调用统计摘要(更高效,适合快速了解调用分布)
//%20注意:onReceive%20和%20onCallSummary%20是互斥的,只能选择其中一个!
//%20onCallSummary(summary)%20{
//%20%20%20%20%20//%20summary%20是一个对象:{%20\"0x7fff12345\":%2042,%20\"0x7fff12346\":%2010,%20...%20}
//%20%20%20%20%20//%20key%20为调用目标地址,value%20为在统计周期内的调用次数
//%20},
//%20高级功能:transform%20回调(后续第四节详细介绍)
//%20transform(iterator)%20{%20...%20}
});
重要提示:onReceive%20和%20onCallSummary%20只能二选一。如果你不需要某种回调,不要传空函数(如%20onReceive()%20{}),因为即使回调是空的,Stalker%20仍然会为其生成事件数据,造成不必要的性能开销。
2.2%20Stalker.unfollow(threadId)
停止跟踪指定线程。如果省略参数,则停止跟踪当前线程:
//%20停止跟踪指定线程
Stalker.unfollow(targetThreadId);
//%20停止跟踪当前线程(省略参数时默认当前线程)
Stalker.unfollow();
2.3%20Stalker.flush()
立即刷新事件缓冲区,将已缓冲的事件数据发送到%20onReceive%20/%20onCallSummary%20回调:
//%20强制刷新缓冲区,不等待下一个%20queueDrainInterval%20周期
Stalker.flush();
默认情况下,Stalker%20每%20250ms%20自动刷新一次(由%20queueDrainInterval%20控制)。也就是说,事件并不是实时发送的,而是先积攒在缓冲区中,达到时间间隔或缓冲区满时才批量发送。调用%20flush()%20可以立即获取最新数据,这在跟踪结束时(unfollow%20之前)尤其重要——确保最后一批事件不会丢失。
类比一下:Stalker%20内部事件队列像快递分拣中心%20——%20件先攒着,每%20250ms%20集中发车一次。flush%20是手动催发车%20——「这批不等了,现在就走」。如果你%20unfollow%20后不催一脚就直接退出,最后一批件可能还在分拣中心待发,这就是为什么%20unfollow%20之前要主动%20flush。
2.4%20Stalker.garbageCollect()
在安全时机回收已释放的代码块内存:
//%20典型用法:先%20unfollow,等待一小段时间,再回收内存
Stalker.unfollow(threadId);
//%20等待%2050ms,确保线程已经完全离开插桩代码区域
setTimeout(() => {
Stalker.garbageCollect();
}, 50);
为什么需要这个%20API?%20当%20unfollow()%20被调用时,被跟踪的线程可能仍在执行最后一段插桩代码(因为%20unfollow%20是异步生效的)。如果此时立即释放这些代码块的内存,线程就会跳转到已被回收的内存地址,导致段错误崩溃。garbageCollect()%20通过延迟释放机制,确保在安全时机才释放内存,避免这种竞态条件(Race%20Condition)。
下面这张图用双时间轴把整个生命周期可视化:上轨是你在%20JS%20里主动调的%20API(follow%20→%20unfollow%20→%20flush%20→%20setTimeout%20→%20garbageCollect);下轨是事件实际发生的节奏(每%20250ms%20一次的%20onReceive%20圆点)。两轨之间用虚线连接「调用入队」和「实际触发」的异步关系。
Stalker%20事件生命周期%20·%20异步%20drain%20与%2050ms%20安全窗
图中最关键的认知:琥珀色「异步%20drain%20区」——unfollow%20之后,内部缓冲队列里残留的事件仍按%20250ms%20节奏%20send%20完才停。所以从%20unfollow%20调用到%20garbageCollect%20安全之间,必须等够%2050ms(图中灰色「安全窗」)。反例区直接画出「unfollow%20→%20立刻%20garbageCollect%20→%20SIGSEGV」的崩溃路径,这是%20attach%20模式新手最常踩的坑。
2.5%20Stalker.exclude(range)
排除指定内存范围,Stalker%20不会跟踪进入该范围的代码执行。这是性能优化最重要的手段:
//%20排除系统库,大幅降低噪音和性能开销
//%20排除%20libc%20—%20标准%20C%20库的函数调用(如%20malloc、memcpy)不需要追踪
const libc = Process.getModuleByName(\"libc.so\");
Stalker.exclude(libc);
//%20排除%20linker%20—%20动态链接器的内部逻辑与分析目标无关
const linker = Process.getModuleByName(\"linker64\");
Stalker.exclude(linker);
//%20排除%20Frida%20自身的%20agent%20—%20避免追踪%20Frida%20内部代码导致死循环
const fridaAgent = Process.getModuleByName(\"frida-agent-64.so\");
Stalker.exclude(fridaAgent);
比喻:如果不使用%20
exclude,就像在整个城市安装了监控摄像头来追踪一个人——你会淹没在海量无关的监控画面中。使用%20exclude%20就是只在目标人物可能出没的区域安装摄像头,既节省资源又减少噪音。
2.6%20Stalker.invalidate(address)
使指定地址所在基本块的已编译缓存失效,强制%20Stalker%20在下次执行该基本块时重新编译:
//%20使当前线程中某地址的缓存失效
Stalker.invalidate(ptr(\"0x12345678\"));
//%20使指定线程中的缓存失效
Stalker.invalidate(threadId, ptr(\"0x12345678\"));
这比%20unfollow()%20+%20重新%20follow()%20要高效得多,因为后者会丢弃所有已缓存的编译结果,而%20invalidate%20只影响一个基本块。适用于需要动态调整某个基本块的插桩策略时——比如在分析过程中发现某个基本块是关键的加密循环,想要添加更详细的追踪。
2.7%20Stalker.addCallProbe(address,%20callback)
在指定地址设置调用探针(Call%20Probe),当被跟踪的线程执行到该地址的%20CALL%20指令时,会同步调用回调函数:
//%20在目标地址设置探针,每次被调用时打印第一个参数
const probeId = Stalker.addCallProbe(targetAddr, function(args) {
console.log(\"Call%20to%20target,%20arg0:\", args[0]);
});
//%20不再需要时移除探针
Stalker.removeCallProbe(probeId);
与%20Interceptor.attach%20的区别:Call%20Probe%20只在%20Stalker%20跟踪的线程中生效,而%20Interceptor.attach%20对所有线程生效。Call%20Probe%20的优势是可以与%20Stalker%20的其他功能(如%20transform)协同工作。
2.8%20Stalker.parse(events,%20options)
解析%20onReceive%20收到的原始二进制事件数据,将%20ArrayBuffer%20转换为可读的数组格式:
onReceive(events) {
const parsed = Stalker.parse(events, {
annotate: true, //%20在结果中标注事件类型(call/ret/exec/block/compile)
stringify: true //%20将%20NativePointer%20转为十六进制字符串(减少后续%20send()%20的开销)
});
send({ type: \"stalker-events\", payload: parsed });
}
GumEvent%20是%20Frida%20内部的%20C%20结构体名,负责把指令地址%20/%20事件类型%20/%20调用关系打包成紧凑二进制。Stalker.parse%20已经替你做了反序列化,平时不用直接接触这个名字——但你会在错误信息和%20Frida%20源码里看到它。
event%20数组字段对照表(annotate%20模式下,event[0]%20始终是事件类型字符串,后续字段因类型而异):
| 事件类型 | event%20数组结构 | 字段含义 |
|---|---|---|
call |
[\"call\",%20location,%20target,%20depth] |
源地址(BL%20所在%20PC)、目标地址、调用深度 |
ret |
[\"ret\",%20location,%20target,%20depth] |
源地址(RET%20所在%20PC)、返回地址、调用深度 |
exec |
[\"exec\",%20location] |
当前执行指令的%20PC |
block |
[\"block\",%20begin,%20end] |
基本块起止地址 |
compile |
[\"compile\",%20begin,%20end] |
基本块编译时的起止地址(首次编译触发) |
照搬到其他事件场景时,别只看%20event[1]%20/%20event[2]——不同类型字段数不一样,exec%20只有%202%20个字段、call/ret%20有%204%20个字段。
2.9%20配置属性
//%20事件队列容量(可容纳的事件数量上限)
//%20当队列满时会立即触发%20onReceive/onCallSummary,不等待%20drainInterval
Stalker.queueCapacity = 16384; //%20默认值
//%20事件队列自动排空间隔(毫秒)
//%20Stalker%20每隔这么长时间就把积攒的事件发送给回调
Stalker.queueDrainInterval = 250; //%20默认值%20250ms
//%20设为%200%20可禁用自动排空,完全由你手动调用%20Stalker.flush()%20控制
//%20信任阈值%20—%20控制对自修改代码(Self-Modifying%20Code,%20SMC)的检测
Stalker.trustThreshold = 1; //%20默认值:基本块执行%201%20次后信任(不再检查是否被修改)
//%20-1%20=%20永不信任(每次执行都重新编译,非常慢,但能处理自修改代码/VMP%20保护)
//%20%200%20=%20立即信任(第一次编译后就不再检查,性能最好)
//%20%20N%20=%20执行%20N%20次后信任
三、快速上手:第一个%20Stalker%20脚本
3.1%20环境准备
确保你的环境已经就绪:
#%20安装%20Frida%20工具(建议使用%20pip%20安装,确保版本一致性)
pip install frida-tools
#%20检查%20Frida%20版本(建议%2016.x%20以上,Stalker%20在新版本中有显著性能改进)
frida --version
#%20确认设备连接(Android%20设备需要通过%20USB%20连接并开启%20USB%20调试)
frida-ls-devices
#%20确认%20frida-server%20在设备上运行
#%20frida-server%20版本必须与本地%20frida-tools%20版本一致
adb shell \"ps%20-ef%20|%20grep%20frida\"
环境排错提示:如果%20
frida-ls-devices%20看不到设备,检查%20adb%20连接状态;如果连接后提示版本不匹配,确保%20frida-server%20和%20frida-tools%20的版本号完全一致(包括小版本号)。API%20兼容性%20──%20本篇代码示例基于%20Frida%2016.x。Frida%2017%20起部分模块%20API%20语义有变(
Module.findExportByName%20→%20Module.getExportByName、Process.getModuleByName(name).getExportByName(...)),17+%20用户跑示例代码时根据报错替换即可。跨篇关联%20──%20Stalker%20假设你已经能定位到目标函数地址。定位本身是%20第%2021%20篇%20的主题:符号被%20strip%20时的特征码%20/%20字符串交叉引用%20/%20调用点反查等多策略兜底。本篇假设你已经拿到地址,直接进%20Stalker.follow。
3.2%20最小示例:跟踪函数调用
创建文件%20stalker_basic.js。这段脚本结构很简单——用%20Interceptor%20当触发器、在%20onEnter%20启%20Stalker、在%20onLeave%20收%20Stalker。读代码时只看%203%20处:①%20events%20配置只开%20call(其他全关,避免噪音);②%20onCallSummary%20拿调用统计(比%20onReceive%20高效);③%20onLeave%20里立刻%20unfollow%20+%20flush(避免回调淹没)。其余是参数解析和符号解析的样板,扫一眼有印象就行:
//%20stalker_basic.js%20—%20跟踪目标函数内部的所有%20CALL%20指令
//%20场景:监控%20libc%20的%20open%20函数执行过程中调用了哪些子函数
//%20获取%20libc%20中%20open%20函数的地址
const openAddr = Module.findExportByName(\"libc.so\", \"open\");
//%20使用%20Interceptor%20作为触发器:当%20open%20被调用时启动%20Stalker
Interceptor.attach(openAddr, {
onEnter(args) {
//%20读取%20open%20的第一个参数(文件路径)
const path = args[0].readUtf8String();
//%20只对包含特定关键词的路径启动追踪,避免噪音
if (path && path.includes(\"target_file\")) {
console.log(`[*]%20open(\"${path}\")%20—%20开始%20Stalker%20追踪`);
//%20跟踪当前线程(this.threadId%20是触发%20onEnter%20的线程%20ID)
Stalker.follow(this.threadId, {
events: {
call: true, //%20只关心函数调用事件
ret: false,
exec: false,
block: false,
compile: false
},
//%20使用%20onCallSummary%20获取调用统计(比%20onReceive%20更高效)
onCallSummary(summary) {
console.log(\"[*]%20调用统计:\");
//%20summary%20的%20key%20是目标地址,value%20是调用次数
for (const [addr, count] of Object.entries(summary)) {
//%20尝试解析地址所属的模块和符号名
const module = Process.findModuleByAddress(ptr(addr));
const name = module ? module.name : \"unknown\";
const func = DebugSymbol.fromAddress(ptr(addr));
console.log(`${addr} (${name}!${func.name || \"?\"})%20=>%20${count} 次`);
}
}
});
}
},
onLeave(retval) {
//%20函数返回时停止跟踪
Stalker.unfollow(this.threadId);
Stalker.flush(); //%20确保最后一批事件被发送
}
});
真机案例%20──%20这套%20onCallSummary%20+%20exclude%20配方在%20Pixel%205%20/%20Android%2014%20/%20frida%2016.7.19%20+%20QQ%20音乐%20attach%20模式上实测命中:hook%20
libc.so!pthread_mutex_lock%20触发后,Stalker%20在一次%20onLeave%20之内拿到%2023%20个%20unique%20目标地址,top%20命中%20libDownloadProxy.so%20+%200x15b8d0(176%20次)和%20libc.so!__aarch64_swp2_rel(176%20次)。意外收获:unfollow%20之后%20onCallSummary%20仍按%20250ms%20节奏触发%2010%20次——这正是第%202.3%20节%20queueDrainInterval%20异步%20drain%20机制的真机印证。完整数据%20+%20配套脚本见%20scripts/frida24/。
运行:
#%20-U:%20使用%20USB%20连接的设备
#%20-f:%20以%20spawn%20模式启动%20App(Frida%20负责启动进程)
#%20-l:%20加载指定的%20JS%20脚本
frida -U -f com.example.target -l stalker_basic.js
3.3%20使用%20onReceive%20获取详细事件
当你需要更详细的事件信息(如调用深度、源地址和目标地址)时,使用%20onReceive:
//%20stalker_events.js%20—%20获取详细的调用和返回事件
function stalkThread(tid, duration) {
console.log(`[*]%20开始跟踪线程%20${tid},持续%20${duration}ms`);
Stalker.follow(tid, {
events: {
call: true, //%20捕获函数调用
ret: true, //%20捕获函数返回(配合%20call%20可以还原调用链)
exec: false,
block: false,
compile: false
},
onReceive(events) {
//%20解析原始二进制事件数据
const parsed = Stalker.parse(events, {
annotate: true, //%20标注事件类型
stringify: true //%20指针转字符串
});
for (const event of parsed) {
//%20annotate%20模式下的事件格式:
//%20call%20事件:[\"call\",%20fromAddr,%20toAddr,%20depth]
//%20ret%20事件:[\"ret\",%20fromAddr,%20toAddr,%20depth]
if (event[0] === \"call\") {
const target = event[2]; //%20调用目标地址
const sym = DebugSymbol.fromAddress(ptr(target));
if (sym.name) {
console.log(` CALL%20=>%20${sym.moduleName}!${sym.name}`);
}
}
}
}
});
//%20指定时长后自动停止跟踪
setTimeout(() => {
Stalker.unfollow(tid);
Stalker.flush();
//%20延迟回收内存,避免竞态崩溃
setTimeout(() => Stalker.garbageCollect(), 50);
console.log(`[*]%20线程%20${tid} 跟踪结束`);
}, duration);
}
//%20获取主线程并跟踪%203%20秒
const mainThread = Process.enumerateThreads()[0];
stalkThread(mainThread.id, 3000);
3.4%20常见坑点
- 线程选择:不要随意跟踪主线程(UI%20线程),Stalker%20会显著降低执行速度,可能导致%20App%20卡死甚至触发%20ANR(Application%20Not%20Responding)。建议先用%20
Process.enumerateThreads()%20了解线程分布,选择目标工作线程 - 时机控制:在%20
Interceptor.attach%20的%20onEnter%20中启动%20Stalker,onLeave%20中停止,可以精确控制跟踪范围,避免追踪无关代码 - 事件回调互斥:
onReceive%20和%20onCallSummary%20只能二选一,传两个会导致只有一个生效 - 资源清理:
unfollow%20后务必在安全时机(延迟%2050ms%20左右)调用%20garbageCollect,否则可能出现内存泄漏
四、transform%20回调%20—%20Stalker%20的核心能力
transform%20是%20Stalker%20最强大的功能,它允许你在基本块编译阶段介入,逐条检查和修改指令。如果说事件回调(onReceive/onCallSummary)是\"被动观察\",那么%20transform%20就是\"主动参与\"。
类比一下:transform%20像是给汇编代码加一道审查工序——每条指令进来都过你的手,你决定让它过(keep)、改(在%20keep%20前后插桩)、加注释(putCallout%20打日志)、还是丢掉(不调%20keep%20就直接%20NOP%20掉)。事件回调只看「程序在执行什么」,transform%20直接「参与」程序在执行什么。
真机案例%20──%20真机跑%20transform%20看一次%20
libc.so!pthread_mutex_lock%20内部:23%20个基本块、66%20条指令、19%20种%20mnemonic。top%205%20是%20mov(8)%20/%20b(7)%20/%20ldr(7)%20/%20bl(6)%20/%20ldp(6)——典型%20AArch64%20调用栈分布(load/store%20占主导、控制流中等、ALU%20少)。完整数据见%20scripts/frida24/recipe/step2_transform.js%20与%20outputs/qq_stalker_validation.txt。
4.1%20工作原理
当%20Stalker%20遇到一个新的基本块需要编译时,会调用%20transform%20回调。在回调内部,你通过%20iterator%20遍历基本块中的每条指令,可以:
- 遍历基本块中的每条指令:获取地址、助记符、操作数等信息
- 选择保留或丢弃指令:调用%20
iterator.keep()%20保留,不调用则丢弃 - 在指令前后插入自定义代码:使用%20
iterator.putCallout()%20插入%20JavaScript%20回调 - 读取指令的详细信息:地址、助记符(如%20
mov、bl、ret)、操作数字符串等
下图展示了%20transform%20回调的完整工作流程:
Transform%20回调工作流程
4.2%20基本结构
Stalker.follow(threadId, {
//%20transform%20在每个基本块首次编译时被调用
//%20编译结果会被缓存,同一基本块不会重复调用%20transform
transform(iterator) {
//%20iterator.next()%20前进到下一条指令,返回指令对象
let instruction = iterator.next();
do {
//%20instruction%20对象包含以下属性:
//%20instruction.address%20—%20指令在原始代码中的地址(NativePointer)
//%20instruction.mnemonic%20—%20指令助记符,如%20\"mov\",%20\"bl\",%20\"ret\",%20\"ldr\"
//%20instruction.opStr%20—%20操作数字符串,如%20\"x0,%20x1\",%20\"#0x10\"
//%20instruction.size%20—%20指令的字节长度
//%20必须调用%20keep()%20保留原始指令到输出
//%20如果不调用%20keep(),该指令会被丢弃(相当于%20NOP%20掉)
iterator.keep();
} while ((instruction = iterator.next()) !== null);
//%20next()%20返回%20null%20表示已遍历完当前基本块的所有指令
}
});
如果不提供%20transform%20回调,Stalker%20的默认行为等价于:
//%20默认行为:原封不动地保留所有指令
transform(iterator) {
while (iterator.next() !== null)
iterator.keep();
}
4.3%20iterator%20API
| 方法 | 说明 |
|---|---|
iterator.next() |
前进到下一条指令,返回指令对象或%20null(遍历结束) |
iterator.keep() |
将当前指令写入输出(保留指令),不调用则丢弃该指令 |
iterator.putCallout(callback) |
在当前位置插入一个%20JavaScript%20回调函数调用 |
iterator.putChainingReturn() |
插入提前返回(让%20Stalker%20回到调度循环) |
iterator.memoryAccess |
返回%20'open'%20表示当前位置可以安全插入代码(不在%20exclusive%20序列中) |
4.4%20使用%20putCallout%20插入回调
putCallout%20是最常用的%20transform%20操作,它在指定位置插入一个%20JavaScript%20回调。回调接收%20context%20参数,包含当前%20CPU%20所有寄存器的状态(在%20AArch64%20上包括%20x0-x28、sp、lr、pc%20等)。
类比一下:putCallout%20像是在某条指令旁边贴一张便利贴——指令执行到这里时,「便利贴」上的%20JS%20代码会被回头看一眼,你可以在里面读寄存器、记日志、改状态。便利贴本身不影响指令执行,只是「顺道」让%20JS%20介入一次。
便利贴回调里拿到的%20context%20参数是一整盒%20CPU%20寄存器快照。在%20AArch64%20上分%205%20组——下图按%20ABI%20角色把它画清楚:
AArch64%20Context%20寄存器礼物盒
读寄存器的口诀:x0-x7%20是入参%20/%20返回值(BL%20之前%20hook%20拿参数、RET%20之前%20hook%20拿返回值)、x19-x28%20是%20callee-saved(关键中间变量如%20SHA%20状态机、AES%20轮密钥常驻这里)、sp%20是栈指针(context.sp.readPointer()%20读栈顶值、context.sp.add(8)%20偏移读上一槽)、lr%20是返回地址(DebugSymbol.fromAddress(context.lr)%20推%20caller)、pc%20是当前%20PC(减%20module.base%20拿%20IDA%20G%20跳要的偏移)。
//%20示例:在每个%20BL(分支链接/函数调用)指令前插入回调,记录调用目标和参数
const targetModule = Process.getModuleByName(\"libtarget.so\");
const moduleBase = targetModule.base;
const moduleEnd = moduleBase.add(targetModule.size);
Stalker.follow(threadId, {
transform(iterator) {
let instruction = iterator.next();
//%20判断当前基本块是否属于目标模块
//%20注意:这个判断放在%20do-while%20外面,因为基本块内所有指令属于同一模块
const isTargetModule = instruction.address.compare(moduleBase) >= 0 &&
instruction.address.compare(moduleEnd) < 0;
do {
//%20只对目标模块内的%20BL%20指令插桩
if (isTargetModule && instruction.mnemonic === \"bl\") {
//%20在%20BL%20指令执行前插入回调
iterator.putCallout((context) => {
//%20context%20提供当前%20CPU%20寄存器的完整快照
console.log(`BL%20at%20${context.pc},%20target:%20${instruction.opStr}`);
//%20在%20AArch64%20调用约定中,x0-x7%20是前%208%20个参数
console.log(` x0=${context.x0},%20x1=${context.x1}`);
});
}
iterator.keep(); //%20保留原始指令
} while ((instruction = iterator.next()) !== null);
}
});
4.5%20条件性插桩:只关注特定范围
在实际分析中,我们通常只关心目标%20SO%20库内部的指令执行,系统库的代码不需要追踪。通过地址范围判断可以大幅减少插桩点:
//%20只对目标%20SO%20库的代码进行插桩,忽略其他模块
const targetModule = Process.getModuleByName(\"libtarget.so\");
const moduleStart = targetModule.base;
const moduleEnd = moduleStart.add(targetModule.size);
Stalker.follow(threadId, {
transform(iterator) {
let instruction = iterator.next();
const blockStart = instruction.address;
//%20判断当前基本块是否属于目标模块
//%20只需检查基本块起始地址即可,同一基本块内的指令连续排列
const isInTarget = blockStart.compare(moduleStart) >= 0 &&
blockStart.compare(moduleEnd) < 0;
do {
if (isInTarget) {
//%20在目标模块中的每条指令前插入追踪回调
//%20注意:对每条指令都插入回调会产生巨大的性能开销
//%20实际分析中建议只对关键指令(如%20bl、ret、cmp)插桩
iterator.putCallout((context) => {
console.log(`[${context.pc}]%20${instruction.mnemonic}${instruction.opStr}`);
});
}
iterator.keep();
} while ((instruction = iterator.next()) !== null);
}
});
4.6%20ARM/AArch64%20注意事项
常用助记符速查%20──%20下文用到的%20
BL(分支链接%20=%20函数调用)、BLR(寄存器分支链接%20=%20间接调用)、RET(返回)、EOR(异或)、LDR/STR(load/store)、LDXR/STXR(exclusive%20load/store)是%20AArch64%20最常见的几条。系统学过%20ARM%20汇编的读者直接读;没学过的看本系列%20第%2020%20篇%20ARM%20汇编速成%20补一下,本节剩余部分等你看完再回来。
在%20ARM%20架构上,需要特别注意%20exclusive%20load/store%20指令序列。这类指令用于实现原子操作(如互斥锁、CAS%20操作),在%20ldxr(exclusive%20load)和%20stxr(exclusive%20store)之间不能插入任何额外的内存访问或分支指令,否则会导致%20exclusive%20monitor%20被清除,进而导致无限重试或崩溃:
transform(iterator) {
let instruction = iterator.next();
do {
//%20检查是否可以安全插入代码
//%20'open'%20表示当前不在%20exclusive%20load/store%20序列中
//%20在%20exclusive%20序列中插入%20putCallout%20会导致崩溃!
const canInsertCode = iterator.memoryAccess === 'open';
if (canInsertCode && instruction.mnemonic === \"ret\") {
//%20只在安全位置插入回调
iterator.putCallout((context) => {
console.log(`RET%20at%20${context.pc},%20x0=${context.x0}`);
});
}
iterator.keep();
} while ((instruction = iterator.next()) !== null);
}
什么是%20Exclusive%20Load/Store?%20在%20ARM%20架构中,
ldxr/stxr%20指令对用于实现无锁的原子操作。ldxr%20加载一个值并设置%20exclusive%20monitor,stxr%20尝试写入并返回是否成功。如果在两者之间有任何干扰(包括中断、其他内存访问),stxr%20会失败,程序需要重试。Stalker%20的%20putCallout%20会插入函数调用,这会破坏%20exclusive%20monitor,因此必须检查%20memoryAccess。
五、实用技巧与性能优化
Stalker%20的指令级追踪不可避免地带来性能开销,但通过合理的优化策略,可以将开销控制在可接受的范围内。
Stalker%20性能开销对比
5.1%20使用%20Stalker.exclude%20排除无关模块
这是最重要的优化手段,效果立竿见影:
//%20策略一:排除已知的系统库和框架
const excludeList = [
\"libc.so\", //%20C%20标准库
\"libm.so\", //%20数学库
\"libdl.so\", //%20动态链接相关
\"liblog.so\", //%20Android%20日志
\"linker64\", //%20动态链接器
\"libandroid_runtime.so\", //%20Android%20运行时
\"libart.so\", //%20ART%20虚拟机
\"libbase.so\", //%20Android%20基础库
\"frida-agent-64.so\" //%20Frida%20自身(必须排除,否则可能死循环)
];
for (const name of excludeList) {
try {
const mod = Process.getModuleByName(name);
Stalker.exclude(mod);
console.log(`[*]%20已排除:%20${name}`);
} catch (e) {
//%20模块不存在(比如在不同设备上库名不同),忽略
}
}
//%20策略二(更激进):只保留目标模块,排除其他所有模块
//%20适用于明确知道只需要分析某个特定%20SO%20的场景
const targetModuleName = \"libtarget.so\";
for (const mod of Process.enumerateModules()) {
if (mod.name !== targetModuleName) {
Stalker.exclude(mod);
}
}
5.2%20精确选择事件类型
不同的分析场景需要不同的事件类型,选错事件类型会导致要么数据不够、要么性能爆炸:
//%20!!%20不要这样做%20—%20exec%20事件数据量巨大,通常一秒钟就能产生数百万条事件
Stalker.follow(tid, {
events: { call: true, ret: true, exec: true, block: true, compile: true },
onReceive(events) { /*%20...%20*/ }
});
//%20===%20按需选择正确的事件类型%20===
//%20场景1:只需要知道调用了哪些函数及频率%20—%20用%20onCallSummary(最高效)
Stalker.follow(tid, {
events: { call: true },
onCallSummary(summary) { /*%20{%20地址:%20次数%20}%20*/ }
});
//%20场景2:需要代码覆盖率%20—%20只开%20compile(最小数据量)
//%20compile%20事件只在基本块首次编译时触发,不会重复
Stalker.follow(tid, {
events: { compile: true },
onReceive(events) { /*%20...%20*/ }
});
//%20场景3:需要完整调用链(含调用深度)—%20call%20+%20ret
Stalker.follow(tid, {
events: { call: true, ret: true },
onReceive(events) { /*%20...%20*/ }
});
5.3%20合理使用%20flush
//%20方案1:减小排空间隔(数据更实时,但回调更频繁)
Stalker.queueDrainInterval = 100; //%20100ms,默认%20250ms
//%20方案2:禁用自动排空,完全手动控制
//%20适合精确分析:在目标函数执行完毕后手动%20flush
Stalker.queueDrainInterval = 0;
//%20在关键时机手动%20flush
Interceptor.attach(keyFuncAddr, {
onLeave() {
Stalker.flush(); //%20确保函数执行期间产生的所有事件都已发送到回调
}
});
5.4%20线程管理策略
//%20策略1:精确跟踪%20—%20只在目标函数执行期间追踪
//%20优点:追踪范围精确,数据量可控
Interceptor.attach(targetFunc, {
onEnter() {
this.stalking = true;
Stalker.follow(this.threadId, { /*%20options%20*/ });
},
onLeave() {
if (this.stalking) {
Stalker.unfollow(this.threadId);
Stalker.flush();
this.stalking = false;
}
}
});
//%20策略2:定向跟踪%20—%20找到特定工作线程进行持续跟踪
const threads = Process.enumerateThreads();
for (const thread of threads) {
//%20通过线程名、状态或其他标识来选择目标线程
console.log(`Thread%20${thread.id}:%20state=${thread.state}`);
}
5.5%20大量数据分批传输
当追踪产生大量数据时,直接在%20JS%20端通过%20send()%20传输可能造成瓶颈。核心思路:JS%20端先在内存里攒到%201000%20条再%20send,Python%20端用「定时%20flush%20+%20最终%20flush%20兜底」两层保险。代码看%202%20处:①%20JS%20的%20BATCH_SIZE%20=%201000%20阈值触发批量%20send;②%20Python%20的%20pending%20缓冲%20+%20flush_buffer%20定时器,避免最后几条事件因不足%20batch%20而漏掉:
//%20Frida%20脚本端:分批积攒事件,达到阈值后批量发送
let eventBuffer = [];
const BATCH_SIZE = 1000;
Stalker.follow(tid, {
events: { call: true },
onReceive(events) {
const parsed = Stalker.parse(events, { stringify: true });
eventBuffer = eventBuffer.concat(parsed);
//%20达到批次大小时发送
if (eventBuffer.length >= BATCH_SIZE) {
send({ type: \"stalker-batch\", events: eventBuffer });
eventBuffer = [];
}
}
});
Python%20端接收并写入文件:
import frida
import json
def on_message(message, data):
\"\"\"处理%20Frida%20脚本发来的消息\"\"\"
if message[\"type\"] == \"send\":
payload = message[\"payload\"]
if payload.get(\"type\") == \"stalker-batch\":
#%20以%20JSONL%20格式追加写入文件(每行一个事件,便于后续分析)
with open(\"stalker_trace.jsonl\", \"a\") as f:
for event in payload[\"events\"]:
f.write(json.dumps(event) + \"\
\")
#%20加载脚本...
script.on(\"message\", on_message)
5.6%20Stalker%20+%20Interceptor%20配合
这是最常用的实战模式——用%20Interceptor%20作为\"触发器\",在特定条件下才启动%20Stalker:
//%20先用%20Interceptor%20确认目标函数被调用,再用%20Stalker%20深入分析
//%20场景:某个函数在%20App%20启动时会被调用数百次,但只有第%203%20次是我们关心的
let callCount = 0;
Interceptor.attach(targetAddr, {
onEnter(args) {
callCount++;
//%20只在第%203%20次调用时启动%20Stalker%20分析(避免前两次的干扰数据)
if (callCount === 3) {
console.log(\"[*]%20第3次调用,启动%20Stalker%20深度追踪\");
Stalker.follow(this.threadId, {
transform(iterator) {
let insn = iterator.next();
do {
//%20只追踪函数调用指令
if (insn.mnemonic === \"bl\" || insn.mnemonic === \"blr\") {
iterator.putCallout((ctx) => {
console.log(` ->%20${insn.mnemonic} at%20${ctx.pc}`);
});
}
iterator.keep();
} while ((insn = iterator.next()) !== null);
}
});
}
},
onLeave(retval) {
if (callCount === 3) {
Stalker.unfollow(this.threadId);
Stalker.flush();
}
}
});
六、应用场景
Stalker%20在实战里最常用的%206%20个落点列在下面。其中%20第%206.3%20节%20/%20第%206.4%20节%20/%20第%206.6%20节%20会反复出现%20OLLVM%20这个词:它是%20Obfuscator-LLVM%20的简称——基于%20LLVM%20的代码混淆器,常被用来混淆%20Native%20加密算法(控制流平坦化、字符串加密、虚假控制流等)。静态反编译看%20OLLVM%20输出像看天书,但运行时指令依然按汇编一条一条跑——所以%20Stalker%20这种动态追踪是%20OLLVM%20的天敌。
6.1%20代码覆盖率收集(Coverage)
代码覆盖率是%20Stalker%20最经典的应用场景之一。像在地图上画过的足迹%20——%20路过哪条街,就在地图上把那一格涂亮;没去过的街区保持灰色。读者一眼看到%20App%20走了哪条逻辑分支、哪些%20if%20没进、哪些函数从未执行。
实现方式:通过收集哪些基本块被执行过,然后导出为%20drcov%20格式(DynamoRIO%20的覆盖率数据格式),可以在%20IDA%20的%20Lighthouse%20插件或%20Ghidra%20中实现可视化的覆盖率着色——执行过的代码会被高亮显示,未执行的代码保持灰色。
drcov%20最初是%20DynamoRIO%20项目(类似%20Frida%20的动态二进制插桩框架)定义的覆盖率二进制格式,后来被%20IDA%20Lighthouse%20/%20Ghidra%20Coverage%20等可视化工具广泛复用。你只要拼一个简单的文本头%20+%20二进制%20BB%20表,Lighthouse%20就认账——不需要懂%20DynamoRIO%20本身怎么用。
脚本分两段读:①%20数据采集(compile%20事件%20→%20coveredBlocks%20Map%20天然去重);②%20导出%20drcov%20v2%20+%20RPC%20暴露给%20Python%20端触发。两段之间%20Stalker%20一直跑着不停。
第一段:数据采集%20──%20compile%20事件%20+%20Map%20去重
//%20coverage.js%20(1/2)%20—%20数据采集:Stalker.follow%20配合%20compile%20事件
const targetModule = Process.getModuleByName(\"libtarget.so\");
const moduleBase = targetModule.base;
const moduleSize = targetModule.size;
const modulePath = targetModule.path;
//%20使用%20Map%20存储已执行的基本块:offset%20->%20size
//%20用%20Map%20而不是%20Set%20是因为需要同时记录偏移和大小
const coveredBlocks = new Map();
Stalker.follow(Process.getCurrentThreadId(), {
events: {
compile: true //%20只需要%20compile%20事件即可!
//%20compile%20在基本块首次被编译时触发一次,天然去重
//%20比%20block(每次执行都触发)高效得多
},
onReceive(events) {
//%20不需要%20annotate(节省解析开销)
const parsed = Stalker.parse(events, { annotate: false, stringify: false });
for (const event of parsed) {
//%20compile%20事件格式:[blockStartAddr,%20blockEndAddr]
const start = event[0];
const end = event[1];
//%20只记录目标模块内的基本块
if (start.compare(moduleBase) >= 0 &&
start.compare(moduleBase.add(moduleSize)) < 0) {
//%20计算相对于模块基址的偏移(IDA/Ghidra%20使用偏移来匹配)
const offset = start.sub(moduleBase).toInt32();
const size = end.sub(start).toInt32();
coveredBlocks.set(offset, size);
}
}
}
});
采集器挂上后,Stalker%20每编译一个新基本块就会写一条记录到%20coveredBlocks。下一段定义\"什么时候导出\"。
第二段:导出%20drcov%20v2%20+%20RPC%20暴露
//%20coverage.js%20(2/2)%20—%20导出:拼%20drcov%20文件头%20+%20发送到%20Python%20端
//%20在合适的时机导出覆盖率数据(如分析完目标操作后手动调用)
function exportDrcov() {
Stalker.flush();
//%20生成%20drcov%20v2%20格式的文件头
let output = \"DRCOV%20VERSION:%202\
\";
output += \"DRCOV%20FLAVOR:%20frida\
\";
output += `Module%20Table:%20version%202,%20count%201\
`;
output += \"Columns:%20id,%20base,%20end,%20entry,%20checksum,%20timestamp,%20path\
\";
output += `0,%20${moduleBase},%20${moduleBase.add(moduleSize)},%200,%200,%200,%20${modulePath}\
`;
output += `BB%20Table:%20${coveredBlocks.size} bbs\
`;
//%20将基本块数据打包发送到%20Python%20端
const blocks = [];
for (const [offset, size] of coveredBlocks) {
blocks.push({ offset, size, moduleId: 0 });
}
send({
type: \"coverage\",
format: \"drcov\",
header: output,
blocks: blocks
});
}
//%20通过%20RPC%20暴露导出函数,Python%20端可以调用%20script.exports.export_coverage()
rpc.exports = {
exportCoverage: exportDrcov
};
rpc.exports%20是%20Frida%20提供的「JS%20→%20Python%20远程调用」接口。把%20JS%20函数挂到%20rpc.exports%20上,Python%20端就能%20script.exports.export_coverage()%20远程触发(注意:JS%20端驼峰命名%20exportCoverage、Python%20端会自动转下划线%20export_coverage)。本篇%20第%209.3%20节%20还会再用到。真机案例%20──%20这个%20coverage%20配方在%20QQ%20音乐%20attach%20模式上实测:hook%20
libc.so!pthread_mutex_lock%20触发后,Stalker%20收到%2028%20次%20compile%20事件,去重后%206%20个%20unique%20基本块(比%204.7:1),全部落在%20libc.so(linker64%20+%20libart.so%20已%20exclude)。样例%20block:libc.so%20+%200xbf48c(size%2012%20字节)、libc.so%20+%200xbf498(size%2020)、libc.so%20+%200xc7440(size%2016)——这些%20offset%20+%20size%20直接拼成%20drcov%20v2%20文件,丢给%20Lighthouse%20就能在%20IDA%20里高亮覆盖路径。完整脚本与输出见%20scripts/frida24/recipe/step3_coverage.js。
6.2%20函数调用链追踪
还原完整的函数调用链——像翻看手机的通话记录:谁打了谁、什么时候打的、聊了多深(嵌套层级)。特别适合分析被混淆或通过间接调用(如函数指针、虚函数表)调用的代码,这类调用在静态分析中很难追踪——但运行时所有%20BL/BLR%20指令的真实目标地址都瞒不过%20Stalker:
//%20calltrace.js%20—%20追踪完整的函数调用链(带缩进显示层级关系)
const targetModule = Process.getModuleByName(\"libtarget.so\");
const base = targetModule.base;
const end = base.add(targetModule.size);
let callDepth = 0; //%20跟踪调用深度,用于输出缩进
function startCallTrace(threadId) {
Stalker.follow(threadId, {
events: { call: true, ret: true },
onReceive(events) {
const parsed = Stalker.parse(events, {
annotate: true,
stringify: true
});
for (const event of parsed) {
const type = event[0];
const from = event[1]; //%20调用/返回的源地址
const to = event[2]; //%20调用/返回的目标地址
if (type === \"call\") {
//%20根据调用深度添加缩进,直观展示调用层级
const indent = \"%20%20\".repeat(callDepth);
const sym = DebugSymbol.fromAddress(ptr(to));
//%20有符号名就显示符号名,没有就显示偏移(如%20sub_1a2c)
const name = sym.name || `sub_${ptr(to).sub(base).toString(16)}`;
console.log(`${indent}=>%20${name} (from:%20${from})`);
callDepth++;
} else if (type === \"ret\") {
callDepth = Math.max(0, callDepth - 1);
}
}
}
});
}
//%20在目标函数被调用时启动调用链追踪
Interceptor.attach(Module.findExportByName(\"libtarget.so\", \"process_request\"), {
onEnter() {
callDepth = 0; //%20重置调用深度
startCallTrace(this.threadId);
},
onLeave() {
Stalker.unfollow(this.threadId);
Stalker.flush();
}
});
6.3%20算法分析与还原
跟踪加密/签名函数的执行,记录关键寄存器和内存变化。这是逆向分析中最有价值的应用——通过观察算术/逻辑运算指令的输入输出,可以逐步还原算法逻辑:
//%20algo_trace.js%20—%20追踪算法执行,记录寄存器和内存操作
const signFunc = Module.findExportByName(\"libtarget.so\", \"generate_sign\");
const targetModule = Process.getModuleByName(\"libtarget.so\");
Interceptor.attach(signFunc, {
onEnter(args) {
//%20记录函数输入(作为后续验证的基准数据)
this.inputPtr = args[0];
this.inputLen = args[1].toInt32();
console.log(`[*]%20generate_sign%20输入:%20${this.inputPtr.readByteArray(this.inputLen)}`);
//%20启动%20Stalker%20追踪算法内部的每一步运算
Stalker.follow(this.threadId, {
transform(iterator) {
let insn = iterator.next();
//%20判断是否在目标模块内
const inTarget = insn.address.compare(targetModule.base) >= 0 &&
insn.address.compare(targetModule.base.add(targetModule.size)) < 0;
do {
if (inTarget && iterator.memoryAccess === 'open') {
//%20关注所有算术和逻辑运算指令%20—%20这些是加密算法的核心
//%20eor=异或,%20and=与,%20orr=或,%20lsl/lsr/ror=移位/旋转
//%20add/sub=加减,%20mul=乘法,%20adc=带进位加法
const dominated = [\"eor\", \"and\", \"orr\", \"lsl\", \"lsr\", \"ror\",
\"add\", \"sub\", \"mul\", \"adc\"];
if (dominated.includes(insn.mnemonic)) {
//%20保存指令信息(闭包捕获)
const addr = insn.address;
const mnemonic = insn.mnemonic;
const opStr = insn.opStr;
iterator.putCallout((context) => {
//%20记录运算前的寄存器状态
//%20通过对比运算前后的值,可以推断每一步的计算逻辑
console.log(`[${addr}]%20${mnemonic}${opStr}`);
console.log(` x0=${context.x0} x1=${context.x1} x2=${context.x2} x3=${context.x3}`);
});
}
}
iterator.keep();
} while ((insn = iterator.next()) !== null);
}
});
},
onLeave(retval) {
Stalker.unfollow(this.threadId);
Stalker.flush();
console.log(`[*]%20generate_sign%20返回:%20${retval}`);
}
});
6.4%20反调试/反检测绕过
通过%20Stalker%20追踪反检测函数内部的执行路径,定位关键的条件判断分支,然后精确地修改寄存器值来绕过检测:
//%20anti_detect_bypass.js%20—%20追踪并绕过检测逻辑
const checkFunc = Module.findExportByName(\"libtarget.so\", \"check_integrity\");
const targetModule = Process.getModuleByName(\"libtarget.so\");
Interceptor.attach(checkFunc, {
onEnter() {
console.log(\"[*]%20检测函数被调用,启动%20Stalker%20分析执行路径...\");
Stalker.follow(this.threadId, {
transform(iterator) {
let insn = iterator.next();
const inTarget = insn.address.compare(targetModule.base) >= 0 &&
insn.address.compare(targetModule.base.add(targetModule.size)) < 0;
do {
if (inTarget) {
//%20记录所有条件跳转,分析程序在检测时走了哪个分支
const branchInsns = [\"b.eq\", \"b.ne\", \"b.gt\", \"b.lt\",
\"cbz\", \"cbnz\", \"tbz\", \"tbnz\"];
if (branchInsns.includes(insn.mnemonic)) {
const addr = insn.address;
const mnemonic = insn.mnemonic;
iterator.putCallout((context) => {
//%20NZCV%20是条件标志寄存器,决定了条件跳转的走向
console.log(`[BRANCH]%20${addr}:%20${mnemonic} —%20NZCV=${context.nzcv}`);
});
}
//%20精确绕过示例:如果发现%20CMP%20指令在比较%20frida-server%20的默认端口号
//%200x69a2%20=%2027042%20=%20frida-server%20默认监听端口(DEFAULT_LISTEN_ADDRESS%20=%20127.0.0.1:27042)
if (insn.mnemonic === \"cmp\" && insn.opStr.includes(\"#0x69a2\")) {
console.log(`[!]%20发现端口检测:%20${insn.address}`);
//%20通过%20putCallout%20修改寄存器,使比较结果不匹配
iterator.putCallout((context) => {
//%20将比较的寄存器值改为%200,使%20CMP%20结果改变
context.x0 = ptr(0);
});
}
}
iterator.keep();
} while ((insn = iterator.next()) !== null);
}
});
},
onLeave(retval) {
Stalker.unfollow(this.threadId);
Stalker.flush();
console.log(`[*]%20检测函数返回值:%20${retval}`);
}
});
6.5%20协议分析
跟踪网络数据的编码/加密流程,定位数据从明文到密文经过了哪些加密函数:
//%20protocol_trace.js%20—%20追踪数据从明文到密文的完整变换过程
const sendFunc = Module.findExportByName(\"libtarget.so\", \"send_encrypted\");
Interceptor.attach(sendFunc, {
onEnter(args) {
const dataPtr = args[0];
const dataLen = args[1].toInt32();
console.log(`[*]%20send_encrypted%20被调用,%20数据长度:%20${dataLen}`);
//%20hexdump%20以十六进制格式展示内存内容
console.log(`[*]%20输入数据:%20${hexdump(dataPtr, { length: Math.min(dataLen, 64) })}`);
Stalker.follow(this.threadId, {
events: { call: true },
onReceive(events) {
const parsed = Stalker.parse(events, { annotate: true, stringify: true });
for (const event of parsed) {
if (event[0] === \"call\") {
const target = event[2];
const sym = DebugSymbol.fromAddress(ptr(target));
if (sym.name) {
//%20通过关键词匹配识别加密相关函数调用
const cryptoKeywords = [\"aes\", \"encrypt\", \"cipher\", \"hmac\",
\"sha\", \"md5\", \"base64\", \"encode\"];
for (const kw of cryptoKeywords) {
if (sym.name.toLowerCase().includes(kw)) {
console.log(` [CRYPTO]%20${sym.name} at%20${target}`);
}
}
}
}
}
}
});
},
onLeave(retval) {
Stalker.unfollow(this.threadId);
Stalker.flush();
}
});
6.6%20Native%20层逆向
SO%20库中的关键函数定位和%20JNI%20调用链还原。对于动态注册(通过%20RegisterNatives)的%20JNI%20函数,静态分析很难找到对应关系,Stalker%20可以在运行时追踪完整的调用链:
脚本读%202%20段:①%20准备(exclude%20全部非目标模块%20+%20列%20JNI%20vtable%20索引备查);②%20自动发现%20JNI%20导出%20+%20对每个挂%20Stalker。前一段只跑一次,后一段每个%20JNI%20函数被调用都触发。
第一段:准备%20──%20exclude%20噪音%20+%20JNI%20vtable%20索引参考表
//%20native_trace.js%20(1/2)%20—%20准备阶段
const targetSo = Process.getModuleByName(\"libnative.so\");
//%20排除其他所有模块以减少噪音(激进策略)
Process.enumerateModules().forEach(mod => {
if (mod.name !== \"libnative.so\") {
Stalker.exclude(mod);
}
});
//%20预定义%20JNI%20函数表中常用函数的索引(用于识别%20JNI%20调用)
const jniEnv = Java.vm.tryGetEnv();
if (jniEnv) {
const jniFunctions = {
\"FindClass\": 6,
\"GetMethodID\": 33,
\"CallObjectMethod\": 34,
\"GetStringUTFChars\": 169,
\"NewStringUTF\": 167,
\"GetArrayLength\": 171,
\"GetByteArrayElements\": 184,
};
console.log(\"[*]%20目标%20SO%20已加载,等待%20JNI%20调用...\");
}
jniFunctions%20这张表本身是参考用——当你在%20BL/BLR%20trace%20里看到形如%20ldr%20xN,%20[x0,%20#0x118]%20然后%20blr%20xN%20这种%20JNIEnv%20vtable%20调用时,把%200x118%20/%208%20=%2035%20反查这张表就能识别出来调的是哪个%20JNI%20函数(FindClass%20/%20GetMethodID%20等),无需查%20jni.h。
第二段:自动发现%20JNI%20导出%20+%20挂%20Stalker
//%20native_trace.js%20(2/2)%20—%20对每个%20JNI%20函数挂%20Interceptor%20+%20Stalker
const exports = targetSo.enumerateExports();
for (const exp of exports) {
//%20JNI%20函数的命名规则:Java_%20开头,或%20JNI_OnLoad
if (exp.name.includes(\"Java_\") || exp.name === \"JNI_OnLoad\") {
console.log(`[*]%20发现%20JNI%20函数:%20${exp.name} @%20${exp.address}`);
Interceptor.attach(exp.address, {
onEnter(args) {
console.log(`\
[*]%20${exp.name} 被调用`);
Stalker.follow(this.threadId, {
events: { call: true, ret: true, block: true },
onReceive(events) {
const parsed = Stalker.parse(events, {
annotate: true,
stringify: true
});
for (const ev of parsed) {
if (ev[0] === \"call\") {
const target = ptr(ev[2]);
//%20判断是否是%20SO%20内部的函数调用(排除外部库调用)
if (target.compare(targetSo.base) >= 0 &&
target.compare(targetSo.base.add(targetSo.size)) < 0) {
//%20以偏移显示,方便在%20IDA/Ghidra%20中定位
const offset = target.sub(targetSo.base);
console.log(` 内部调用%20->%20sub_${offset.toString(16)}`);
}
}
}
}
});
},
onLeave(retval) {
Stalker.unfollow(this.threadId);
Stalker.flush();
}
});
}
}
七、实战案例:逆向分析某%20App%20的签名算法
读完%20第%206%20章,你手上已经有%206%20件趁手的工具(coverage%20/%20calltrace%20/%20算法分析%20/%20反调试%20/%20协议分析%20/%20Native%20逆向)。实战往往不是单选,而是把多件工具串成一条流水线。下面用一个常见场景——逆向%20App%20的请求签名算法——演示从定位到验证的完整%205%20步串法。整个过程严格遵循「先粗后细」策略。
Stalker%20实战分析六步工作流
7.1%20目标描述
本章是教学化抽象场景%20──%20下文用到的%20App%20包名%20
com.example.SecurityUtil、SO%20名%20libsecurity.so、offset%200x1234、XOR%20密钥%200x42%20都是为说明流程而虚构的占位值。真实分析时请把这些值替换为你目标%20App%20的实际值。整套流程(定位%20→%20追踪%20→%20精细%20dump%20→%20数据分析%20→%20验证)在任何带签名的真实%20App%20上都通用,跨篇真机案例可参考%20第%2022%20篇%20配套的%20scripts/frida22/。
目标%20App%20在每个%20HTTP%20请求中携带一个%20X-Sign%20头,值为%2032%20位十六进制字符串(看起来像%20MD5)。通过初步分析(抓包%20+%20Java%20层%20Hook),签名在%20Native%20层生成,关键%20SO%20为%20libsecurity.so。
7.2%20Step%201:定位签名函数
第一步先从%20Java%20层入手,通过%20Hook%20OkHttp%20的请求构建器,找到签名添加的位置,再通过堆栈回溯定位到%20Native%20入口:
//%20step1_locate.js%20—%20通过%20Java%20层%20Hook%20定位%20Native%20入口
Java.perform(() => {
//%20Hook%20OkHttp%20的%20Request.Builder.addHeader%20方法
//%20当%20App%20添加%20X-Sign%20头时会触发
const RequestBuilder = Java.use(\"okhttp3.Request$Builder\");
RequestBuilder.addHeader.implementation = function(name, value) {
if (name === \"X-Sign\") {
console.log(`[*]%20X-Sign%20=%20${value}`);
//%20打印%20Java%20层的完整调用堆栈
//%20通过堆栈可以看到是哪个类的哪个方法添加了这个%20Header
console.log(Java.use(\"android.util.Log\").getStackTraceString(
Java.use(\"java.lang.Throwable\").$new()
));
}
return this.addHeader(name, value);
};
});
通过堆栈回溯,确认签名由%20com.example.SecurityUtil.generateSign%20调用,底层是%20JNI%20函数%20Java_com_example_SecurityUtil_nativeSign。
这一步如果%20App%20用了%20动态注册(JNI\_OnLoad%20里%20
RegisterNatives%20而非%20Java_xxx%20静态前缀),直接搜符号表会落空,需要按%20第%2022%20篇%20的方法%20hook%20RegisterNatives%20拿真实%20Native%20函数地址。本篇假设是静态注册,符号能直接搜到。
7.3%20Step%202:使用%20Stalker%20追踪%20Native%20签名函数
知道了%20Native%20函数入口后,用%20Stalker%20追踪其内部的完整执行过程。脚本按时序分三段:①%20准备阶段(定位%20SO%20+%20排除无关模块);②%20transform%20主体(双钩%20BL/BLR%20调用%20+%20算术运算);③%20onLeave%20收尾(unfollow%20+%20flush%20+%20send)。读起来按%20①%20→%20②%20→%20③%20顺序,跑起来%20Stalker.follow%20一次性串好。
第一段:准备%20──%20定位%20SO%20+%20排除无关模块
//%20step2_stalker_trace.js%20(1/3)%20—%20准备阶段
const securitySo = Process.getModuleByName(\"libsecurity.so\");
const soBase = securitySo.base;
const soEnd = soBase.add(securitySo.size);
//%20排除其他所有模块,只追踪%20libsecurity.so%20内部的代码
//%20这一步是性能命门:不排除的话,JNI%20调用穿透到%20libc%20/%20libart%20全部被追,数据爆炸
Process.enumerateModules().forEach(mod => {
if (mod.name !== \"libsecurity.so\") {
Stalker.exclude(mod);
}
});
//%20找到%20nativeSign%20函数的地址(Step%201%20已确认这个%20JNI%20符号名)
const nativeSign = Module.findExportByName(\"libsecurity.so\",
\"Java_com_example_SecurityUtil_nativeSign\");
console.log(`[*]%20nativeSign%20@%20${nativeSign} (offset:%200x${nativeSign.sub(soBase).toString(16)})`);
const traceLog = []; //%20收集追踪记录,onLeave%20时批量发送给%20Python%20端
准备就绪。下一步把%20Stalker%20挂上,在%20transform%20里对每条指令做分类:函数调用%20/%20算术运算%20/%20其他。
第二段:transform%20主体%20──%20双钩%20BL/BLR%20+%20ALU
//%20step2_stalker_trace.js%20(2/3)%20—%20Stalker.follow%20+%20transform%20主循环
Interceptor.attach(nativeSign, {
onEnter(args) {
//%20JNI%20函数参数:%20JNIEnv*,%20jobject,%20[自定义参数...]
const timestamp = args[3].toInt32(); //%20jint%20timestamp
console.log(`[*]%20nativeSign%20被调用,%20timestamp=${timestamp}`);
Stalker.follow(this.threadId, {
events: { call: true, block: true },
transform(iterator) {
let insn = iterator.next();
const blockAddr = insn.address;
//%20只追踪%20SO%20内部的代码(双保险:exclude%20+%20地址范围)
const inSo = blockAddr.compare(soBase) >= 0 && blockAddr.compare(soEnd) < 0;
do {
if (inSo && iterator.memoryAccess === 'open') {
//%20===%20钩%201:%20函数调用(BL%20直接调用%20/%20BLR%20间接调用)%20===
if (insn.mnemonic === \"bl\" || insn.mnemonic === \"blr\") {
const addr = insn.address;
const mn = insn.mnemonic;
const op = insn.opStr;
iterator.putCallout((context) => {
let targetStr = \"\";
if (mn === \"blr\") {
//%20间接调用:目标地址在寄存器中(如%20blr%20x8)
const target = context[op]; //%20op%20是寄存器名
const sym = DebugSymbol.fromAddress(target);
targetStr = sym.name ? `${sym.moduleName}!${sym.name}` : `${target}`;
} else {
targetStr = op; //%20直接调用:操作数就是目标地址
}
traceLog.push({
pc: addr.sub(soBase).toString(16),
type: \"call\", target: targetStr,
x0: context.x0.toString(), x1: context.x1.toString()
});
});
}
//%20===%20钩%202:%20关键算术运算(后续算法还原用)%20===
if ([\"eor\", \"ror\", \"lsl\", \"lsr\"].includes(insn.mnemonic)) {
const addr = insn.address;
const mn = insn.mnemonic;
iterator.putCallout((context) => {
traceLog.push({
pc: addr.sub(soBase).toString(16),
type: \"alu\", op: mn,
x0: context.x0.toString(), x1: context.x1.toString()
});
});
}
}
iterator.keep();
} while ((insn = iterator.next()) !== null);
}
});
},
注意:钩%201%20用%20DebugSymbol.fromAddress%20解析%20BLR%20的间接调用目标——很多混淆%20SO%20把直接调用全改成寄存器间接调用,光看反汇编不知道跳哪去,Stalker%20运行时拿到的目标地址%20+%20符号名是关键证据。
第三段:onLeave%20收尾%20──%20unfollow%20+%20flush%20+%20send
//%20step2_stalker_trace.js%20(3/3)%20—%20收尾发送
onLeave(retval) {
Stalker.unfollow(this.threadId);
Stalker.flush(); //%20确保最后一批事件不丢失
console.log(`[*]%20nativeSign%20返回%20·%20共记录%20${traceLog.length} 条`);
send({ type: \"trace\", log: traceLog });
traceLog.length = 0; //%20清空,为下一次调用做准备
}
});
跑起来后,Python%20端会陆续收到每次%20nativeSign%20调用的完整%20call/ALU%20trace。下一节(7.4)从这堆数据里圈出关键的%20XOR%20循环,做更精细的%20dump。
7.4%20Step%203:通过%20transform%20在关键指令处记录内存
经过%20Step%202%20的分析,假设发现%20offset%200x1234%20处有一个%20XOR%20循环。现在精确追踪这个循环的每次迭代,记录%20XOR%20操作的输入和输出:
//%20step3_memory_trace.js%20—%20在关键点记录内存内容
const securitySo = Process.getModuleByName(\"libsecurity.so\");
const soBase = securitySo.base;
//%20根据%20Step%202%20分析结果确定的%20XOR%20循环地址范围
const xorLoopAddr = soBase.add(0x1234); //%20XOR%20循环起始地址
const xorLoopEnd = soBase.add(0x1280); //%20XOR%20循环结束地址
const xorRecords = [];
Interceptor.attach(Module.findExportByName(\"libsecurity.so\",
\"Java_com_example_SecurityUtil_nativeSign\"), {
onEnter(args) {
Stalker.follow(this.threadId, {
transform(iterator) {
let insn = iterator.next();
const blockAddr = insn.address;
//%20只对%20XOR%20循环所在的基本块进行精细追踪
//%20这是%20Step%202%20\"先粗\"%20->%20Step%203%20\"后细\"%20的体现
const inXorLoop = blockAddr.compare(xorLoopAddr) >= 0 &&
blockAddr.compare(xorLoopEnd) < 0;
do {
if (inXorLoop && insn.mnemonic === \"eor\" &&
iterator.memoryAccess === 'open') {
const addr = insn.address;
iterator.putCallout((context) => {
//%20记录%20XOR%20操作前的寄存器和栈状态
xorRecords.push({
pc: addr.sub(soBase).toString(16),
x0: context.x0.toString(16),
x1: context.x1.toString(16),
x2: context.x2.toString(16),
//%20读取栈上的局部变量(可能存储中间结果)
stack_0: context.sp.readPointer().toString(16),
stack_8: context.sp.add(8).readPointer().toString(16)
});
});
}
iterator.keep();
} while ((insn = iterator.next()) !== null);
}
});
},
onLeave(retval) {
Stalker.unfollow(this.threadId);
Stalker.flush();
console.log(`[*]%20XOR%20循环执行了%20${xorRecords.length} 次迭代`);
for (const rec of xorRecords) {
console.log(` [${rec.pc}]%20x0=${rec.x0} ^%20x1=${rec.x1} =>%20x2=${rec.x2}`);
}
send({ type: \"xor-trace\", records: xorRecords });
xorRecords.length = 0;
}
});
7.5%20Step%204:分析%20trace%20数据,还原算法
Python%20端收集%20Stalker%20产生的追踪数据,进行自动化分析:
#%20step4_analyze.py%20—%20分析%20Stalker%20trace%20数据
import frida
import json
import sys
trace_data = []
xor_data = []
def on_message(message, data):
\"\"\"处理%20Frida%20脚本发来的消息\"\"\"
if message[\"type\"] == \"send\":
payload = message[\"payload\"]
if payload[\"type\"] == \"trace\":
trace_data.extend(payload[\"log\"])
elif payload[\"type\"] == \"xor-trace\":
xor_data.extend(payload[\"records\"])
def analyze_trace():
\"\"\"分析追踪数据,还原算法流程\"\"\"
#%201.%20分析函数调用链
print(\"\
===%20调用链分析%20===\")
calls = [x for x in trace_data if x[\"type\"] == \"call\"]
for call in calls:
print(f\" 0x{call['pc']}:%20CALL%20{call['target']} (x0={call['x0']},%20x1={call['x1']})\")
#%202.%20统计算术/逻辑操作的频率分布
print(\"\
===%20ALU%20操作分析%20===\")
alu_ops = [x for x in trace_data if x[\"type\"] == \"alu\"]
op_counts = {}
for op in alu_ops:
op_counts[op[\"op\"]] = op_counts.get(op[\"op\"], 0) + 1
for op, count in sorted(op_counts.items(), key=lambda x: -x[1]):
print(f\"{op}:%20{count} 次\")
#%203.%20分析%20XOR%20循环,尝试提取密钥
print(\"\
===%20XOR%20循环分析%20===\")
if xor_data:
keys = set()
for rec in xor_data:
x0 = int(rec[\"x0\"], 16) & 0xFF
x1 = int(rec[\"x1\"], 16) & 0xFF
x2 = int(rec[\"x2\"], 16) & 0xFF
#%20验证%20XOR%20关系:如果%20x0%20^%20x1%20==%20x2,则%20x1%20是%20key%20字节
if x0 ^ x1 == x2:
keys.add(x1)
print(f\" 0x{rec['pc']}:%20{x0:#04x} ^%20{x1:#04x} =%20{x2:#04x}\")
print(f\"\
提取到的%20XOR%20key%20bytes:%20{[hex(k) for k in sorted(keys)]}\")
#%20连接设备并加载脚本
device = frida.get_usb_device()
pid = device.spawn([\"com.example.target\"])
session = device.attach(pid)
with open(\"step2_stalker_trace.js\") as f:
script = session.create_script(f.read())
script.on(\"message\", on_message)
script.load()
device.resume(pid)
input(\"按回车触发签名请求后分析...\
\")
analyze_trace()
7.6%20Step%205:验证还原结果
根据%20Stalker%20追踪数据推断出的算法逻辑,用%20Python%20复现并验证:
#%20step5_verify.py%20—%20用%20Python%20复现签名算法并验证
import hashlib
import struct
def generate_sign(data: bytes, timestamp: int, key: bytes) -> str:
\"\"\"
还原的签名算法(基于%20Stalker%20追踪数据推断):
1.%20输入数据与固定%20key%20进行循环%20XOR
2.%20拼接%20timestamp(小端序%204%20字节)
3.%20计算%20MD5%20哈希
\"\"\"
#%20Step%201:%20XOR%20with%20key(循环密钥异或)
xored = bytearray(len(data))
for i in range(len(data)):
xored[i] = data[i] ^ key[i % len(key)]
#%20Step%202:%20Append%20timestamp(小端序写入%204%20字节整数)
xored += struct.pack(\"<I\", timestamp)
#%20Step%203:%20MD5
return hashlib.md5(bytes(xored)).hexdigest()
#%20使用%20Frida%20抓到的实际输入/输出数据验证
test_data = b\"test_request_body\"
test_timestamp = 1679900000
#%20从%20Stalker%20XOR%20追踪中提取的密钥字节
test_key = bytes([0x5A, 0x3C, 0x7E, 0x1B, 0x9D, 0xF2, 0x48, 0xA6])
result = generate_sign(test_data, test_timestamp, test_key)
print(f\"Python%20计算结果:%20{result}\")
#%20将结果与%20Frida%20抓到的%20X-Sign%20值对比
#%20如果完全一致,说明算法还原成功!
八、Stalker%20局限与注意事项
8.1%20性能开销
指令级追踪的性能开销不可避免,下表列出了不同场景下的大致减速倍数(与第%205%20章%20performance.png 同口径):
| 场景 | 大致减速倍数 | 适用建议 |
|---|---|---|
| 全缓存命中(稳态) | 1.5-2x | 已编译块全部复用,长跑后逼近这个下限 |
| 仅 compile 事件 | 3-5x | 代码覆盖率收集(首次编译触发,天然去重) |
| call + ret 事件 | 10-15x | 调用链分析 |
| transform + putCallout(关键点) | 30-50x | 在 BL/EOR 等关键指令处插桩 |
| exec 事件(全指令开) | 80-100x | 不可用挡位,仅作为极短窗口的最后手段 |
缓解策略:
- 使用
Stalker.exclude()限制追踪范围——这是效果最显著的优化 - 在 transform 中精确过滤,只在关键位置(如特定偏移的指令)插入 callout
- 使用 CModule 将性能关键的回调用 C 语言实现(见 9.2 节)
- 控制追踪时长,不要长时间全量追踪——用 Interceptor 精确控制 follow/unfollow 的时机
8.2 Stalker 被检测的风险
性能解决了,第二个最容易踩的坑就是被 App 反检测干掉。Stalker 的动态重编译机制可能被高级的反分析保护检测到:
- 代码完整性校验:App 可能计算代码段(.text section)的 hash。虽然 Stalker 不修改原始代码,但某些更激进的检测方式可能检查代码执行路径的完整性
- 时间检测:Stalker 显著增加执行时间(典型 3-5x,稳态可降到 1.5-2x),可被精确的计时器(如
clock_gettime)检测到。某些 App 会在关键操作前后设置时间窗口 - 内存特征:Stalker 分配的代码块有特定的内存布局特征(大量可执行的匿名 mmap 区域),可通过读取
/proc/self/maps来检测
商业反检测 App(银行 / 直播 / 加固后的国民级 App)往往同时上多种检测。具体绕过手法见第 17/18 篇反调试章节。
8.3 自修改代码(SMC)
如果目标代码会在运行时修改自身(常见于加壳/VMP/代码混淆保护),需要调整信任阈值:
// 处理自修改代码
Stalker.trustThreshold = -1; // 永不信任,每次执行基本块都重新编译
// 注意:这会显著降低性能,仅在确认存在 SMC 时使用
// 或者设为 0:立即信任(忽略自修改)
// 适用于代码虽然有解密过程,但解密后不会再变化的场景
Stalker.trustThreshold = 0;
何时怀疑存在 SMC? 如果你发现 Stalker 追踪的指令与 IDA 中看到的反汇编不一致,或者 Stalker 在某些基本块崩溃,可能是代码在运行时被解密/修改了。
8.4 多线程竞态
Stalker.follow()跟踪的是特定线程,不会自动跟踪该线程创建的子线程- 多个线程同时被 follow 时,各自的 transform 回调是独立的,不会相互干扰
onReceive/onCallSummary回调是在 Frida 的 JS 线程中串行执行的,不存在并发问题
8.5 ARM Thumb/ARM 模式切换
在 32 位 ARM 上,代码可能在 ARM(32 位指令)和 Thumb(16 位指令)模式之间切换。确保传给 Stalker 的地址考虑了 Thumb 位——地址的最低位为 1 表示 Thumb 模式:
// 在 32 位 ARM 上,注意 Thumb 位
const addr = Module.findExportByName(\"libtarget.so\", \"target_func\");
// addr 可能包含 Thumb 位(最低位为 1),Stalker 会自动处理
// 不需要手动清除 Thumb 位
为什么要关注这个? 在 64 位 AArch64 上不存在 Thumb 模式,因此只有在分析 32 位 ARM 代码时需要注意。如果你的目标设备和 App 都是 64 位的,可以跳过此节。
九、进阶:与其他工具联动
Stalker 自己就能完成数据采集,但真要做大型项目逆向,3 个搭子缺一不可:IDA Lighthouse 把覆盖率可视化(看路径)、CModule 把热点回调从 JS 拉到 C(看性能)、Python 把 trace 数据自动化分析(看结论)。下面三段独立可读,但实战通常三者都会用上——大项目 = 数据采集(Stalker)+ 可视化(Lighthouse)+ 性能(CModule)+ 分析(Python),四件套一起转才转得顺。
9.1 Stalker + IDA Lighthouse(覆盖率可视化)
将 Stalker 收集的覆盖率数据导出为 drcov 格式,然后在 IDA 中通过 Lighthouse 插件加载,实现代码覆盖率的可视化着色:
# ida_coverage.py — 生成 Lighthouse 兼容的 drcov 二进制文件
import struct
def write_drcov(module_name, module_base, module_size, module_path, blocks, output_path):
\"\"\"
生成 drcov 格式的覆盖率文件
blocks: [(offset, size), ...] 列表,每个元素是一个基本块的偏移和大小
\"\"\"
with open(output_path, \"wb\") as f:
# 写入文本格式的头部
f.write(b\"DRCOV VERSION: 2\
\")
f.write(b\"DRCOV FLAVOR: frida-stalker\
\")
f.write(f\"Module Table: version 2, count 1\
\".encode())
f.write(b\"Columns: id, base, end, entry, checksum, timestamp, path\
\")
f.write(f\"0, {module_base:#x}, {module_base + module_size:#x}, 0, 0, 0, {module_path}\
\".encode())
f.write(f\"BB Table: {len(blocks)} bbs\
\".encode())
# 写入二进制格式的基本块数据
for offset, size in blocks:
# drcov BB entry 结构:uint32 start_offset, uint16 size, uint16 module_id
f.write(struct.pack(\"<IHH\", offset, size, 0))
print(f\"[*] 覆盖率文件已保存: {output_path}\")
print(f\"[*] 共 {len(blocks)} 个基本块\")
print(f\"[*] 在 IDA 中加载: File -> Load File -> Code Coverage File\")
9.2 Stalker + CModule(高性能 C 回调)
当 JavaScript 回调的性能开销无法接受时,可以使用 Frida 的 CModule 将 transform 和事件处理逻辑用 C 语言实现。C 回调直接在目标进程的上下文中执行,没有 JS ↔ Native 的跨边界调用开销:
// cmodule_stalker.js — 使用 CModule 实现高性能 Stalker 回调
const cm = new CModule(`
#include <gum/gumstalker.h>
#include <stdio.h>
// 这些变量由 JS 端设置(通过 CModule 构造函数的第二个参数)
extern guint64 module_start;
extern guint64 module_end;
extern guint64 call_count;
// C 语言版本的 transform 回调
// 直接操作 Stalker 的 C API,没有 JS 桥接开销
void
transform (GumStalkerIterator * iterator,
GumStalkerOutput * output,
gpointer user_data)
{
cs_insn * insn;
while (gum_stalker_iterator_next (iterator, &insn))
{
// 只对目标模块内的代码进行插桩
if (insn->address >= module_start && insn->address < module_end)
{
// 在 RET 指令前插入 C 回调
if (insn->id == ARM64_INS_RET)
{
gum_stalker_iterator_put_callout (iterator,
on_ret, NULL, NULL);
}
}
gum_stalker_iterator_keep (iterator);
}
}
// C 语言版本的回调函数 — 直接处理,无 JS 调用开销
static void
on_ret (GumCpuContext * cpu_context,
gpointer user_data)
{
call_count++;
// 在 C 层直接处理数据,避免频繁的 JS 回调
if (cpu_context->x[0] != 0)
{
// 只记录非零返回值(减少输出量)
printf (\"RET x0=0x%llx pc=0x%llx\\\
\",
(unsigned long long) cpu_context->x[0],
(unsigned long long) cpu_context->pc);
}
}
`, {
// 将 JS 端的变量传递给 CModule
module_start: ptr(targetModule.base),
module_end: ptr(targetModule.base.add(targetModule.size)),
call_count: Memory.alloc(8) // 分配 8 字节作为计数器
});
// 使用 CModule 导出的 transform 函数
Stalker.follow(threadId, {
transform: cm.transform,
data: ptr(0)
});
CModule 跨边界类型对照:JS 端传
ptr(...)(NativePointer),C 端extern guint64收 ── Frida 会把 NativePointer 当成 64 位无符号整数原样写入符号地址,C 代码里直接当地址用即可。Memory.alloc(N)同理传指针。详见 frida-gum 文档CModule symbol values。性能对比:使用 CModule 的 transform 回调比纯 JavaScript 版本通常快 5-10 倍,因为省去了每次 putCallout 时从 Native 调用 JS 引擎的开销。在需要对大量指令进行插桩时,CModule 是必须的。
9.3 Stalker + Python 数据分析
对大规模 trace 数据进行自动化分析。当追踪数据量达到数万甚至数十万条记录时,人工查看不现实,需要自动化分析框架:
# trace_analyzer.py — 自动化 trace 数据分析框架
import json
from collections import Counter, defaultdict
class StalkerTraceAnalyzer:
def __init__(self, trace_file):
\"\"\"从 JSONL 文件加载追踪数据\"\"\"
self.events = []
with open(trace_file) as f:
for line in f:
self.events.append(json.loads(line))
def call_frequency(self, top_n=20):
\"\"\"分析函数调用频率 — 找出最常被调用的函数(可能是循环体内的热点)\"\"\"
targets = Counter()
for ev in self.events:
if ev.get(\"type\") == \"call\":
targets[ev[\"target\"]] += 1
print(f\"\
=== Top {top_n} 调用目标 ===\")
for target, count in targets.most_common(top_n):
print(f\"{count:6d}x {target}\")
def call_sequence(self):
\"\"\"提取函数调用的时间序列 — 还原执行的先后顺序\"\"\"
calls = [ev for ev in self.events if ev.get(\"type\") == \"call\"]
print(f\"\
=== 调用序列(共 {len(calls)} 次调用)===\")
for i, call in enumerate(calls[:50]): # 只显示前 50 个
print(f\" [{i:3d}] {call['target']}\")
def detect_loops(self):
\"\"\"检测循环模式 — 通过 PC 重复出现来识别循环\"\"\"
pc_sequence = [ev[\"pc\"] for ev in self.events if \"pc\" in ev]
print(\"\
=== 热点地址(可能的循环)===\")
pc_counts = Counter(pc_sequence)
for pc, count in pc_counts.most_common(10):
if count > 5: # 执行超过 5 次的地址很可能在循环内
print(f\" 0x{pc}: {count} 次执行\")
def data_flow(self, register=\"x0\"):
\"\"\"追踪特定寄存器值的变化 — 观察数据如何在算法中流动\"\"\"
values = []
for ev in self.events:
if register in ev:
values.append((ev.get(\"pc\", \"?\"), ev[register]))
print(f\"\
=== {register} 值变化追踪 ===\")
for pc, val in values[:30]:
print(f\" [{pc}] {register} = {val}\")
# 使用示例
analyzer = StalkerTraceAnalyzer(\"stalker_trace.jsonl\")
analyzer.call_frequency() # 哪些函数被调用最多?
analyzer.call_sequence() # 调用的先后顺序是什么?
analyzer.detect_loops() # 有哪些循环?
analyzer.data_flow(\"x0\") # x0 寄存器的值如何变化?
十、总结
Stalker 是 Frida 的指令级代码追踪引擎,填补了 Interceptor(方法级 Hook)的盲区——能看到函数内部每一条指令的执行。核心能力分五类:指令追踪(exec 事件)、调用追踪(call/ret 事件)、基本块追踪(block 事件)、编译时注入(transform + putCallout)、覆盖率收集(compile 事件配合 Lighthouse 可视化)。
Stalker 使用流程总结
1. 确定目标 → 用 Interceptor/Java Hook 定位关键 Native 函数
↓
2. 配置环境 → Stalker.exclude 排除无关模块
↓
3. 初步追踪 → call/block 事件,了解整体调用结构
↓
4. 深入分析 → transform + putCallout,在关键位置记录状态
↓
5. 数据分析 → Python 端自动化分析 trace 数据
↓
6. 算法还原 → 根据 trace 数据还原逻辑,编写验证代码
核心要点
- 先粗后细:从宏观的调用统计开始,逐步深入到指令级追踪。不要一上来就开
exec: true - 精确限定范围:善用
Stalker.exclude()和地址过滤,避免信息过载和性能崩溃 - 合理选择事件:按需开启事件类型——
compile适合覆盖率,call适合调用链,exec仅作为最后手段 - transform 是核心:掌握
putCallout和指令过滤,可以实现几乎任何分析需求 - 注意性能:始终在精度和性能之间找到平衡,必要时使用 CModule 将热点逻辑用 C 实现
- 配合使用:Stalker 不是孤立使用的,与 Interceptor、Java Hook、IDA、Python 分析工具配合效果最佳
下次再遇到一段 OLLVM 混淆 / 未知签名算法,推荐工作流
- 入口定位 — 抓包看异常字段(
X-Sign/sign头),Java 层 hook 反查到 Native 函数
- JNI 静态注册 → 直接
Module.findExportByName - JNI 动态注册 → 走 第 22 篇 hook
RegisterNatives拿真实地址 -
符号被 strip → 走 第 21 篇 多策略兜底(特征码 / 字符串交叉引用 / 调用点反查)
2. 粗追踪 — Interceptor.attach + Stalker.follow +events: { call: true }+onCallSummary -
拿调用统计,看「我的 Native 函数内部都调了谁」
-
必开
Stalker.exclude把 libc / linker / frida-agent 全排除,只追目标 SO
3. 精细 dump — 用transform + putCallout在关键 mnemonic(eor / lsl / bl / blr 等)处插桩 -
把 PC / 寄存器值 / 栈值打成结构化 record,push 到数组
-
onLeave 时一次性
send()给 Python 端
4. 算法还原 — Python 端拿 trace 数据反推 -
看 call 序列 → 哪些是 libc 函数(memcpy / sha256)、哪些是自定义
看 ALU 序列 → 推算 XOR 密钥 / 偏移变换
5. 独立验证 — 用 Python 复现整套算法,跟 Frida 抓到的真实输入/输出比对 -
完全一致 → 还原成功,可用于黑盒批量签名 / fuzz / 自动化
按这条流程走,大多数「Native 层签名/加密算法」逆向都能拿下。exec 事件永远是最后手段——除非你需要看每一条指令的执行,否则 call/block 已经够用。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论