20、Hook框架进阶:Frida Stalker(指令追踪)、Frida脚本编写(拦截、修改参数、返回值)
好,咱们继续往下走。前面几章我们把 Frida 的基础 Hook 玩得挺熟了,拦截函数、改参数、改返回值,这些基本操作你应该已经能上手了。但说实话,这些只是 Frida 的冰山一角。今天我要聊的,是 Frida 里一个真正让我觉得「卧槽,还能这样玩」的功能——Stalker。
Stalker 是什么?说白了,它是一个指令级别的追踪器。你可以把它理解成在 CPU 执行指令的路上装了一排摄像头,每一条指令跑过去,它都能给你录下来。我当年第一次用 Stalker 去分析一个混淆过的 native 函数时,那种感觉就像在黑屋子里突然开了灯——一切都清清楚楚。
20.1 Stalker 核心概念
先别急着写代码,咱们得先搞清楚 Stalker 到底在干什么。
Frida 的 Stalker 可以让你实时追踪一个线程的执行流。它能捕获到每一条 CPU 指令,包括跳转、调用、返回。你想想看,这意味着什么?意味着你可以看到程序到底走了哪条分支,调了哪些函数,甚至能知道它有没有在偷偷执行一些你没见过的代码。
我在项目中遇到过一件事:一个 App 的 native 层做了很重的混淆,IDA 里看过去全是花指令,根本没法静态分析。我当时就是用 Stalker 把关键函数的执行流 dump 出来,一条指令一条指令地看,最后找到了它的核心算法。嗯,那感觉,挺爽的。
- Stalker 工作在指令级别,不是函数级别
- 它可以追踪单个线程的执行流
- 支持指令的实时修改和替换
- 性能开销较大,建议精准使用
20.2 Stalker 基础用法
咱们直接上代码。下面这个例子,演示了如何用 Stalker 追踪一个 native 函数的执行过程。
// 附加到目标进程
var threadId = Process.getCurrentThreadId();
// 开始追踪当前线程
Stalker.follow(threadId, {
events: {
call: true, // 追踪函数调用
ret: true, // 追踪函数返回
exec: false // 不追踪每条指令(太慢了)
},
onReceive: function(events) {
// 处理接收到的事件
console.log('收到事件数量: ' + events.length);
Stalker.parse(events, {
onCall: function(args) {
console.log('调用: ' + args[0]);
},
onRet: function(args) {
console.log('返回: ' + args[0]);
}
});
}
});
这段代码看起来简单,但实际跑起来你会发现,输出量非常大。所以我个人习惯是先缩小追踪范围,比如只追踪某个特定函数内部的指令,而不是整个线程。
20.3 Stalker 指令级追踪
如果你需要更细粒度的控制,可以开启指令级别的追踪。但我要提醒你,这玩意开销很大,别随便对整个 App 开。
Stalker.follow(threadId, {
events: {
call: false,
ret: false,
exec: true // 追踪每条指令
},
onReceive: function(events) {
Stalker.parse(events, {
onExec: function(address) {
var instruction = Instruction.parse(address);
console.log(instruction.address + ': ' + instruction.mnemonic + ' ' + instruction.opStr);
}
});
}
});
你看,这样就能拿到每一条执行的指令了。我曾经用这个功能去分析一个反调试的代码,发现它在执行过程中会随机插入一些无意义的指令来混淆执行流。嗯,Stalker 一开,这些花招全暴露了。
20.4 Stalker 代码修改
Stalker 不仅能看,还能改。你可以在指令执行前替换它,这比 Interceptor 更底层、更强大。
Stalker.follow(threadId, {
transform: function(iterator) {
var instruction = iterator.next();
// 如果遇到某个特定指令,替换它
if (instruction.mnemonic === 'bl' && instruction.opStr.indexOf('0x1234') !== -1) {
// 替换成 nop
iterator.putNop();
} else {
iterator.keep();
}
}
});
这个 transform 回调会在每条指令执行前被调用。你可以决定是保留原指令、替换成别的指令、还是直接跳过。说实话,这个功能我平时用得不多,因为太底层了,容易把程序搞崩。但如果你在做一些极致的逆向,比如对抗代码虚拟化,这个就是神器。
20.5 Frida 脚本进阶:拦截与修改
好,Stalker 聊完了,咱们回到 Frida 脚本本身。前面几章我们讲过基础的拦截,今天再深入一点,聊聊如何优雅地修改参数和返回值。
20.5.1 修改参数
修改参数是逆向中最常见的需求之一。比如你想绕过某个校验,直接把参数改成期望的值。
Interceptor.attach(Module.findExportByName('libc.so', 'strcmp'), {
onEnter: function(args) {
var str1 = args[0].readCString();
var str2 = args[1].readCString();
console.log('strcmp(' + str1 + ', ' + str2 + ')');
// 如果比较的是某个特定字符串,直接修改参数
if (str1.indexOf('password') !== -1) {
// 把第二个参数改成和第一个一样,让比较结果返回0
args[1] = args[0];
console.log('已修改参数,绕过密码校验');
}
},
onLeave: function(retval) {
console.log('strcmp 返回值: ' + retval);
}
});
你看,这样就能让 strcmp 永远返回 0,相当于绕过了密码校验。但要注意,修改参数要谨慎,有些函数内部会缓存参数的值,你改了可能没用。
20.5.2 修改返回值
修改返回值更直接。比如你想让一个检查函数永远返回成功。
Interceptor.attach(Module.findExportByName('libnative.so', 'check_license'), {
onLeave: function(retval) {
console.log('原始返回值: ' + retval);
// 把返回值改成 1(成功)
retval.replace(1);
console.log('已修改返回值,强制返回成功');
}
});
这里有个坑:返回值类型不同,修改方式也不同。对于整数类型,直接用 retval.replace() 就行。但对于指针类型,你需要用 retval.replace(ptr(0x1234)) 这种方式。我刚开始学的时候,在这个坑里摔了好几次。
| 返回值类型 | 修改方式 | 示例 |
|---|---|---|
| 整数 (int) | retval.replace(新值) | retval.replace(1) |
| 指针 (pointer) | retval.replace(ptr(新地址)) | retval.replace(ptr(0x1234)) |
| 浮点数 (float) | retval.replace(新值) | retval.replace(3.14) |
| 布尔值 (bool) | retval.replace(新值) | retval.replace(true) |
20.6 实战案例:破解一个简单的 License 校验
咱们来一个完整的例子。假设有一个 App,它的 native 层有一个 check_license 函数,返回 0 表示未授权,返回 1 表示已授权。我们要做的就是让它永远返回 1。
// 1. 找到目标函数
var checkLicense = Module.findExportByName('libnative.so', 'check_license');
if (checkLicense) {
console.log('找到 check_license 地址: ' + checkLicense);
// 2. 拦截函数
Interceptor.attach(checkLicense, {
onEnter: function(args) {
console.log('check_license 被调用');
// 可以在这里打印参数,看看传了什么
console.log('参数: ' + args[0] + ', ' + args[1]);
},
onLeave: function(retval) {
console.log('原始返回值: ' + retval);
// 3. 修改返回值
retval.replace(1);
console.log('已修改返回值,授权通过');
}
});
// 4. 用 Stalker 追踪函数内部的执行流
Stalker.follow(Process.getCurrentThreadId(), {
events: {
call: true,
ret: true,
exec: false
},
onReceive: function(events) {
Stalker.parse(events, {
onCall: function(args) {
console.log('内部调用: ' + args[0]);
}
});
}
});
} else {
console.log('未找到 check_license 函数');
}
这个脚本做了两件事:一是用 Interceptor 拦截函数并修改返回值,二是用 Stalker 追踪函数内部的调用链。这样你既能破解授权,又能看到它内部调了哪些函数,一举两得。
20.7 避坑指南
最后,分享几个我踩过的坑,希望能帮你省点时间。
- 忘记解除 Stalker 追踪:Stalker 一旦开启,会持续追踪直到你调用 Stalker.unfollow()。我有一回忘了解除,结果手机越来越烫,最后直接重启了。所以,记得在函数返回时调用 Stalker.unfollow()。
- 在 onLeave 里修改了错误的返回值类型:前面说过,不同类型要用不同的修改方式。我刚开始时直接用 retval.replace('1'),结果程序直接崩了。后来才发现,字符串和整数在底层是完全不同的表示方式。
- 对系统函数使用 Stalker:Stalker 对系统函数的追踪要格外小心。系统函数调用频繁,开启 Stalker 后性能开销极大,而且容易触发反调试。我建议只对你自己感兴趣的 native 函数使用 Stalker。
20.8 本章小结
Stalker 是 Frida 里一个非常强大的功能,它让你能看到代码执行的每一个细节。配合 Interceptor 的参数修改和返回值修改,你几乎可以对任何 native 函数进行全方位的控制和监控。
但记住,能力越大,责任越大。Stalker 用不好,轻则程序崩溃,重则手机死机。所以,我的建议是:先用小函数练手,等熟练了再上复杂的场景。
嗯,今天就到这里。下一章我们会聊聊 Frida 的另一个高级功能——内存搜索和修改。到时候见。
公众号:蓝海资料掘金营,微信deep3321