ARTICLE · 1051587
马蜂窝 App 反 Frida 检测分析与绕过实录
仅用于安全研究与学习交流。文中所有操作均在本人自有设备与账号上完成,请勿用于非法用途。

一、结论先行
这次检测的本质不是"发现 Frida 就杀进程",而是校验 native TLS 函数的函数序言(prologue)是否被 inline hook 篡改。
因此绕过方式不是硬刚检测逻辑,而是换一层 hook:放弃在 native 层 hook SSL_read/SSL_write,改为在 Java 层 hook OkHttp 的请求对象。Java 层没有做完整性校验,且拿到的还是加密前的明文——比 TLS 层更靠上、信息更全。
一句话总结:检测打的是"函数前几个字节",那我们就不去动那几个字节。
二、环境与初始现象
初始测试:用 r0capture(native 层 hook SSL_read/SSL_write)attach 主进程。
attachPress Ctrl+C to stop logging. ← 注入成功(数秒后)pidof com.mfw.roadbook ← 空,进程没了
进程消失,但 logcat 里没有任何 Java 崩溃栈,只有系统日志里轻描淡写的一行:
ActivityManager: Force stopping com.mfw.roadbook ... (process died)
到这一步有两条岔路:
是 native crash(SIGSEGV 之类)? 还是进程"主动自杀"?
答案在 tombstone 里。
三、第一步:让 tombstone 说话
adb shell ”su -c 'ls -lt /data/tombstones/ | head -3'”
最新的 tombstone 与 attach 时刻完全对应。头部信息:
Cmdline: com.mfw.roadbookpid: 9165, tid: 9490, name: api.mafengwo.cn >>> com.mfw.roadbook <<<signal 7 (SIGBUS), code -6 (SI_TKILL), fault addr --------backtrace:#01 ... boot.oat (java.net.SocketOutputStream.socketWrite+968)#04 ... conscrypt.jar (ConscryptEngineSocket$SSLOutputStream.writeToSocket+32)#10 ... conscrypt.jar (ConscryptEngineSocket.drainOutgoingQueue+24)#16 ... base.odex (okio.AsyncTimeout$sink$1.close+256)
三个关键信息:
1.code -6 (SI_TKILL)= 进程内主动tgkill,不是真内存错误。
普通 SIGBUS 是 BUS_ADRERR 之类的硬件/映射错误。SI_TKILL 意味着有线程调用了 tgkill()/pthread_kill() 主动给目标线程发信号——这是自杀,不是被杀。所以真实死因不是"崩溃",而是"进程内某处检测到异常,主动清理门户"。
2. 死的线程叫api.mafengwo.cn。
这是网络线程按目标域名命名的常见做法(OkHttp 或 app 自身的拦截器都可能设置,本例未逐一确认归属)。也就是说死的不是主线程,而是正在做网络 I/O 的工作线程——检测发生在"用"的那一刻,不是启动时扫一遍。
3. 死在 TLS 写路径上。
调用栈从 okio → Conscrypt 的 SSLOutputStream.writeInternal → SocketOutputStream.socketWrite,正好穿过我们 hook 过的 native TLS 函数。
到这里,怀疑对象已经锁定:我们 hook 的 native TLS 函数被发现了。
四、验证:单变量与对照实验
逆向排查最忌多变量同时动。这次用两个实验把范围缩死。
实验 A:单纯 attach 会不会死?(排除"frida 存在即杀")
写一个不碰 TLS 的纯 libc hook 脚本,只 hook tgkill/kill/raise 等信号原语(顺带可以抓凶手):
[”tgkill”, ”tkill”, ”kill”, ”raise”, ”abort”, ”pthread_kill”].forEach(function (fn) {var addr = Module.findExportByName(”libc.so”, fn);if (!addr) return;Interceptor.attach(addr, {onEnter: function (args) {var sig = args[2].toInt32(); // 只关心致命信号if (sig !== 7 && sig !== 6 && sig !== 9 && sig !== 31) return;send({fn: fn, sig: sig,bt: Thread.backtrace(this.context, Backtracer.ACCURATE).map(Module.getModuleByAddress ? function(a){var m = Process.findModuleByAddress(a);return m ? m.name + ”+0x” + a.sub(m.base).toString(16) : a.toString();} : String)});}});});
结果:脚本加载成功,进程存活 30 秒以上,一切正常。
结论:Frida attach 本身不被检测。 没有扫 27042 端口、没有扫 maps 里的 frida 字符串、没有扫
gmain线程名——这些常见特征在这版 app 上都没被使用。
实验 B:Java 层 hook 会不会死?(排除"任何 hook 都杀")
hook OkHttp 的 RealCall 构造函数,完全不碰 native 层:
Java.perform(function () {var RealCall = Java.use(”okhttp3.internal.connection.RealCall”);RealCall.$init.overloads.forEach(function (ov) {ov.implementation = function () {for (var i = 0; i < arguments.length; i++) {if (arguments[i].getClass().getName() === ”okhttp3.Request”) {send({ url: arguments[i].url().toString() }); // 明文 URLbreak;}}return ov.apply(this, arguments);};});});
结果:进程稳定存活,请求全部打印出来了。
两个实验拼起来,结论就唯一了:
SSL_read/write) |
差异只有一个:是否修改了 native TLS 函数的函数序言。
五、检测机制推断
Interceptor.attach 在 ARM64 上的实现方式是在函数入口写入跳转指令(trampoline),就地篡改函数前几条指令。所以校验逻辑只要读一下 SSL_read/SSL_write 的头几个字节和"预期值"比对,就能精确发现被 hook。
时序上也能对上:检测发生在网络线程实际调用 TLS 函数的那一刻(死掉的线程栈正穿过 TLS 写路径),而不是启动时统一扫一遍。
补充几个现场观察(未完全证实,供参考):
运行期加载了几个 APK 里不存在的 SO( libsoload.so、libsobridge.so、libprocreporter.so等),符合"安全 SDK 运行期下发模块"的特征;app 的 Application 是 MfwTinkerApplication——Tinker 热修复框架本身就在做 Java 层方法替换。这也侧面解释了为什么检测不敢在 Java 层乱杀:app 自己就是 hook 大户。
方法论提醒:以上是"证据指向"的推断,不是逐条反汇编验证过的定论。但对"绕过"这个目标而言,机制推断够用就行——我们不需要知道检测代码长什么样,只需要知道它盯的是什么。
六、绕过:换一层 hook
既然检测盯的是 native TLS 序言,那就根本不去碰 TLS。
为什么 Java 层是更好的选择
逃逸检测面:不修改 TLS 函数,序言完整,校验逻辑看到的"世界"是正常的; 信息更全:TLS 层拿到的是加密前的字节流,还得自己拼 HTTP;Java 层直接拿到 Request对象——URL、Method、Headers、Body 全是结构化明文;更稳定:TLS hook 受 OpenSSL/BoringSSL 版本和符号名影响,Java 层只要 app 不混淆 okhttp3就能稳定工作。
为什么 hook 构造函数
最常见的写法是 hook RealCall.execute() / enqueue() 来打印请求。但在这版 app(OkHttp 4 + Kotlin,execute 是属性)上这种写法会踩坑——直接在回调里调 this.execute() 会触发脚本报错甚至递归。
更稳的锚点是RealCall的构造函数:任何请求都必须先构造 RealCall,构造函数一进一出就是一次完整的请求采集点,且不需要在 hook 里调用被 hook 的方法(避免递归和签名不匹配):
Java.perform(function () {var RealCall = Java.use(”okhttp3.internal.connection.RealCall”);RealCall.$init.overloads.forEach(function (ov) {ov.implementation = function () {for (var i = 0; i < arguments.length; i++) {try {var a = arguments[i];if (a !== null && a.getClass && a.getClass().getName() === ”okhttp3.Request”) {var req = a;var Buffer = Java.use(”okio.Buffer”);var buf = Buffer.$new();var body = req.body();if (body !== null) body.writeTo(buf);send({method: req.method(),url: req.url().toString(),headers: req.headers().toString(),body: body !== null ? buf.readUtf8() : null});buf.close();break;}} catch (e) { /* 单个请求失败不影响其他 */ }}return ov.apply(this, arguments);};});});
实战效果:酒店搜索的 5 个接口、完整 URL、全部请求头(含签名参数)、请求体,一网打尽。
七、完整检查清单(同类目标可复用)
SI_TKILL = 进程内主动杀 | ||
八、附带发现(顺带记一下)
排查过程中还发现两个和抓包相关、但和 Frida 无关的机制,写下来对做同类工作的人也有用:
1. 启动时代理检测
挂上全局代理(settings put global http_proxy)后冷启动,进程毫秒级死亡,logcat 只有:
ActivityManager: Failure starting process com.mfw.roadbook
无 tombstone、无异常栈——典型的 app 早期初始化阶段(Application/ContentProvider)自查后 killProcess 主动退出。
绕过:先无代理启动,app 完全就绪后再设置代理,本次会话不再复查。
2. 网络栈无视系统代理
更彻底的是:即便代理就位,app 也一个包都不发到代理(9000 端口零连接)。原因是 OkHttp 层自定义了 ProxySelector(返回 NO_PROXY),系统级代理配置直接被无视。
结论:这类 app 的抓包根本不该走"系统代理 + 证书"这条路线,直接考虑 Frida hook 或 VPN 层抓包。
3. spawn 模式在特定环境下全局失效
本机 frida 的 spawn 连系统设置都会超时失败(子进程秒死,疑似 Zygisk 与 frida-server 注入冲突),与目标 app 无关。遇到这种情况别怀疑人生,直接换 attach——hook 时机的问题可以用「先启动再附加」绕过。
九、总结
这次绕过之所以成立,靠的不是某个"高级技巧",而是三个判断:
先问"怎么死的",再问"怎么绕": SI_TKILL这一个信号码就把方向从"崩溃调试"扭到了"对抗检测";把检测面当成一个可选择的参数:检测在 native 层,那就把工作搬到 Java 层——对抗的本质是换战场,不是拼消耗; 不做多余的事:不碰 TLS、不改系统代理、不用 spawn,每一步都只做"必须做的最小动作"。
从工程结果看,这套方法最终支撑了完整的协议还原:Java 层拿到明文请求与签名真值对,静态分析定位到 SO 中的签名算法,最终实现了脱离 app 的纯 Python 全链路复现(22/22 接口测试通过)。
免责声明
本文仅用于网络安全技术研究、逆向工程学习及技术交流,所涉及的技术思路、代码及案例仅供学习参考。请勿将本文内容用于未经授权的系统、服务或安全验证机制,不得用于任何违法违规或侵犯他人合法权益的行为。
请读者在合法、授权的环境下进行技术研究与实践,因不当使用本文内容所产生的任何后果由使用者自行承担。
本文仅供学习交流,请勿用于非法用途