当前位置: 首页 > news >正文

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.sosub_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()方法本身只是个门面。实际校验逻辑藏在CertificateChainCleanerTrustManagerImpl里。更关键的是,很多加固方案(如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.sossl_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,而是:

  1. 调用原函数获取返回值;
  2. 若返回false,手动构造一个合法的X509Certificate对象(用Java.use("java.security.cert.X509Certificate").$new());
  3. 将其注入到SSLSocketFactorytrustManager中。

这需要你提前用keytool -printcert -file proxy.crt导出Fiddler证书的SHA-256指纹,并在脚本中硬编码。别嫌麻烦——这是唯一能100%模拟真实代理环境的方法。我在某证券App审计中,就是靠这步发现了其后台接口对证书指纹的二次校验,否则会误判为“绕过成功”。

3. 绝技二:破解加固App的Java层Hook,关键在“类加载器隔离”与“反射逃逸”

当你的Frida脚本对com.xxx.MainActivityonCreate()Hook完全没反应,十有八九是遇到了类加载器隔离(ClassLoader Isolation)。这不是Frida失效,而是你Hook的对象根本不在当前ClassLoader里。以腾讯乐固为例,它会创建一个DexClassLoader,将核心业务类(如LoginActivityPayService)从原始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.StubShellcom.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.Activityandroid.content.Context的调用,记录其方法签名和参数类型

我开发了一个轻量级Frida模块MethodTracer,它不依赖类名,只监听dalvik.system.DexFileloadClassBinaryName调用,在类加载瞬间扫描所有方法的@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失效。此时必须启用双钩策略

  1. HookJNI_OnLoad:这是SO加载时的入口,所有JNI函数注册都在此处完成。Hook后,遍历gMethods数组,打印所有注册的Java层方法名与Native函数地址映射;
  2. Hookdlopen:监控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()不仅能打印栈帧,还能获取每个帧的moduleoffset。当App调用某个加密函数时,其调用栈必然包含libart.soart_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->GetObjectClassJNIEnv->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.sogenDeviceFingerprint(),返回值是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; } });

但问题来了:onLeaveretval是寄存器值,无法直接修改。最终解法是:Memory.patchCoderet指令前插入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-----".rodata1KB~4KB
"AES-128-ECB".data16字节
0x00,0x01,0x02,0x03,...(连续递增).bss32字节

用以下脚本快速扫描:

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 堆内存捕获:Hookmallocmemset的协同作战

密钥常在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指令,此时需Hookmemcpymemmove。这是很多教程遗漏的细节。

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.Handlerpost方法。这是我踩了三次坑后总结的——别信文档,信实测。

http://www.zskr.cn/news/1381218.html

相关文章:

  • 为静态网站生成器配置自动化AI内容摘要的简易方案
  • 基于ESP32与空气质量API的智能环境灯设计与实现
  • 为什么你的Midjourney输出总带“脏噪”?揭秘底层渲染管线中未公开的noise injection节点与4种绕过策略
  • Windows 11系统瘦身大作战:5分钟让你的电脑重获新生
  • 企业法务紧急通知:DeepSeek最新v2.3协议识别引擎已覆盖Rust/Cargo生态,错过本次升级将丧失GPLv3兼容审计资质
  • 揭秘Midjourney云雾渲染失效真相:3大隐性提示词冲突、2类SDXL迁移兼容漏洞及实时雾浓度校准公式
  • VMware Workstation Pro 17免费密钥终极指南:快速激活虚拟化神器
  • flowcontainer实战:加密流量特征工程的高效提取方案
  • Godot 2D随机地图三大静默故障:黑屏、穿墙、寻路失败的根源与修复
  • 基于Arduino Uno与MQ-2传感器的智能气体检测报警系统DIY全攻略
  • 机器学习赋能矩方法:破解稀薄气体强非平衡流动模拟难题
  • 为现有OpenAI兼容应用迁移到Taotoken的步骤指南
  • OpenCore Legacy Patcher技术突破:老旧Mac设备系统兼容性实战指南
  • 如何快速解密QQ音乐、网易云音乐等平台的加密音频文件?终极免费解决方案
  • 三步免费获取百度文库文档:浏览器控制台脚本实用指南
  • UOP MTO vs. 大连化物所DMTO:年产40万吨烯烃项目,工艺路线到底该怎么选?
  • 前景理论(Prospect Theory)深入扩展:数学公式、代码模拟、实验案例、AI结合及理论对比
  • 终极Obsidian笔记系统:如何用kepano-obsidian模板轻松管理你的数字生活
  • 5分钟快速上手res-downloader:跨平台资源下载工具的完整指南
  • Lovable后端集成安全红线清单,含OAuth2.1动态客户端注册、JWT密钥轮转、敏感头过滤(CWE-522/OWASP API Top 10对齐版)
  • 实战指南:基于YOLOv5的FPS游戏AI瞄准系统深度解析与高效应用
  • UE5高精度长度测量系统架构解析:定位球、射线检测与鼠标映射
  • NPU跑LLM实战指南:KV Cache动态性如何突破硬件限制
  • 工业洗地机什么牌子好用?从需求出发选对设备 - 品牌排行榜
  • 如何实现智能AutoCAD字体管理:FontCenter免费解决方案完整指南
  • 如何3分钟告别城通网盘下载烦恼:ctfileGet直链解析工具完全指南
  • C++ 标准库中的reverse 函数使用示例
  • 深入AMD处理器底层:SMUDebugTool硬件级调优实战
  • springboot的工程,写业务领域最好提前准备的依赖
  • Diablo Edit2:暗黑破坏神2存档修改器终极指南,轻松打造完美角色