夜雨聆风学习资料网

ARTICLE · 1051587

马蜂窝 App 反 Frida 检测分析与绕过实录

马蜂窝 App 反 Frida 检测分析与绕过实录

仅用于安全研究与学习交流。文中所有操作均在本人自有设备与账号上完成,请勿用于非法用途。

一、结论先行

这次检测的本质不是"发现 Frida 就杀进程",而是校验 native TLS 函数的函数序言(prologue)是否被 inline hook 篡改

因此绕过方式不是硬刚检测逻辑,而是换一层 hook:放弃在 native 层 hook SSL_read/SSL_write,改为在 Java 层 hook OkHttp 的请求对象。Java 层没有做完整性校验,且拿到的还是加密前的明文——比 TLS 层更靠上、信息更全。

一句话总结:检测打的是"函数前几个字节",那我们就不去动那几个字节。


二、环境与初始现象

设备
Redmi K60,Android 13 (MIUI V14.0.28.0)
Root
Magisk alpha,SELinux Permissive
Frida
frida-server 16.7.19(与 PC 端严格对齐)
目标
com.mfw.roadbook v11.5.0,无壳,11 个 dex

初始测试:用 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.roadbookpid9165, 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, {        onEnterfunction (args) {            var sig = args[2].toInt32();     // 只关心致命信号            if (sig !== 7 && sig !== 6 && sig !== 9 && sig !== 31return;            send({                fn: fn, sig: sig,                btThread.backtrace(this.contextBacktracer.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({ urlarguments[i].url().toString() });   // 明文 URL                    break;                }            }            return ov.apply(thisarguments);        };    });});

结果:进程稳定存活,请求全部打印出来了。

两个实验拼起来,结论就唯一了:

实验
动作
结果
A
attach + hook libc 信号函数
存活 ✅
B
attach + hook Java 层 OkHttp
存活 ✅
C
attach + hook native TLS (SSL_read/write)
死亡 ❌

差异只有一个:是否修改了 native TLS 函数的函数序言


五、检测机制推断

Interceptor.attach 在 ARM64 上的实现方式是在函数入口写入跳转指令(trampoline),就地篡改函数前几条指令。所以校验逻辑只要读一下 SSL_read/SSL_write 的头几个字节和"预期值"比对,就能精确发现被 hook。

时序上也能对上:检测发生在网络线程实际调用 TLS 函数的那一刻(死掉的线程栈正穿过 TLS 写路径),而不是启动时统一扫一遍。

补充几个现场观察(未完全证实,供参考):

  • 运行期加载了几个 APK 里不存在的 SO(libsoload.solibsobridge.solibprocreporter.so 等),符合"安全 SDK 运行期下发模块"的特征;
  • app 的 Application 是 MfwTinkerApplication——Tinker 热修复框架本身就在做 Java 层方法替换。这也侧面解释了为什么检测不敢在 Java 层乱杀:app 自己就是 hook 大户。

方法论提醒:以上是"证据指向"的推断,不是逐条反汇编验证过的定论。但对"绕过"这个目标而言,机制推断够用就行——我们不需要知道检测代码长什么样,只需要知道它盯的是什么。


六、绕过:换一层 hook

既然检测盯的是 native TLS 序言,那就根本不去碰 TLS

为什么 Java 层是更好的选择

  1. 逃逸检测面:不修改 TLS 函数,序言完整,校验逻辑看到的"世界"是正常的;
  2. 信息更全:TLS 层拿到的是加密前的字节流,还得自己拼 HTTP;Java 层直接拿到 Request 对象——URL、Method、Headers、Body 全是结构化明文;
  3. 更稳定: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(thisarguments);        };    });});

实战效果:酒店搜索的 5 个接口、完整 URL、全部请求头(含签名参数)、请求体,一网打尽。


七、完整检查清单(同类目标可复用)

步骤
做法
目的
1
先看 tombstone,不猜
区分"崩溃"vs"自杀":SI_TKILL = 进程内主动杀
2
看死掉的线程名
OkHttp 网络线程名 = 域名,能立刻定位到网络路径
3
单变量实验:attach 不 hook 先试
排除"frida 存在即杀"的简单检测
4
对照实验:native 层 vs Java 层
锁定检测的具体层面
5
优先在最上层 hook
检测成本高的层面(Java/ART)通常没人做校验
6
frida-server 改名 + 非默认端口
防御低成本的进程名/端口扫描
7
用 attach,别用 spawn(本机 spawn 环境异常时)
spawn 会在启动早期暴露,且本机 Zygisk 冲突

八、附带发现(顺带记一下)

排查过程中还发现两个和抓包相关、但和 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 时机的问题可以用「先启动再附加」绕过。


九、总结

这次绕过之所以成立,靠的不是某个"高级技巧",而是三个判断:

  1. 先问"怎么死的",再问"怎么绕"SI_TKILL 这一个信号码就把方向从"崩溃调试"扭到了"对抗检测";
  2. 把检测面当成一个可选择的参数:检测在 native 层,那就把工作搬到 Java 层——对抗的本质是换战场,不是拼消耗
  3. 不做多余的事:不碰 TLS、不改系统代理、不用 spawn,每一步都只做"必须做的最小动作"。

从工程结果看,这套方法最终支撑了完整的协议还原:Java 层拿到明文请求与签名真值对,静态分析定位到 SO 中的签名算法,最终实现了脱离 app 的纯 Python 全链路复现(22/22 接口测试通过)。


免责声明

本文仅用于网络安全技术研究、逆向工程学习及技术交流,所涉及的技术思路、代码及案例仅供学习参考。请勿将本文内容用于未经授权的系统、服务或安全验证机制,不得用于任何违法违规或侵犯他人合法权益的行为。

请读者在合法、授权的环境下进行技术研究与实践,因不当使用本文内容所产生的任何后果由使用者自行承担。

本文仅供学习交流,请勿用于非法用途

相关学习资料