攻克So层SSL Pinning:Frida与IDA逆向实战指南
1. 项目概述与核心挑战
如果你在小米8这类老款安卓设备上尝试用Charles或Burp Suite抓取某些App的网络包,大概率会遇到一个经典的“拦路虎”——SSL Pinning(证书锁定)。症状很典型:代理设置好了,CA证书也装上了,但App要么直接提示“网络错误”,要么在抓包工具里只能看到一堆CONNECT请求,具体内容全是加密的乱码。这背后的核心原因,就是应用在代码层面“锁死”了只信任自家服务器的证书,你安装的中间人证书即使被系统信任,也会因为指纹不匹配而被无情拒绝。
标题里提到的“So层SSL Pinning”,正是这个问题的“地狱难度”版本。当App将证书校验逻辑下沉到Native层,用C/C++代码在.so动态链接库里实现时,传统的、针对Java层的通用绕过脚本就彻底失效了。你会在Logcat里看到javax.net.ssl.SSLHandshakeException,或者干脆连异常都没有,连接直接静默失败。这时候,就需要祭出逆向工程领域的两大“神器”:Frida和IDA Pro。Frida负责动态运行时注入和Hook,而IDA则用于静态分析so库,定位那些深藏不露的校验函数。这篇教程,就是为你拆解这个组合拳,手把手带你攻克So层的证书堡垒。
2. 环境准备与工具链搭建
工欲善其事,必先利其器。在开始“手术”之前,我们需要一个稳定且版本匹配的工具环境。很多新手在这里栽跟头,问题往往出在版本不匹配或者环境配置错误。
2.1 Frida环境配置:版本同步是关键
首先,在电脑端安装Frida客户端工具。打开你的终端或命令行,执行以下命令:
pip install frida-tools安装完成后,用frida --version查看版本号,比如16.1.4。这是最关键的一步:你必须下载与此主版本号完全一致的frida-server。去Frida的GitHub Release页面,找到对应版本,根据你的手机架构(小米8是arm64)下载frida-server-16.1.4-android-arm64.xz。
接下来,将frida-server推送到手机并启动:
# 连接手机,确保USB调试已开启 adb devices # 推送frida-server到临时目录 adb push frida-server-16.1.4-android-arm64 /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server-16.1.4-android-arm64 # 以root权限在后台运行(需要手机已root) adb shell su -c “/data/local/tmp/frida-server-16.1.4-android-arm64 -D &”验证是否运行成功:
frida-ps -U如果能看到手机上的进程列表,恭喜你,Frida服务端已经成功跑起来了。
实操心得:很多连接失败问题都源于版本不匹配。务必保证
frida-tools、fridaPython包以及frida-server三者的主版本号(如16.x)完全一致。如果遇到Unable to connect to remote frida-server错误,第一件事就是检查版本。
2.2 抓包代理与系统证书安装
接下来配置抓包环境。我习惯用Burp Suite,你也可以用Charles,原理相通。
- 设置代理:在手机Wi-Fi设置中,手动配置代理,指向你电脑的IP和Burp监听的端口(默认8080)。
- 安装CA证书:这是另一个大坑,尤其是Android 7.0之后。仅仅把Burp的CA证书安装到“用户凭据”里是没用的,对于很多App来说,它们默认不信任用户安装的证书。必须将CA证书安装到系统信任的存储区。
对于已Root的设备(如刷了Magisk的小米8),最方便的方法是使用Magisk模块“MagiskTrustUserCerts”。安装后重启,它会自动将用户证书提升为系统证书。如果没有Magisk,则需要手动操作:
# 从Burp导出DER格式的CA证书(cacert.der) # 转换为PEM格式并计算哈希 openssl x509 -inform DER -in cacert.der -out cacert.pem HASH=$(openssl x509 -inform PEM -subject_hash_old -in cacert.pem | head -1) cp cacert.pem “${HASH}.0” # 推送至系统证书目录(需要/system分区可写,在Recovery或使用Magisk挂载) adb push “${HASH}.0” /system/etc/security/cacerts/ adb shell chmod 644 /system/etc/security/cacerts/${HASH}.0 adb reboot重启后,在系统的“信任的凭据” -> “系统”标签页下,应该能看到PortSwigger(Burp)的证书。
注意事项:Android 14及更高版本对/system分区的保护更严格,即使
adb remount也可能无法写入。此时Magisk模块几乎是唯一省心的选择。
2.3 IDA Pro基础准备
IDA Pro(Interactive Disassembler)是我们的静态分析主力。对于So层分析,我们主要用到它的反汇编和函数识别能力。不需要你成为逆向专家,但需要了解基本操作:
- 加载So文件:将目标App的APK解压,在
lib/arm64-v8a/或lib/armeabi-v7a/目录下找到目标so库(如libttboringssl.so,libnetguard.so等),用IDA打开。 - 识别关键函数:在IDA的“Exports”窗口或“Functions”窗口中,搜索
SSL_、verify、cert等关键字,寻找可能的校验函数。 - 分析函数逻辑:查看函数的交叉引用(Xrefs),了解它在哪里被调用,初步判断其作用。
我们的目标不是完全逆向整个so,而是结合Frida的动态行为,快速定位到那个关键的证书验证函数。
3. So层SSL Pinning原理与定位策略
为什么So层的Pinning更难搞?因为它跳出了Java虚拟机的管辖范围。应用通过JNI(Java Native Interface)调用编译好的原生代码,这些代码直接与操作系统底层的SSL库(如OpenSSL/BoringSSL)交互,执行证书验证。Java层的Hook点(如TrustManager)在这里完全不起作用。
3.1 常见So层实现方式
- 链接系统
libssl.so:这是最常见的方式。App在Native代码中直接调用系统提供的SSL库函数,如SSL_CTX_set_verify。我们的Hook目标就是这个系统库。 - 静态编译或自带SSL库:更高阶的应用会把BoringSSL等库的代码静态编译进自己的so文件(如
libnative-lib.so),或者打包一个独立的动态库(如字节系的libttboringssl.so)。这时,校验逻辑就在应用自定义的库中,我们需要分析这个特定的so。 - 自定义验证回调:无论是哪种链接方式,核心都是通过
SSL_CTX_set_verify或BoringSSL特有的SSL_CTX_set_custom_verify函数,注册一个自定义的回调函数。这个回调函数才是执行证书指纹比对、决定连接是否放行的“法官”。
3.2 逆向定位核心校验函数
面对一个陌生的so,如何快速找到那个“法官”?这里分享我的实战定位流程:
第一步:字符串搜索在IDA中按下Shift+F12打开字符串窗口,搜索以下关键词:
certificatverifypinsha256public.keyhandshake/SSL- 特定的错误信息,如
verification failed、untrusted。
找到这些字符串后,点击它,然后按X查看交叉引用,就能找到使用这些字符串的函数。这些函数很可能就是校验逻辑的一部分。
第二步:导出函数分析查看IDA的“Exports”标签页,直接寻找SSL相关的导出函数。重点关注:
SSL_CTX_set_verifySSL_CTX_set_custom_verify(BoringSSL)SSL_CTX_set_cert_verify_callback(BoringSSL)SSL_get_verify_result
如果这些函数被导出,那么Hook它们就是最直接的入口。
第三步:基于Frida的动态探测当静态分析毫无头绪时,Frida的动态插桩能力就是我们的“雷达”。我们可以写一个脚本来Hook所有可能的SSL相关函数,并打印调用堆栈,从而定位到应用实际使用的函数。
// trace_ssl.js - SSL函数追踪脚本 Java.perform(function() { // 先确保能连接到Native库 }); // 监听所有模块的加载 Interceptor.attach(Module.findExportByName(null, “dlopen”), { onEnter: function(args) { this.libpath = args[0].readCString(); if (this.libpath && this.libpath.indexOf(“.so”) !== -1) { console.log(“[*] dlopen: “ + this.libpath); } }, onLeave: function(retval) { if (retval.toInt32() != 0) { // 库加载成功后,尝试Hook常见SSL函数 var libName = this.libpath.split(‘/’).pop(); hookCommonSSLFunctions(libName); } } }); function hookCommonSSLFunctions(libName) { var funcs = [“SSL_CTX_set_verify”, “SSL_set_verify”, “SSL_get_verify_result”, “SSL_CTX_set_custom_verify”, “SSL_CTX_set_cert_verify_callback”]; funcs.forEach(function(funcName) { var addr = Module.findExportByName(libName, funcName); if (addr) { console.log(“[+] Found “ + funcName + ” in ” + libName + ” @ ” + addr); Interceptor.attach(addr, { onEnter: function(args) { console.log(“[>] Called: ” + funcName); // 打印调用栈,帮助定位上层逻辑 console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join(‘\n’) + ‘\n’); } }); } }); }运行这个脚本后操作App触发网络请求,观察控制台输出。哪个函数被调用了,它的调用栈是什么,一目了然。这能极大缩小我们的分析范围。
4. 实战:使用Frida Hook So层函数
定位到关键函数后,真正的绕过就开始了。Frida提供了强大的Interceptor API来操作Native函数。
4.1 基础Hook:让验证失效
假设我们通过分析,确定目标App使用了系统libssl.so的SSL_CTX_set_verify函数。我们的目标是让验证失效。这个函数原型是:void SSL_CTX_set_verify(SSL_CTX *ctx, int mode, int (*verify_callback)(int, X509_STORE_CTX *));其中mode参数为SSL_VERIFY_NONE(0)时表示不验证。
// hook_libssl.js Interceptor.attach(Module.findExportByName(“libssl.so”, “SSL_CTX_set_verify”), { onEnter: function(args) { // args[0] 是 SSL_CTX* // args[1] 是 mode (int) // args[2] 是 verify_callback 函数指针 console.log(“[*] SSL_CTX_set_verify called. Original mode: “ + args[1].toInt32()); // 关键操作:将验证模式强制设为 SSL_VERIFY_NONE (0) args[1] = ptr(0x0); // 同时清空自定义回调函数指针,防止它被调用 args[2] = ptr(0x0); console.log(“[+] Mode forced to SSL_VERIFY_NONE, callback cleared.”); } });这个Hook非常简单粗暴:无论应用原本想设置何种严格的验证模式,我们都把它改成“不验证”。对于很多使用标准OpenSSL接口的应用,这一招就足够了。
4.2 进阶Hook:处理自定义验证回调
有些应用不仅设置验证模式,还会传入一个自定义的verify_callback函数。这时,仅仅设置mode为0可能不够,因为应用可能在回调函数里还有别的逻辑。我们需要连这个回调函数一起“解决”掉。
方法一:替换回调函数。我们在HookSSL_CTX_set_verify时,如果发现它传入了回调函数指针(args[2]不为空),就用一个我们自己编写的、永远返回成功(返回值为1)的假回调函数替换它。
Interceptor.attach(Module.findExportByName(“libssl.so”, “SSL_CTX_set_verify”), { onEnter: function(args) { var callbackPtr = args[2]; if (!callbackPtr.isNull()) { console.log(“[*] Found custom verify_callback @ ” + callbackPtr); // 创建一个新的NativeCallback,总是返回1(验证成功) var fakeCallback = new NativeCallback(function(ok, storeCtx) { console.log(“[*] Fake verify_callback executed, returning 1 (success).”); return 1; }, ‘int’, [‘int’, ‘pointer’]); // 函数签名:int (*)(int, X509_STORE_CTX*) // 替换原有的回调指针 args[2] = fakeCallback; } // 同样,将模式设为NONE更保险 args[1] = ptr(0x0); } });方法二:Hook验证结果函数。另一个思路是HookSSL_get_verify_result函数,它返回最终的验证结果(0表示X509_V_OK,即成功)。我们强制让它返回0。
Interceptor.attach(Module.findExportByName(“libssl.so”, “SSL_get_verify_result”), { onLeave: function(retval) { console.log(“[*] SSL_get_verify_result intercepted. Forcing X509_V_OK.”); retval.replace(ptr(0x0)); // 替换返回值为0(成功) } });这种方法更底层,不管前面有多少验证步骤,最终我们都告诉上层“验证通过了”。
4.3 针对BoringSSL与定制库的Hook
对于像libttboringssl.so(字节跳动系)或libboringssl.so这类定制库,方法类似,但函数名和参数可能略有不同。BoringSSL常用SSL_CTX_set_custom_verify,其回调函数返回值是ssl_verify_result_t枚举(ssl_verify_ok为0)。
var libName = “libttboringssl.so”; // 或 “libboringssl.so” var customVerifyAddr = Module.findExportByName(libName, “SSL_CTX_set_custom_verify”); if (customVerifyAddr) { Interceptor.attach(customVerifyAddr, { onEnter: function(args) { // args[2] 是 ssl_verify_result_t (*callback)(SSL *ssl, uint8_t *out_alert) if (!args[2].isNull()) { var fakeCallback = new NativeCallback(function(ssl, out_alert) { console.log(“[*] BoringSSL custom_verify callback -> ssl_verify_ok”); return 0; // ssl_verify_ok }, “int”, [“pointer”, “pointer”]); args[2] = fakeCallback; console.log(“[+] Replaced SSL_CTX_set_custom_verify callback.”); } } }); }4.4 处理未导出函数与偏移计算
最棘手的情况是:关键函数没有被导出(符号被剥离)。你在IDA的Exports列表里找不到SSL_CTX_set_custom_verify。这时候就需要结合静态分析来计算函数在内存中的偏移地址。
- 用IDA找到函数地址:在IDA中分析so文件,找到目标函数的起始地址。例如,你发现
ssl_verify_peer_cert这个函数在IDA中的地址是0x123456。 - 计算基址偏移:这个
0x123456是文件偏移地址或加载偏移。在Frida中,我们需要的是函数在内存中的绝对地址。公式是:内存绝对地址 = 模块基地址 + 函数在IDA中的偏移地址。 - 使用Frida Hook:
var libName = “libtarget.so”; var moduleBase = Module.findBaseAddress(libName); if (moduleBase) { // 假设通过IDA分析,得知关键校验函数 verify_cert 的偏移是 0x123456 var functionOffset = 0x123456; var targetAddress = moduleBase.add(functionOffset); console.log(“[*] Hooking unexported function @ ” + targetAddress); Interceptor.attach(targetAddress, { onEnter: function(args) { console.log(“[*] Custom verify function entered.”); }, onLeave: function(retval) { // 强制函数返回成功 (例如,返回1或一个表示成功的指针) console.log(“[*] Forcing custom verify function to return success.”); retval.replace(ptr(0x1)); // 根据函数实际返回值类型调整 } }); }核心技巧:如何确定偏移量?在IDA中,你可以直接看函数起始地址。同时,确保IDA中设置的
Loading address(镜像基址)与Frida中Module.findBaseAddress返回的基址计算方式一致。通常,如果so是动态加载的,IDA默认的基址是0,那么函数地址就是偏移量。更稳妥的方法是:在IDA中找一个已导出的函数,记下它的地址A,同时在Frida中用Module.findExportByName得到它的运行时地址B。那么,偏移量 = 目标函数IDA地址 - 已导出函数IDA地址。目标函数运行时地址 = B + 偏移量。
5. 组合拳:整合Java层与Native层绕过
一个加固良好的App,往往会在Java层和Native层同时部署SSL Pinning,形成“双保险”。我们的绕过脚本也需要是多层防御的。一个完整的绕过脚本应该像下面这样,同时覆盖多个层面:
// universal_bypass.js Java.perform(function() { console.log(“[*] Starting universal SSL pinning bypass...”); // ——— 1. Java层:全局TrustManager替换(兜底方案)——— var TrustAllManager = Java.registerClass({ name: “com.bypass.TrustAllManager”, implements: [Java.use(“javax.net.ssl.X509TrustManager”)], methods: { checkClientTrusted: function() {}, checkServerTrusted: function() {}, getAcceptedIssuers: function() { return []; } } }); var sslContext = Java.use(“javax.net.ssl.SSLContext”); sslContext.init.overload(‘[Ljavax.net.ssl.KeyManager;’, ‘[Ljavax.net.ssl.TrustManager;’, ‘java.security.SecureRandom’).implementation = function(km, tm, sr) { console.log(“[+] Hooking SSLContext.init, installing TrustAllManager.”); var trustAllArray = Java.array(‘javax.net.ssl.TrustManager’, [TrustAllManager.$new()]); this.init(km, trustAllArray, sr); }; // ——— 2. Java层:针对OkHttp的CertificatePinner ——— try { var CertPinner = Java.use(“okhttp3.CertificatePinner”); CertPinner.check.overload(‘java.lang.String’, ‘java.util.List’).implementation = function() { console.log(“[+] Bypassing OkHttp CertificatePinner.check()”); // 直接返回,不执行校验 }; } catch(e) { console.log(“[-] OkHttp not found or already hooked.”); } // ——— 3. Java层:WebView SSL错误处理 ——— var WebViewClient = Java.use(“android.webkit.WebViewClient”); WebViewClient.onReceivedSslError.implementation = function(view, handler, error) { console.log(“[+] Bypassing WebView SSL error.”); handler.proceed(); // 忽略错误,继续加载 }; }); // ——— 4. Native层:系统libssl.so ——— var sslLib = ‘libssl.so’; var sslBase = Module.findBaseAddress(sslLib); if (sslBase) { console.log(“[+] Found ” + sslLib + ” @ ” + sslBase); // Hook标准验证模式设置 var setVerifyAddr = Module.findExportByName(sslLib, “SSL_CTX_set_verify”); if (setVerifyAddr) { Interceptor.attach(setVerifyAddr, { onEnter: function(args) { args[1] = ptr(0); // SSL_VERIFY_NONE args[2] = ptr(0); // 清除回调 console.log(“[+] “ + sslLib + ” SSL_CTX_set_verify -> NONE”); } }); } // Hook验证结果获取 var getResultAddr = Module.findExportByName(sslLib, “SSL_get_verify_result”); if (getResultAddr) { Interceptor.attach(getResultAddr, { onLeave: function(retval) { retval.replace(ptr(0)); // X509_V_OK } }); } } // ——— 5. Native层:定制BoringSSL库(如libttboringssl.so)——— var targetLibs = [‘libttboringssl.so’, ‘libboringssl.so’]; targetLibs.forEach(function(libName) { var mod = Process.findModuleByName(libName); if (!mod) return; console.log(“[+] Targeting ” + libName); // 尝试Hook BoringSSL特有API var customVerifyAddr = Module.findExportByName(libName, “SSL_CTX_set_custom_verify”); if (customVerifyAddr) { Interceptor.attach(customVerifyAddr, { onEnter: function(args) { if (!args[2].isNull()) { var fakeCB = new NativeCallback(function(ssl, out_alert) { return 0; // ssl_verify_ok }, “int”, [“pointer”, “pointer”]); args[2] = fakeCB; console.log(“[+] Replaced SSL_CTX_set_custom_verify in ” + libName); } } }); } // 同样的,可以添加对SSL_CTX_set_cert_verify_callback等的Hook }); console.log(“[*] Universal bypass script loaded.”);将上述脚本保存为bypass.js,使用Frida加载:
frida -U -f com.target.app --no-pause -l bypass.js-f参数表示以spawn方式启动应用,--no-pause确保应用不会在启动时暂停,这对于绕过某些在启动时就进行校验的App很重要。
6. 疑难排查与进阶对抗
即使脚本写得再完美,实战中也可能遇到各种意外。下面是一些常见的“坑”及其解决方案。
6.1 脚本注入成功,但抓包仍失败
- 检查CA证书:确认Burp/Charles的CA证书已成功安装到系统信任区(System trust store),而不是用户区。在手机设置中查看“加密与凭据”->“信任的凭据”->“系统”页签。
- 检查代理流量:有些App不使用系统代理。可以尝试用
iptables强制重定向流量:
这条命令将所有从手机发出的443端口(HTTPS)流量重定向到你的抓包工具。用完记得清理规则:adb shell su -c “iptables -t nat -A OUTPUT -p tcp –dport 443 -j DNAT –to-destination <你的电脑IP>:8080”adb shell su -c “iptables -t nat -F OUTPUT”。 - 检查是否有多层Pinning:应用可能同时使用了
Network Security Configuration(Android 7.0+)和Native Pinning。确保你的脚本也Hook了NetworkSecurityTrustManager(见上方完整脚本的Java层部分)。 - 查看Frida输出:仔细阅读Frida控制台的输出,看你的Hook点是否真的被触发。如果没有
[+]或[*]开头的成功日志,说明Hook的类或函数不对,需要重新分析。
6.2 应用启动崩溃或检测到Frida
高安全级别的App会进行反调试/反注入检测。
- 检测Frida Server:扫描
27042默认端口或查找frida-server进程。对策:修改Frida Server启动端口。# 在手机上 /data/local/tmp/frida-server -l 0.0.0.0:9999 # 在电脑上连接时指定端口 frida -H 192.168.1.100:9999 -f com.target.app -l bypass.js - 检测内存映射:读取
/proc/self/maps检查是否有frida-agent字符串。对策:使用Frida Gadget模式,将frida-gadget.so打包进App,而不是通过frida-server注入。这更隐蔽,但需要重新打包APK。 - 完整性校验:App可能检查自身的签名或Dex/So文件是否被修改。对策:尝试在App启动最早的时刻注入(使用
-fspawn模式),在完整性校验执行前完成Hook。
6.3 应对代码混淆与加固
如果App的so库被加固(加壳),直接分析IDA加载的so可能是一团乱码。你需要先进行脱壳。对于Frida Hook来说,有时可以绕过脱壳:我们Hook的是运行时内存中已解压的代码。关键在于找到正确的时机。监听dlopen事件,在目标so被加载到内存的瞬间进行Hook,是一个有效的方法。
// 监听so加载 var dlopen = Module.findExportByName(null, “dlopen”); Interceptor.attach(dlopen, { onEnter: function(args) { var path = args[0].readCString(); if (path && path.includes(“libttboringssl.so”)) { // 替换为你的目标so名 this.targetLib = path; } }, onLeave: function(retval) { if (this.targetLib) { // 延迟一小段时间,确保so初始化完成 setTimeout(function() { console.log(“[*] ” + this.targetLib + ” loaded, installing hooks...”); // 在这里调用你的Hook函数 hookSpecificLib(this.targetLib); }.bind(this), 100); } } });7. 案例复盘:小米8上的完整操作流
让我们串联起整个流程,假设目标是一个在小米8上使用了libttboringssl.so进行So层Pinning的App:
- 准备:手机解锁Bootloader,刷入Magisk获取Root权限。安装
MagiskTrustUserCerts模块并导入Burp的CA证书。安装Frida Server并启动(注意非默认端口)。 - 侦查:解压APK,在
lib/arm64-v8a目录下发现libttboringssl.so。用IDA打开,发现SSL_CTX_set_custom_verify函数被导出。 - 编写脚本:基于第5节的完整脚本,重点完善针对
libttboringssl.so的Hook部分。 - 执行:
frida -U -H 192.168.1.100:9999 -f com.target.app --no-pause -l universal_bypass.js - 验证:观察Frida输出,确认
SSL_CTX_set_custom_verify等Hook点被成功拦截并替换。操作App触发网络请求,此时Burp Suite中应该能看到明文的HTTPS请求和响应了。 - 调试:如果失败,打开Burp的
Proxy->Options->SSL Pass Through,确保没有将目标域名加入直通列表。同时查看手机Logcat (adb logcat | grep -i ssl) 和Frida输出,寻找错误线索。
整个过程的核心思想是动态分析定位,精准Hook替换。不需要完全读懂so里每一行汇编代码,只要找到那个决定“是”或“否”的关键函数,然后让它在运行时永远说“是”,就成功了。
最后,记住这类技术仅用于你拥有合法测试权限的应用,比如自己开发的应用、公司授权的安全评估或漏洞众测项目。在合规的范围内探索技术,才能走得更远。