Frida实战:突破Android SSL Pinning多层防御的进阶Hook技巧
1. 项目概述与核心挑战在移动应用安全测试和逆向工程领域SSL Pinning证书绑定是一个让很多安全研究员和开发者又爱又恨的技术。爱它是因为它确实能有效防止中间人攻击保护用户数据在传输过程中的安全恨它是因为当我们作为安全测试人员需要分析应用网络流量时它就像一道坚固的锁把我们的抓包工具挡在了门外。你可能会遇到这样的情况用Burp Suite或Charles设置好代理信心满满地打开目标App结果发现网络请求要么直接失败要么返回一堆乱码App本身却运行正常。这十有八九就是SSL Pinning在起作用。我最近在分析一个社交类App时就遇到了一个比较棘手的证书绑定实现。它不像早期那些只校验公钥哈希的简单实现而是采用了更复杂的证书链校验和动态加载机制。传统的绕过方法比如直接替换TrustManager或者HookX509TrustManager的checkServerTrusted方法在这个App上完全失效了。这促使我深入研究了Frida在Android平台上进行SSL Pinning绕过的进阶技巧也就是今天要分享的实战内容。这不仅仅是“绕过”更是一次对应用安全机制的深度剖析。无论你是想学习如何加固自己的App还是作为安全研究员需要突破这层防御理解其背后的原理和对抗方法都至关重要。2. SSL Pinning 进阶原理与常见实现方式拆解在开始Hook之前我们必须先搞清楚对手是谁。SSL Pinning的核心思想是客户端App不再完全信任操作系统或浏览器提供的根证书列表CA证书而是只信任自己预先“钉入”Pinned的特定证书或公钥。这样即使攻击者成功让系统信任了一个恶意CA证书也无法对启用Pinning的App进行中间人攻击因为App只认自己绑定的那个证书。2.1 证书绑定的几种主流实现方式根据绑定的对象和校验的严格程度可以分为以下几类难度依次递增证书锁定这是最初级的形式。App在代码或资源文件中硬编码了一个或多个特定证书通常是.der或.pem格式。在建立SSL连接时会对比服务器返回的证书是否与本地存储的完全一致。这种方式最容易绕过因为找到那个硬编码的证书文件或内存中的证书数据然后用自己的证书替换掉或者直接Hook证书比对函数返回true即可。公钥锁定比证书锁定更灵活一些。App存储的是证书的公钥或公钥的哈希如SHA-256。只要服务器证书的公钥与本地存储的匹配即使证书本身因为到期而重新签发连接也能正常建立。这需要我们去定位存储公钥的位置和校验公钥的函数。证书链锁定这是目前很多成熟App采用的方式。App不仅校验叶子证书服务器证书还会校验整个证书链确保链中的每一个证书包括中间CA证书都符合预期。它可能要求链中必须出现某个特定的中间CA证书或者整个证书链的哈希必须匹配。这种方式大大增加了绕过难度因为你需要伪造或绕过对整个链的校验。动态/网络证书锁定最棘手的一种。证书或公钥信息并不直接打包在APK里而是在App首次启动或定期从服务器动态获取。这避免了密钥硬编码在客户端被反编译的风险。对抗这种方式通常需要先拦截网络请求拿到服务器下发的证书信息或者直接Hook App内部处理这些动态证书的逻辑。2.2 Android 中 SSL 验证的关键代码路径要绕过就得知道校验发生在哪里。在Android中SSL验证的核心发生在以下几个层次Java层 -HttpsURLConnection/OkHttp/Retrofit这是最常被Hook的层面。例如OkHttp可以通过自定义CertificatePinner来实现Pinning。我们需要找到设置CertificatePinner的地方或者直接Hook其内部的验证方法。Java层 -TrustManager这是SSL上下文的核心。X509TrustManager接口的checkServerTrusted方法是校验服务器证书的最终关卡。很多初级绕过脚本就是通过Hook这个方法并让它直接返回即不做任何校验来实现的。Native层 (C/C)一些对性能和安全要求极高的App或者使用了特定网络库如Cronet Chromium的网络栈的App可能会将证书校验逻辑放在Native层。这通常涉及OpenSSL或BoringSSL库中的函数如SSL_CTX_set_cert_verify_callback或X509_verify_cert。Hook这一层需要用到Frida的Interceptor来操作Native函数。框架层Android系统本身的网络栈也可能被修改以支持Pinning但这更多见于系统App或深度定制的ROM。我遇到的那个社交App初步分析发现它同时使用了OkHttp的CertificatePinner和自定义的X509TrustManager并且在Native层还有额外的签名校验属于复合型防御这也是我将其作为“实战篇二”进阶案例的原因。3. 实战环境准备与目标分析工欲善其事必先利其器。在开始写Frida脚本之前我们需要一个稳定的实验环境。3.1 环境与工具清单测试设备一台已Root的Android手机或一个已Root的Android模拟器如夜神、雷电。我强烈推荐使用真机因为模拟器环境有时会引入一些难以排查的兼容性问题。确保设备的USB调试模式已打开。Frida环境PC端安装Python及Frida-tools (pip install frida-tools)。设备端下载与PC端Frida版本对应的frida-server通过adb push推送到设备并赋予可执行权限以后台方式运行。记得根据设备CPU架构arm,arm64,x86等选择正确的frida-server版本。抓包工具Burp Suite Professional 或 Charles。配置好代理如127.0.0.1:8080并在设备上安装并信任抓包工具的CA证书。重要提示在Android 7.0 (API 24) 及以上版本系统默认不再信任用户安装的CA证书除非App明确配置。因此我们需要将Burp/Charles的CA证书安装到系统证书目录/system/etc/security/cacerts/这通常需要Root权限。目标App选择一个集成了SSL Pinning的App作为测试目标。出于法律和道德考虑请不要对非授权的商业App进行测试。你可以自己编写一个包含SSL Pinning功能的Demo App或者使用一些故意设计用于安全学习的“靶场”App。3.2 目标App的初步侦察在Hook之前我们需要对目标App有一个基本的了解。静态分析使用apktool或jadx-gui反编译APK文件。首先搜索一些关键字符串如“pin”, “certificate”, “sha256/”, “PublicKey”等这有助于快速定位可能硬编码的证书信息。然后重点查看网络相关代码搜索OkHttpClient.Builder(),CertificatePinner,X509TrustManager等类的使用。动态分析在未进行任何绕过操作的情况下先尝试抓包。观察App启动时和发起网络请求时的行为。如果请求失败并伴有SSL错误日志如“Certificate pinning failure”, “SSL handshake exception”则确认SSL Pinning存在。同时使用frida-ps -U确认App的进程名并使用frida-trace快速追踪一些可能的类和方法例如frida-trace -U -p PID -j *!*certificate* -j *!*pinning* -j *!*TrustManager*这能帮助我们快速了解App在运行时加载和调用了哪些与证书相关的类。以我分析的社交App为例通过静态分析我在res/raw目录下发现了一个pinned_certs.pem文件里面包含了几个证书。同时在代码中发现了OkHttpClient构建时调用了.certificatePinner(...)方法。动态分析时直接抓包所有请求都失败Frida-trace显示大量对com.android.org.conscrypt.TrustManagerImpl和okhttp3.CertificatePinner中方法的调用。4. 核心Hook策略与Frida脚本编写面对复合型防御我们需要一套组合拳。下面我将分层次讲解Hook策略并提供关键的Frida JavaScript代码片段。4.1 第一层攻破 Java 层 CertificatePinnerOkHttp的CertificatePinner是常见的防线。它的check方法会在连接时被调用。我们的目标是让这个检查永远通过。策略HookCertificatePinner类的check方法或者Hook其内部用于比对的pin验证逻辑直接让函数返回而不抛出异常。Frida脚本示例Java.perform(function() { var CertificatePinner Java.use(okhttp3.CertificatePinner); // 方法一直接替换check方法让它什么都不做 CertificatePinner.check.overload(java.lang.String, java.util.List).implementation function(hostname, pinsToCheck) { console.log([] CertificatePinner.check called for host: hostname); // 原方法会在这里执行校验失败则抛出异常。我们直接不调用原方法就等于跳过了校验。 // 如果需要更隐蔽可以调用原方法但捕获所有异常。 console.log([-] SSL Pinning check bypassed for hostname); return; // 原方法返回void }; // 方法二Hook构建CertificatePinner的builder返回一个空的或伪造的pinner var CertificatePinner_Builder Java.use(okhttp3.CertificatePinner$Builder); CertificatePinner_Builder.build.implementation function() { console.log([] CertificatePinner.Builder.build() called, returning empty pinner); // 调用原方法得到一个builder然后清除里面所有的pin规则 var originalPinner this.build(); // 创建一个新的、没有任何pin规则的CertificatePinner var emptyPinner CertificatePinner.Builder().build(); return emptyPinner; }; });注意有些App可能会混淆OkHttp的类名比如变成a.b.c这样的形式。你需要通过静态分析或运行时枚举Java.enumerateLoadedClasses来找到真实的类名。4.2 第二层瓦解自定义 TrustManager如果App实现了自己的X509TrustManager那么绕过CertificatePinner可能还不够因为TrustManager会进行另一轮校验。策略找到自定义的TrustManager类Hook其checkServerTrusted、checkClientTrusted和getAcceptedIssuers方法。Frida脚本示例Java.perform(function() { // 首先尝试找到App自定义的TrustManager类。可以通过枚举或已知类名。 Java.choose(com.example.app.CustomTrustManager, { onMatch: function(instance) { console.log([] Found CustomTrustManager instance: instance); }, onComplete: function() { console.log([] CustomTrustManager search complete.); } }); // 更通用的方法Hook所有X509TrustManager的实现可能影响系统其他App需谨慎 var X509TrustManager Java.use(javax.net.ssl.X509TrustManager); var checkServerTrusted X509TrustManager.checkServerTrusted; if (checkServerTrusted) { checkServerTrusted.overload([Ljava.security.cert.X509Certificate;, java.lang.String).implementation function(chain, authType) { console.log([] X509TrustManager.checkServerTrusted bypassed.); // 关键不调用原方法或者调用一个空实现直接放行。 // 如果直接返回对于void函数没问题。但有些实现可能需要返回特定值这里通常是void。 return; }; } // 有些实现可能直接继承自Android系统的TrustManagerImpl需要找到具体类 var TrustManagerImpl Java.use(com.android.org.conscrypt.TrustManagerImpl); TrustManagerImpl.verifyChain.implementation function(...args) { console.log([] Bypassing TrustManagerImpl.verifyChain); return; // 绕过链式验证 }; });实操心得直接Hook系统级的X509TrustManager接口可能会造成系统不稳定或影响其他App。更稳妥的做法是精确找到目标App自己实例化的那个TrustManager对象然后修改其行为。可以通过HookSSLContext.init()方法查看其传入的TrustManager参数来定位。4.3 第三层应对 Native 层校验硬骨头当Java层绕过后仍然无法抓包或者你在日志中看到来自Native库如libssl.so,libcrypto.so的SSL错误时就需要挑战Native层了。策略使用Frida的Interceptor来Hook Native库中的关键验证函数。最常Hook的函数是SSL_CTX_set_cert_verify_callback用于设置自定义验证回调和X509_verify_cert实际执行证书验证。Frida脚本示例Interceptor.attach(Module.findExportByName(libssl.so, SSL_CTX_set_cert_verify_callback), { onEnter: function(args) { // args[0]是SSL_CTX* args[1]是回调函数指针 args[2]是用户数据 console.log([Native] SSL_CTX_set_cert_verify_callback called.); // 我们的目标让这个回调函数变成一个直接返回1成功的函数 // 但更常见的做法是Hook后续被调用的验证函数本身。 }, onLeave: function(retval) { // 可以在这里修改返回值 } }); // 更直接的方法Hook实际的验证函数 X509_verify_cert var verifyCertAddr Module.findExportByName(libcrypto.so, X509_verify_cert); if (verifyCertAddr) { Interceptor.attach(verifyCertAddr, { onEnter: function(args) { console.log([Native] X509_verify_cert called. Bypassing...); // 跳过复杂的验证逻辑直接让函数返回成功 (1) }, onLeave: function(retval) { // 将返回值修改为1验证成功 retval.replace(1); console.log([Native] X509_verify_cert forced to return 1 (success).); } }); } else { console.log([!] X509_verify_cert not found in libcrypto.so. Trying alternative names or libraries.); // BoringSSL 或不同版本可能函数名不同如 X509_verify。 var altAddr Module.findExportByName(libcrypto.so, X509_verify); if (altAddr) { Interceptor.attach(altAddr, { onLeave: function(retval) { retval.replace(1); } }); } }注意事项Native Hook的稳定性要求很高。函数签名、参数数量、调用约定stdcall,cdecl必须完全正确否则会导致App崩溃。务必先在小范围测试。另外不同Android版本和设备厂商可能使用不同的SSL库OpenSSL, BoringSSL或不同版本的so库名需要灵活调整。4.4 整合脚本与自动化在实际测试中我们通常会将上述多层Hook整合到一个脚本中并增加一些健壮性判断。// bypass_ssl_pinning_advanced.js Java.perform(function() { console.log([] Starting advanced SSL Pinning bypass...); // 1. Bypass OkHttp CertificatePinner bypassOkHttpPinner(); // 2. Bypass custom TrustManagers bypassTrustManagers(); // 3. Bypass Native checks (optional, if needed) // bypassNativeChecks(); // 注释掉需要时再开启 console.log([] SSL Pinning bypass hooks installed.); }); function bypassOkHttpPinner() { try { var CertificatePinner Java.use(okhttp3.CertificatePinner); CertificatePinner.check.overload(java.lang.String, java.util.List).implementation function(hostname, pinsToCheck) { console.log([OkHttp] Pinning check bypassed for: hostname); return; }; console.log([] OkHttp CertificatePinner bypassed.); } catch (e) { console.log([-] OkHttp bypass failed (maybe not used): e.message); } // 也尝试Hook常见的混淆后类名模式 Java.enumerateLoadedClasses({ onMatch: function(className) { if (className.includes(CertificatePinner) || className.toLowerCase().includes(pin)) { console.log([?] Potential Pinner class: className); // 可以尝试动态Hook } }, onComplete: function() {} }); } function bypassTrustManagers() { // 寻找并Hook所有可能的checkServerTrusted方法 Java.enumerateClassLoaders({ onMatch: function(loader) { var TrustManager; try { TrustManager loader.loadClass(javax.net.ssl.X509TrustManager); } catch (e) { return; } var methods TrustManager.getDeclaredMethods(); for (var i 0; i methods.length; i) { var method methods[i]; if (method.getName().indexOf(checkServerTrusted) ! -1) { console.log([] Found checkServerTrusted in loader: loader); // 这里需要更精细的处理因为方法是属于类而不是实例。 // 更常见的做法是Hook SSLContext.init 或找到具体的实现类实例。 } } }, onComplete: function() {} }); // 实用技巧Hook SSLContext.init 来捕获实际使用的TrustManager var SSLContext Java.use(javax.net.ssl.SSLContext); SSLContext.init.overload([Ljavax.net.ssl.KeyManager;, [Ljavax.net.ssl.TrustManager;, java.security.SecureRandom).implementation function(keyManagers, trustManagers, secureRandom) { console.log([] SSLContext.init called.); if (trustManagers) { for (var i 0; i trustManagers.length; i) { console.log( TrustManager[ i ]: trustManagers[i].$className); // 可以在这里替换或包装trustManagers[i] } } // 继续调用原方法 return this.init(keyManagers, trustManagers, secureRandom); }; }运行脚本frida -U -f com.target.app -l bypass_ssl_pinning_advanced.js --no-pause5. 实战问题排查与深度对抗技巧即使脚本写好了实战中依然会遇到各种问题。下面是一些常见坑点和解决思路。5.1 问题排查清单问题现象可能原因排查步骤Frida注入失败App闪退1. Frida-server版本不匹配。2. App有反调试/反Frida检测。3. 脚本语法错误导致崩溃。1. 检查frida --version与server版本。2. 先运行frida -U -f com.app --no-pause不加载脚本看是否崩溃。如果崩溃说明有反制。需要先绕过反调试。3. 使用frida -U -f com.app -l script.js --runtimev8或检查JS语法。Hook成功但抓包仍失败1. 证书绑定发生在更底层如Native。2. App使用了自定义Socket或非HTTP协议。3. 设备系统证书未正确安装Android 7.0。1. 查看Logcat中是否有Native SSL错误。开启Native Hook尝试。2. 使用tcpdump或Wireshark查看是否有原始TCP流量。3. 确认Burp/Charles的CA证书已正确放入/system/etc/security/cacerts/并重命名为.0格式。部分请求成功部分失败1. App对不同域名使用了不同的校验策略。2. 动态加载证书某些请求证书还未加载。1. 在Hook脚本中打印hostname区分对待不同域名。2. 尝试在App启动早期就注入脚本并Hook网络请求初始化部分。运行一段时间后失效1. App有定时校验或心跳包检测。2. Frida脚本被内存清理或App重启了网络模块。1. 将关键Hook点设置为持久化确保每次相关类被调用时都生效。2. 使用setImmediate或监听类加载事件来重新应用Hook。5.2 对抗反调试与反Hook高安全级别的App不会坐以待毙。它们可能会检测Frida通过检查进程内存中是否有frida-agent字符串、检测特定端口如27042默认Frida端口、或检查/proc/self/maps和/proc/self/task/*/status中是否有Frida痕迹。反调试调用ptrace自身防止其他调试器附加。代码混淆与动态加载核心校验逻辑被严重混淆或在运行时通过DexClassLoader从服务器动态加载。应对策略隐藏Frida使用frida-server的-l 0.0.0.0:8080参数绑定到非常见端口并使用-D参数指定守护进程名进行伪装。在脚本中可以尝试Hook那些检测函数使其永远返回假值。绕过反调试对于ptrace可以Hookptrace函数当发现是目标App在调用时直接返回0成功模拟PTRACE_TRACEME已被自己占用的假象。处理动态加载HookDexClassLoader或PathClassLoader的loadClass方法当目标校验类被加载时立即对其中的关键方法进行Hook。这需要你对App的加载时机有一定预判。5.3 一个综合案例绕过某App的复合校验以我遇到的那个社交App为例最终成功的脚本思路如下早期注入在App启动的Application.onCreate()阶段就注入我们的脚本确保在网络库初始化之前完成Hook。多层覆盖HookOkHttpClient.Builder().certificatePinner()返回一个空的pinner。通过HookSSLContext.init发现其使用了一个自定义的CustomX509TrustManager定位到这个类。直接HookCustomX509TrustManager.checkServerTrusted使其静默返回。在Logcat中仍看到来自libnetguard.so的SSL错误于是使用Frida的Module.enumerateExports找到该so中所有包含verify、cert字样的函数批量进行Hook将其返回值改为1。稳定性处理将所有Hook操作包裹在try-catch中避免因某个Hook点失效导致脚本整体崩溃。并使用Java.ensureClass来等待目标类被加载后再进行Hook。这个过程不是一蹴而就的需要反复尝试、观察日志、调整Hook点。最终当Burp Suite中成功看到明文的HTTPS请求和响应时那种成就感是无与伦比的。6. 总结与安全思考通过这次深入的实战我们可以看到SSL Pinning绕过是一场在Java层、Native层甚至系统框架层之间的攻防博弈。作为防御方应该采用多层次、动态化的证书校验策略并结合代码混淆、反调试等手段增加攻击者的分析成本。而作为安全研究人员掌握Frida这样强大的动态分析工具并深入理解SSL/TLS协议栈在移动端的实现细节是突破这些防御的关键。最后必须强调所有这些技术都应仅用于授权的安全测试、个人学习或对自己开发的应用进行安全加固。未经授权对他人的应用进行逆向、调试或攻击是非法的。技术的刀刃应当用在维护安全而非破坏之上。希望这篇长文能为你打开移动应用安全测试的一扇窗在合法合规的范围内探索更多技术的深度。