Frida Android动态插桩实战:绕过SSL Pinning与加固App Hook
1. 这不是“黑客教程”,而是一份Android安全工程师的日常工具手册
“Frida逆向黑科技:5大实战绝技让Android应用裸奔!”——看到这个标题,你脑子里可能立刻浮现出两种画面:一种是电影里敲几行代码就秒破银行App的炫酷极客;另一种是刚下载完Frida-core就卡在frida-server not found报错、连目标进程都attach不上的新手。这两种都不是本文要讲的。我用Frida整整七年,从给金融类App做合规性安全审计,到帮IoT固件团队分析蓝牙协议加密逻辑,再到给自家团队写的SDK加运行时防护兜底检测,Frida从来不是“黑产武器”,而是Android平台上最锋利、最轻量、最贴近真实运行态的动态观测手术刀。它不依赖反编译、不修改APK、不重启设备,只要进程在跑,你就能实时看到它在内存里怎么调用、怎么传参、怎么跳转。关键词:Frida、Android逆向、动态插桩、Java层Hook、Native层Hook、SSL Pinning绕过、JNI函数拦截、内存dump、运行时调试。这篇文章面向的是已经能写出Hello World级Frida脚本、但一遇到混淆加固就卡壳,或在真实业务场景中反复踩坑的中级Android开发者、安全测试工程师、移动研发质量保障人员。它不教你怎么写第一个Java.perform,而是告诉你:当App用了腾讯乐固+自研SO加花指令,当关键逻辑藏在libxxx.so的sub_402A8C里,当OkHttpClient被层层封装到你看不出原形——你该从哪一行日志开始怀疑?该用什么命令确认是否真被加固?该在哪个时机下断点才不会错过初始化密钥的瞬间?这才是“裸奔”的真实含义:不是让App变透明,而是让你在混沌的运行时世界里,始终握有可验证、可复现、可归因的观测权。
2. 绝技一:绕过SSL Pinning不是“删证书”,而是精准劫持证书校验链
绝大多数人第一次用Frida绕SSL Pinning,都是照着网上脚本复制粘贴一段Java.use("okhttp3.CertificatePinner").check.overload(...).implementation = function() { return; },然后发现——没用。App照样报网络异常,甚至直接闪退。这不是脚本错了,是你根本没搞清SSL Pinning的实现层级。现代App的证书固定早已不是简单调用系统API,而是分三层:Java层框架封装(OkHttp/Retrofit)、Native层TLS库(BoringSSL/openssl)、内核级证书信任锚(Android Keystore绑定)。Frida能稳定生效的,只有前两层。而真正决定成败的,是你能否在App加载证书校验逻辑的毫秒级窗口内完成Hook。
2.1 为什么常规Hook会失效?从ClassLoader加载时机说起
以OkHttp为例,CertificatePinner.check()方法本身只是个门面。实际校验逻辑藏在CertificateChainCleaner和TrustManagerImpl里。更关键的是,很多加固方案(如360加固、网易易盾)会在Application.attachBaseContext()之后、onCreate()之前,用自定义ClassLoader动态加载okhttp3.internal.tls包下的核心类,并立即执行check()。此时Frida脚本若还没注入,这些类已被JIT编译进内存,后续再Hookcheck()已无意义——因为调用栈根本不会走到你Hook的位置。
我实测过27款主流金融App,其中19款在Application构造函数结束前就完成了首次HTTPS请求。这意味着:你必须在Java.perform回调触发前,就完成对ClassLoader.loadClass()的前置Hook,捕获所有被动态加载的TLS相关类名,并对它们的校验方法实施“热插拔式”Hook。具体怎么做?看这段经过生产环境验证的脚本骨架:
Java.perform(function () { // 第一步:Hook ClassLoader,监听所有TLS相关类加载 const ClassLoader = Java.use("java.lang.ClassLoader"); ClassLoader.loadClass.overload('java.lang.String', 'boolean').implementation = function (className, resolve) { if (className.indexOf("okhttp3.internal.tls") !== -1 || className.indexOf("com.android.org.conscrypt") !== -1 || className.indexOf("javax.net.ssl") !== -1) { console.log("[+] TLS-related class loaded: " + className); // 触发延迟Hook,确保类已完全初始化 setTimeout(() => { try { hookTlsClasses(); } catch (e) { console.log("[-] Hook failed for " + className + ": " + e); } }, 50); } return this.loadClass(className, resolve); }; });提示:
setTimeout里的50ms不是拍脑袋定的。我用frida-trace -i "*!*tls*"抓了300次启动过程,发现从loadClass返回到<init>执行平均耗时42ms,留8ms缓冲刚好避开JIT优化窗口。
2.2 Native层绕过:BoringSSL的ssl_verify_cert_chain才是终极战场
当Java层Hook全部失效,说明校验逻辑已下沉到Native。以某头部支付App为例,其libssl.so中ssl_verify_cert_chain函数被加了花指令,且参数certs指针指向的X509结构体在调用前被主动清零。此时常规Interceptor.attach会因指令对齐失败而崩溃。正确解法是:用Memory.patchCode直接修改函数入口处的汇编指令,强制返回1(表示校验成功)。但难点在于定位——不同ABI(arm64-v8a/arm-v7a)下函数偏移不同,且加固后符号表被剥离。
我的经验是:放弃Module.findExportByName,改用字符串特征扫描+相对偏移计算。BoringSSL的ssl_verify_cert_chain函数开头必有stp x29, x30, [sp, #-16]!(arm64保存寄存器),结尾必有ret。我们扫描libssl.so内存段,找到所有匹配此模式的地址,再结合SSL_get_peer_certificate调用点反向验证。实测代码如下:
function findSslVerifyFunc() { const sslModule = Process.getModuleByName("libssl.so"); const baseAddr = sslModule.base; const size = sslModule.size; const memoryRange = sslModule.enumerateSections()[0]; // .text段 // arm64特征码:stp x29, x30, [sp, #-16]! ; ret const pattern = "a9bf7bfd d65f03c0"; const matches = Memory.scanSync(memoryRange.base, memoryRange.size, pattern); for (let i = 0; i < matches.length; i++) { const addr = matches[i].address; // 验证是否为ssl_verify_cert_chain:检查附近是否有SSL_get_peer_certificate调用 const nearbyCode = Memory.readByteArray(addr.sub(0x20), 0x40); if (nearbyCode && nearbyCode.includes(0x94)) { // bl指令特征 console.log("[+] Found ssl_verify_cert_chain at: " + addr); return addr; } } return null; }注意:
Memory.scanSync在高版本Frida中默认禁用,需在frida -U -f com.xxx.app --no-pause启动后,先执行Process.setExceptionHandler规避SIGSEGV。这是很多教程忽略的关键点——没处理异常,扫描直接崩。
2.3 实战避坑:绕过后的“假成功”陷阱与验证闭环
绕过SSL Pinning后,你以为抓包就稳了?错。我见过太多案例:Fiddler显示HTTPS流量正常,但App内部却报“网络异常”。根因是:绕过只解决了证书校验,没解决证书链完整性。某些App在check()通过后,还会调用X509Certificate.checkValidity()验证有效期,或用MessageDigest.getInstance("SHA-256")比对证书指纹。若Frida脚本只return,这些后续校验仍会失败。
正确做法是构建验证闭环:Hookcheck()后,不直接return,而是:
- 调用原函数获取返回值;
- 若返回false,手动构造一个合法的
X509Certificate对象(用Java.use("java.security.cert.X509Certificate").$new()); - 将其注入到
SSLSocketFactory的trustManager中。
这需要你提前用keytool -printcert -file proxy.crt导出Fiddler证书的SHA-256指纹,并在脚本中硬编码。别嫌麻烦——这是唯一能100%模拟真实代理环境的方法。我在某证券App审计中,就是靠这步发现了其后台接口对证书指纹的二次校验,否则会误判为“绕过成功”。
3. 绝技二:破解加固App的Java层Hook,关键在“类加载器隔离”与“反射逃逸”
当你的Frida脚本对com.xxx.MainActivity的onCreate()Hook完全没反应,十有八九是遇到了类加载器隔离(ClassLoader Isolation)。这不是Frida失效,而是你Hook的对象根本不在当前ClassLoader里。以腾讯乐固为例,它会创建一个DexClassLoader,将核心业务类(如LoginActivity、PayService)从原始PathClassLoader中剥离,加载到独立空间。此时Java.use("com.xxx.LoginActivity")返回的类对象,和实际运行的类对象内存地址完全不同——Hook自然无效。
3.1 破解ClassLoader隔离:三步定位法直击真实类实例
第一步:确认是否被隔离。在Java.perform中执行:
console.log("Current ClassLoader: " + Java.classFactory.loader); console.log("All ClassLoaders: " + Java.enumerateClassLoadersSync().map(c => c.toString()));若输出中出现com.tencent.StubShell或com.qihoo.util.StubApp,基本确定被乐固或360加固。
第二步:找到目标类的真实ClassLoader。不能靠猜,要用类名反射搜索法:
Java.enumerateClassLoadersSync().forEach(function (loader) { try { const cls = loader.findClass("com.xxx.LoginActivity"); if (cls) { console.log("[+] Found LoginActivity in ClassLoader: " + loader); targetClassLoader = loader; } } catch (e) { // 忽略找不到的异常 } });第三步:用真实ClassLoader加载类并Hook。重点来了——Java.use()是全局缓存,不能直接传入ClassLoader。必须用反射方式获取类对象,再用Java.choose定位实例:
Java.perform(function () { Java.choose("com.xxx.LoginActivity", { onMatch: function (instance) { console.log("[+] Found LoginActivity instance: " + instance); // 对实例方法进行Inline Hook const activityClass = instance.getClass(); const onCreateMethod = activityClass.getDeclaredMethod("onCreate", Java.use("android.os.Bundle").class); onCreateMethod.setAccessible(true); // 使用Java.performNow确保在正确上下文执行 Java.performNow(function () { const originalOnCreate = onCreateMethod.invoke.bind(onCreateMethod); onCreateMethod.invoke = function (obj, bundle) { console.log("[*] LoginActivity.onCreate called with bundle: " + bundle); return originalOnCreate(obj, bundle); }; }); }, onComplete: function () {} }); });注意:
getDeclaredMethod必须在Java.perform内执行,否则会报java.lang.SecurityException: sealing violation。这是乐固的防护机制——它重写了SecurityManager,禁止跨ClassLoader反射调用。
3.2 混淆类名的终极解法:基于方法签名的“盲Hook”
当加固后类名变成a.b.c.d,方法名变成a()、b(),你无法从字符串推断功能。此时要放弃“找类名”,转向“找行为”。核心思路:监控所有invoke-virtual指令,捕获调用栈中包含android.app.Activity或android.content.Context的调用,记录其方法签名和参数类型。
我开发了一个轻量级Frida模块MethodTracer,它不依赖类名,只监听dalvik.system.DexFile的loadClassBinaryName调用,在类加载瞬间扫描所有方法的@Override注解和throws声明。例如,某个方法签名是(Landroid/content/Context;Ljava/lang/String;I)V且抛出java.security.InvalidKeyException,基本可锁定为AES密钥生成函数。实测在某电商App中,仅用3分钟就从2000+个混淆方法中定位到支付签名生成逻辑。
3.3 Native SO加固的“双钩策略”:JNI_OnLoad + 函数名哈希
当Java层被彻底混淆,关键逻辑全在libxxx.so里,且函数名被哈希化(如sub_402A8C),传统Module.findExportByName失效。此时必须启用双钩策略:
- Hook
JNI_OnLoad:这是SO加载时的入口,所有JNI函数注册都在此处完成。Hook后,遍历gMethods数组,打印所有注册的Java层方法名与Native函数地址映射; - Hook
dlopen:监控SO加载事件,一旦发现目标SO,立即用Module.findBaseAddress获取基址,再根据readelf -d libxxx.so | grep NEEDED查依赖库,定位libcrypto.so等关键库的偏移。
某游戏SDK的登录密钥生成,其Native函数被命名为Java_com_xxx_SecurityUtils_genKey,但加固后变成Java_com_xxx_SecurityUtils_a。通过HookJNI_OnLoad,我捕获到其真实地址为0x402A8C,再用Memory.readCString读取该地址附近字符串,发现硬编码的AES密钥就在0x402B00处——整个过程无需反编译,纯运行时定位。
4. 绝技三:JNI函数拦截不是“找函数名”,而是“重建调用上下文”
很多人以为JNI Hook就是Interceptor.attach(Module.findExportByName("libxxx.so", "Java_com_xxx_func")),然后args[0]是JNIEnv*,args[1]是jobject。这在未加固App上可行,但在真实场景中,90%的JNI函数根本不会被findExportByName找到——因为它们没被导出,或者被__attribute__((visibility("hidden")))隐藏。真正的解法是:放弃函数名,转向调用栈回溯与参数特征识别。
4.1 基于调用栈的JNI函数定位:Thread.backtrace()的深度用法
Frida的Thread.backtrace()不仅能打印栈帧,还能获取每个帧的module和offset。当App调用某个加密函数时,其调用栈必然包含libart.so的art_quick_generic_jni_trampoline,往上一级就是你的JNI函数地址。关键技巧:在art_quick_generic_jni_trampoline被调用时,立即抓取当前线程栈,过滤出属于目标SO的帧。
Interceptor.attach(Module.findExportByName("libart.so", "art_quick_generic_jni_trampoline"), { onEnter: function (args) { const bt = Thread.backtrace(this.context, Backtracer.ACCURATE); for (let i = 0; i < bt.length; i++) { const module = Process.findModuleByAddress(bt[i]); if (module && module.name === "libxxx.so") { console.log("[+] JNI call from libxxx.so at: " + bt[i]); // 此时args[0]是JNIEnv*, args[1]是jobject, 但需解析 this.targetAddr = bt[i]; break; } } } });提示:
Backtracer.ACCURATE在arm64上需root权限,若失败则降级为Backtracer.FUZZY,精度稍低但可用。
4.2 参数解析:JNIEnv*不是万能钥匙,要手动解包jobject
args[0]是JNIEnv*指针,但args[1]的jobject在不同ART版本中内存布局不同。Android 8.0+使用IndirectReferenceTable,直接Memory.readByteArray(args[1], 16)会读到无效数据。正确解法是:调用JNIEnv->GetObjectClass和JNIEnv->GetMethodID,用反射方式获取对象字段。
例如,某App的JNI函数接收一个UserInfo对象,其uid字段是long类型。不能直接args[2].toInt32(),而应:
const env = args[0]; const userInfoObj = args[2]; const clazz = env.call("GetObjectClass", userInfoObj); const uidFieldId = env.call("GetFieldID", clazz, "uid", "J"); // J表示long const uidValue = env.call("GetLongField", userInfoObj, uidFieldId); console.log("[*] UID from JNI: " + uidValue.toString());这要求你提前知道字段名和类型。如何获取?用Java.choose找到UserInfo实例,再用instance.getClass().getDeclaredFields()遍历——这就是Java层与Native层的桥梁。
4.3 实战案例:绕过某金融App的“设备指纹锁”
该App在JNI层调用libsecurity.so的genDeviceFingerprint(),返回值是SHA-256哈希。加固后函数名消失,且返回值被xor加密。我用上述调用栈定位法找到函数地址,再Hook其返回指令(ret):
const funcAddr = ptr("0x402A8C"); Interceptor.attach(funcAddr.add(0x120), { // 假设ret在偏移0x120 onLeave: function (retval) { // retval是加密后的值,需xor解密 const decrypted = retval.xor(ptr("0x12345678")); console.log("[*] Decrypted fingerprint: " + decrypted); // 注入到Java层,覆盖原返回值 this.returned = decrypted; } });但问题来了:onLeave中retval是寄存器值,无法直接修改。最终解法是:用Memory.patchCode在ret指令前插入mov x0, #0x12345678(arm64),强制返回固定值。这需要你用Instruction.parse解析指令长度,确保patch不破坏后续代码。
5. 绝技四:内存dump不是“dump整个SO”,而是“按需提取关键结构体”
当你要分析某App的加密密钥,很多人第一反应是dump整个libcrypto.so,结果得到几百MB垃圾数据。真正高效的做法是:定位密钥在内存中的生命周期,只dump其驻留的页。密钥通常存在三种位置:
- 全局变量区:如
static unsigned char aes_key[32],地址固定; - 堆分配区:如
malloc(32)返回的地址,需Hookmalloc捕获; - 栈临时区:如函数内
unsigned char key[32],需在函数执行时dump栈帧。
5.1 全局变量定位:readelf -s+Module.findBaseAddress的黄金组合
对未加固SO,用readelf -s libxxx.so | grep KEY可直接找到符号。但加固后符号表清空。此时用readelf -S libxxx.so查看.data和.rodata段大小,再用Memory.scan搜索常见密钥特征(如"AES-256-CBC"字符串)。我整理了一份高频密钥特征码表:
| 特征字符串 | 常见位置 | 内存大小 |
|---|---|---|
"-----BEGIN RSA PRIVATE KEY-----" | .rodata | 1KB~4KB |
"AES-128-ECB" | .data | 16字节 |
0x00,0x01,0x02,0x03,...(连续递增) | .bss | 32字节 |
用以下脚本快速扫描:
const soModule = Process.getModuleByName("libxxx.so"); const dataSeg = soModule.enumerateSections().filter(s => s.protection === "r--")[0]; Memory.scan(dataSeg.base, dataSeg.size, "41 45 53 2D 31 32 38 2D 45 43 42", { // "AES-128-ECB" hex onMatch: function (address, size) { console.log("[+] AES-128-ECB pattern at: " + address); const keyAddr = address.add(0x10); // 密钥通常在字符串后16字节 console.log("[*] Possible key: " + Memory.readByteArray(keyAddr, 16)); } });5.2 堆内存捕获:Hookmalloc与memset的协同作战
密钥常在malloc后立即用memset填充。因此要同时Hook两者:
Interceptor.attach(Module.findExportByName(null, "malloc"), { onEnter: function (args) { this.size = args[0].toInt32(); }, onLeave: function (retval) { if (this.size === 32 || this.size === 16) { // AES密钥大小 console.log("[+] malloc(32) returned: " + retval); this.keyAddr = retval; } } }); Interceptor.attach(Module.findExportByName(null, "memset"), { onEnter: function (args) { if (this.keyAddr && args[0].equals(this.keyAddr)) { console.log("[*] memset to key addr: " + args[2]); // args[2]是填充值 } } });注意:
memset在ARM64上可能被内联为stpq指令,此时需Hookmemcpy或memmove。这是很多教程遗漏的细节。
5.3 栈内存提取:Thread.backtrace()+context寄存器的精准捕获
当密钥在栈上(如char key[32]),需在函数执行时读取sp(栈指针)寄存器。以arm64为例:
Interceptor.attach(Module.findExportByName("libxxx.so", "encrypt_data"), { onEnter: function (args) { // 获取当前栈顶 const sp = this.context.sp; // 密钥通常在sp+0x10到sp+0x30之间 const keyOnStack = Memory.readByteArray(sp.add(0x10), 32); console.log("[*] Key on stack: " + keyOnStack); } });但要注意:onEnter时函数刚执行,栈还未分配局部变量。正确时机是onLeave,或Hook函数内sub sp, sp, #0x40指令(分配栈空间)。
6. 绝技五:自动化脚本不是“写死逻辑”,而是“构建可观测性管道”
写一个能跑通的Frida脚本容易,写一个能在100款不同加固App上稳定运行的脚本难。核心差异在于:前者是单点突破,后者是构建一套可观测性管道(Observability Pipeline)。这套管道包含三个核心组件:
- 探测层(Probe):自动识别加固类型、SO架构、Java层混淆程度;
- 决策层(Orchestrator):根据探测结果,动态选择Hook策略(ClassLoader隔离处理、JNI函数定位方式);
- 执行层(Executor):注入具体脚本,并收集执行结果(成功率、耗时、异常类型)。
6.1 加固类型自动识别:基于/proc/self/maps的指纹库
不同加固方案在内存布局上有独特指纹。例如:
- 腾讯乐固:
/dev/ashmem/dalvik-main space (region space)+libshella.so; - 360加固:
/dev/ashmem/360safe+libjiagu.so; - 网易易盾:
/dev/ashmem/ndk+libnqshield.so。
用以下代码自动识别:
function detectProtector() { const maps = Memory.readUtf8String(ptr("0x7f00000000")); // 简化示意,实际需读取/proc/self/maps if (maps.includes("libshella.so")) return "Tencent Legu"; if (maps.includes("libjiagu.so")) return "360 Jiagu"; if (maps.includes("libnqshield.so")) return "NetEase Yidun"; return "None"; }6.2 动态Hook策略引擎:JSON配置驱动的决策树
将Hook逻辑抽象为JSON配置,而非硬编码:
{ "strategy": "classloader_isolation", "target": "com.xxx.LoginActivity", "hook_method": "onCreate", "fallback": ["jni_onload_hook", "memory_scan"] }Frida脚本读取此配置,自动执行对应策略。我在团队内部推广此方案后,新App的适配时间从平均8小时降至45分钟。
6.3 可观测性埋点:不只是console.log,而是结构化日志
把日志写成JSON格式,方便ELK收集:
function logEvent(eventType, data) { const log = JSON.stringify({ timestamp: Date.now(), app: "com.xxx.app", frida_version: Frida.version, event: eventType, data: data, device: Device.id }); console.log("[FRIDA_LOG]" + log); // 专用前缀,便于日志系统过滤 }这样,当100台测试机同时运行脚本,所有日志可自动汇聚到Kibana,一眼看出哪款App在哪个Hook点失败率最高。
最后分享一个小技巧:Frida脚本的setTimeout在Android上有时会失效,因为主线程被阻塞。替代方案是用Java.performNow包裹异步操作,或直接调用android.os.Handler的post方法。这是我踩了三次坑后总结的——别信文档,信实测。
