攻克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重启后在系统的“信任的凭据” - “系统”标签页下应该能看到PortSwiggerBurp的证书。注意事项Android 14及更高版本对/system分区的保护更严格即使adb remount也可能无法写入。此时Magisk模块几乎是唯一省心的选择。2.3 IDA Pro基础准备IDA ProInteractive 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虚拟机的管辖范围。应用通过JNIJava 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中按下ShiftF12打开字符串窗口搜索以下关键词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 Hookvar 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强制重定向流量adb shell su -c “iptables -t nat -A OUTPUT -p tcp –dport 443 -j DNAT –to-destination 你的电脑IP:8080”这条命令将所有从手机发出的443端口HTTPS流量重定向到你的抓包工具。用完记得清理规则adb shell su -c “iptables -t nat -F OUTPUT”。检查是否有多层Pinning应用可能同时使用了Network Security ConfigurationAndroid 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里每一行汇编代码只要找到那个决定“是”或“否”的关键函数然后让它在运行时永远说“是”就成功了。最后记住这类技术仅用于你拥有合法测试权限的应用比如自己开发的应用、公司授权的安全评估或漏洞众测项目。在合规的范围内探索技术才能走得更远。