混淆与SSL Pinning双重防御下,如何通过动静结合技术实现HTTPS抓包
1. 项目概述:当混淆与SSL Pinning联手,抓包如何破局?
在移动应用安全测试和逆向分析领域,抓包是获取应用与服务器交互数据、分析业务逻辑、发现潜在漏洞的基础操作。然而,随着开发者安全意识的提升,两道“铁壁”正变得越来越常见:代码混淆与SSL证书绑定。前者让逆向分析者难以阅读和理解核心逻辑,后者则直接阻断了我们使用像Charles、Fiddler这样的中间人代理工具进行抓包的可能。当这两者结合在一个应用上时,问题就变得尤为棘手——你费尽心思绕过了混淆,定位到了关键的SSL验证代码,却发现代码被混淆得面目全非,传统的Hook或Patch方法无从下手。这正是标题所描述的困境:一个经过混淆的App,其SSL Pinning机制难以被常规方法绕过。
这篇文章,我将从一个资深移动安全研究员的角度,分享一套面对“混淆+SSL Pinning”组合拳时的系统性解决方案。这不仅仅是介绍一两个工具,而是梳理出一条从环境准备、静态分析、动态调试到最终实现稳定抓包的完整路径。无论你是安全测试人员、逆向工程师,还是对移动应用通信机制感兴趣的研究者,这套方法都能为你提供清晰的思路和可实操的步骤。我们将从理解对手开始,逐步拆解防御,最终实现可控的中间人攻击。
2. 核心防御机制拆解:混淆与SSL Pinning如何协同工作
要解决问题,必须先透彻理解问题。混淆和SSL Pinning并非独立运作,它们的结合往往会产生“1+1>2”的防御效果。
2.1 代码混淆:不仅仅是“乱码”
代码混淆的目的并非让程序无法运行,而是增加人工阅读和逆向分析的难度。常见的混淆技术包括:
- 标识符重命名:将有意义的类名、方法名、变量名改为a, b, c, aa, ab等无意义字符。这是最基础的混淆,主要影响静态分析的效率。
- 控制流扁平化:将原本清晰的if-else、switch-case、循环结构,打散成一个巨大的switch语句或通过状态机跳转,使得执行流程难以追踪。
- 字符串加密:将代码中的明文字符串(如API地址、密钥、证书哈希)在编译时加密存储,运行时动态解密。这直接保护了关键常量,包括用于SSL Pinning比对的证书指纹。
- 指令替换和垃圾代码插入:用等价的、更复杂的指令序列替换简单指令,并插入永远不执行或无关紧要的代码块,干扰反编译器和分析者的视线。
在SSL Pinning的语境下,混淆的威力被放大。原本,我们可以通过搜索“X509TrustManager”、“checkServerTrusted”、“CertificatePinner”等关键词快速定位验证逻辑。但混淆后,这些类名和方法名可能变成了a.a、b.b(),而关键的证书公钥或哈希值,可能被加密后分散在多个地方,通过复杂的解密函数在运行时还原。
2.2 SSL Pinning:信任的“白名单”
SSL Pinning的本质是放弃操作系统或浏览器的默认证书信任链,由应用程序自己决定信任哪些证书。具体实现方式主要有两种:
- 证书锁定:在App中预置服务器证书(或它的公钥)。在SSL握手时,将服务器返回的证书与预置的进行比较,完全一致才放行。
- 公钥锁定:在App中预置证书的公钥哈希(如SHA-256)。比较时只比对公钥指纹,即使证书到期续签,只要公钥不变,连接依然有效。这种方式更灵活,是目前的主流。
当中间人代理(如Charles)介入时,它会向客户端(App)出示自己生成的、由用户安装到设备信任库的CA证书。对于未做Pinning的App,它会信任系统信任库,从而接受Charles的证书。但对于做了Pinning的App,它会发现Charles证书的公钥指纹与自己预置的白名单不匹配,随即终止连接,抛出SSLHandshakeException等异常。
2.3 组合防御的挑战
混淆让定位Pinning逻辑和关键数据(证书哈希)变得困难;而强化的Pinning逻辑本身又增加了绕过的复杂度。你可能遇到的情况包括:
- 搜索常见关键词一无所获。
- 找到疑似验证方法,但调用链路因控制流混淆而支离破碎。
- 找到了存储证书哈希的变量或资源,但值是加密的,且解密函数被混淆。
- 应用采用了非标准或自定义的证书验证库,进一步增加了分析难度。
3. 解决方案总览:分层击破的策略
面对复合型防御,单一工具或方法很难奏效。我采用的是一套分层递进的策略,如下图所示(策略流程,非图表):
第一层:环境与工具准备。这是基础,确保拥有一个可控的、支持高级动态分析的环境。第二层:静态探查与信息收集。在不运行App的情况下,尽可能多地收集信息,为动态分析提供线索。第三层:动态调试与行为分析。让App运行起来,在关键节点进行干预和观察,这是破解混淆代码的核心。第四层:定制化绕过与持久化。根据分析结果,编写脚本或修改二进制文件,实现稳定、可重复的绕过。
这个流程不是线性的,而是一个循环。动态分析中获取的新信息,常常需要返回到静态分析中重新审视。接下来,我们深入每一层的具体操作。
4. 实操环境搭建与工具链选型
工欲善其事,必先利其器。一个高效的工具链能事半功倍。以下是我在实战中打磨出的组合,兼顾了Android和iOS平台。
4.1 核心抓包与代理工具
- Charles / Fiddler:经典的GUI抓包工具,配置简单,可视化好,用于初步测试和常规HTTP/HTTPS流量捕获。它们是触发SSL Pinning警报的“试金石”。
- Burp Suite:安全测试的瑞士军刀。除了抓包,其Repeater、Intruder、Decoder模块在分析混淆后的请求参数、尝试爆破时极其有用。Professional版的移动端辅助工具(如
burp-mobile-assistant)有时能简化证书安装。 - mitmproxy:命令行驱动的中间人代理,强大之处在于其可脚本化(Python)。你可以编写脚本,自动修改请求/响应,甚至模拟SSL Pinning验证,这对于自动化测试和复杂场景至关重要。
注意:确保电脑和测试手机在同一局域网,并在手机上正确安装并信任代理工具的CA证书。对于Android 7.0及以上版本,系统不再信任用户安装的CA证书,需要将CA证书移至系统证书目录,这通常需要Root权限。或者,可以修改App的网络安全配置文件(
network_security_config.xml),但这本身就需要对App进行逆向修改。
4.2 静态分析工具
- Android:
apktool:反编译APK,获取smali汇编代码、资源文件和AndroidManifest.xml。smali/baksmali是理解和修改DEX文件的基础。jadx-gui/Ghidra:将DEX或APK反编译为可读性更高的Java代码。jadx-gui速度快,适合快速浏览和搜索。Ghidra是NSA开源的逆向框架,功能强大,支持反编译和深度分析,对于混淆严重的代码,其反编译器有时能产生比jadx更优的结果,并且支持脚本自动化。Bytecode Viewer:集成了多种反编译器(CFR, FernFlower, Procyon),可以对比查看不同反编译器的输出,有助于理解混淆代码的真实意图。
- iOS:
otool/MachOView:查看Mach-O文件头、加载命令和段信息。Hopper Disassembler/IDA Pro:静态反汇编和反编译利器。Hopper性价比高,IDA是行业标准,支持高级脚本和插件,对于分析经过LLVM混淆的代码非常有效。class-dump:用于dump未加密或已脱壳的Objective-C二进制文件的头文件,快速获取类和方法信息。
4.3 动态分析与调试工具
- Frida:这是破解SSL Pinning的核武器。它是一个动态插桩框架,通过注入JavaScript代码到目标进程,可以实时Hook任何函数、修改内存、调用原生方法。完全不受代码混淆的影响,因为你Hook的是函数在内存中的地址或符号(如果符号未剥离)。
objection是基于Frida的命令行工具,可以快速执行诸如android sslpinning disable之类的命令,但对于自定义或深度混淆的Pinning,仍需手写Frida脚本。 - Xposed/LSPosed:Android平台另一个强大的Hook框架,需要在系统层面安装模块。它更稳定,但需要重启,且针对单个App的模块编写和调试流程比Frida稍复杂。在Frida不稳定的某些场景下(如某些加固环境),Xposed模块是备选方案。
- LLDB/GDB:标准的原生代码调试器。对于iOS或Android的Native层(C/C++)实现的SSL Pinning,可能需要使用LLDB进行动态调试和断点。
- adb logcat:Android设备日志输出。配置正确的过滤标签(如
SSL),可以捕获App抛出的SSL相关异常信息,这是定位问题起点的关键。
4.4 辅助工具
justtrustme/SSLUnpinning:这是基于Xposed或Frida的“一键禁用SSL Pinning”的模块/脚本。它们对很多通用库(如OkHttp, Retrofit, Apache HttpClient)有效,但对于自定义或深度混淆的Pinning往往失效。因此,它们更适合作为初步测试工具,如果失效,就说明我们遇到了“硬骨头”,需要下面的手动方法。frida-server:运行在目标设备上的Frida服务端,必须与电脑端的Frida版本匹配。模拟器/真机:推荐使用可Root的Android模拟器(如Android Studio AVD with Root)或越狱的iOS设备进行测试。生产环境测试需使用真机,并注意法律风险。
5. 静态分析:在混沌中寻找秩序
当justtrustme失效后,我们就需要手动出击。第一步是从静态的二进制文件中寻找蛛丝马迹。
5.1 初步侦察与关键词搜索
即使代码被混淆,一些关键的系统API和字符串常量(如果未加密或加密较弱)仍有迹可循。
- 使用
jadx-gui打开APK或IPA。 - 进行全局搜索:
- 类/方法名:搜索
X509TrustManager,checkServerTrusted,CertificatePinner,SSLSocketFactory,TrustManager。混淆后,类名可能变,但方法签名中的参数类型(如java.security.cert.X509Certificate[])和异常类型(CertificateException)相对难以完全改变。可以尝试搜索CertificateException。 - 字符串:搜索
pin,ssl,cert,publickey,sha256,sha1。开发者可能在日志、配置或资源文件中留下线索。特别注意一些第三方库的标识字符串,如OkHttp。 - 网络配置文件:在资源文件中查找
network_security_config.xml,这里可能定义了证书Pinning的原始配置。
- 类/方法名:搜索
5.2 分析证书/公钥存储位置
找到的证书或哈希值可能存储在:
- Java/Kotlin代码中:以字节数组或字符串形式硬编码。
- 资源文件:如
.cer,.pem文件,或存储在raw、assets目录下的文本文件。 - Native层:通过JNI调用,在C/C++代码中存储和验证,这大大增加了分析难度。
- 从服务器动态获取:首次启动或定期从服务器拉取Pinning配置,这需要先捕获一次未加密的通信(可能通过旧版本App或其它漏洞)。
在jadx中,如果你看到一个长的、看似随机的字符串或字节数组,被传入一个名称可疑的方法(如a.a(String str)),那很可能就是加密后的证书数据。你需要找到调用它的地方,并分析那个解密方法。
5.3 理解控制流与定位入口点
对于控制流扁平化,不要试图在反编译的Java代码中完全理清。我们的目标是找到验证函数的入口点。可以关注:
- 网络库的初始化代码:寻找
OkHttpClient.Builder()、Retrofit.Builder()等调用链,通常Pinning的配置会在这里添加。 - 证书验证回调:查找实现了
X509TrustManager接口的类,重写其checkServerTrusted方法。即使类名是a,只要它实现了这个接口,就是我们的目标。 - 使用Ghidra进行辅助分析:将关键的DEX或SO文件导入Ghidra,利用其数据流分析和交叉引用功能,追踪证书数据的来源和使用位置,有时能发现反编译器遗漏的线索。
6. 动态分析与Frida实战:穿透混淆的利刃
静态分析给我们提供了可能的目标和线索,但真正的决战在动态运行时。Frida的强大在于它能无视混淆,直接与内存中的函数对话。
6.1 基础Frida脚本Hook
假设我们通过静态分析,怀疑一个名为com.example.obfuscated.a的类中的b方法负责证书验证。
// ssl_bypass.js Java.perform(function () { var targetClass = Java.use("com.example.obfuscated.a"); targetClass.b.overload('[Ljava.security.cert.X509Certificate;', 'java.lang.String').implementation = function(certs, authType) { console.log("[*] SSL Pinning check intercepted!"); console.log(" Auth Type: " + authType); for (var i = 0; i < certs.length; i++) { console.log(" Cert[" + i + "]: " + certs[i].getSubjectDN()); } // 关键:什么都不做,直接返回,相当于信任所有证书 // 或者,可以在这里打印出服务器证书的SHA256,与我们已知的比对 // var sha256 = computeSha256(certs[0].getEncoded()); // console.log(" Server Cert SHA256: " + sha256); }; });使用命令frida -U -f com.example.app -l ssl_bypass.js --no-pause注入脚本。如果成功Hook并看到日志输出,说明找到了关键函数。通过打印出的证书信息,我们可以确认验证逻辑。
6.2 处理未知方法与枚举
如果静态分析毫无头绪,可以采用“盲Hook”策略,枚举所有可能的方法。
// enumerate_ssl.js Java.perform(function () { Java.enumerateLoadedClasses({ onMatch: function (className) { if (className.toLowerCase().indexOf("ssl") !== -1 || className.toLowerCase().indexOf("cert") !== -1 || className.toLowerCase().indexOf("trust") !== -1 || className.toLowerCase().indexOf("pin") !== -1) { console.log("[*] Found potential class: " + className); try { var clazz = Java.use(className); var methods = clazz.class.getDeclaredMethods(); for (var i in methods) { console.log(" Method: " + methods[i].getName()); } } catch (e) { // 忽略无法使用的类 } } }, onComplete: function () { console.log("[*] Class enumeration complete."); } }); });这个脚本会列出所有类名中包含特定关键词的类及其方法,帮助你缩小目标范围。
6.3 Hook底层SSL库函数
对于Native层实现的Pinning,或者Java层最终调用到Native库(如OpenSSL)的情况,需要Hook C函数。
// hook_openssl.js Interceptor.attach(Module.findExportByName("libssl.so", "SSL_CTX_set_cert_verify_callback"), { onEnter: function (args) { console.log("[*] SSL_CTX_set_cert_verify_callback called!"); // 可以在这里替换回调函数,使其始终返回验证成功 } }); Interceptor.attach(Module.findExportByName("libcrypto.so", "X509_verify_cert"), { onEnter: function (args) { console.log("[*] X509_verify_cert called!"); }, onLeave: function (retval) { // 强制返回1(验证成功) retval.replace(1); } });这需要你对OpenSSL API有一定的了解。使用Module.enumerateImports()和Module.enumerateExports()可以探索目标模块的所有函数。
6.4 绕过自定义验证逻辑
有时,开发者会自己实现证书比对。我们的策略是找到比对结果所在,并强制让其返回“真”。
- 定位比对函数:在动态调试时,在可能进行比对的函数(如那些接收两个字符串或字节数组的函数)入口打印参数。
- Hook并修改返回值:一旦找到,确保其返回值始终为
true或0(表示成功)。
var resultClass = Java.use("com.example.validator.d"); resultClass.a.overload('[B', '[B').implementation = function (key1, key2) { console.log("[*] Custom comparator called. Forcing true."); return true; // 或返回 0,取决于函数定义 };7. 高级技巧与问题排查实录
在实际操作中,你会遇到各种“坑”。以下是一些常见问题及我的解决心得。
7.1 反调试与反Hook检测
一些加固或高安全级别的App会检测Frida、Xposed等工具的存在。
- 检测Frida:检查
/proc/self/maps中是否包含frida-agent字符串,检查端口(默认27042)是否开放。 - 绕过方法:
- 重命名Frida Server:将
frida-server文件改名为其他名字启动。 - 修改端口:使用
frida-server -l 0.0.0.0:8080指定非默认端口。 - 使用定制版或隐藏技术:如
frida-server的某些修改版,或使用ptrace附加等更底层的方法。 - Patch检测代码:使用Frida Hook掉这些检测函数,使其永远返回“未检测到”。
- 重命名Frida Server:将
7.2 证书哈希动态获取或更新
如果证书哈希是从服务器动态获取的,你需要:
- 首先在未开启SSL Pinning的环境下(如旧版App、通过其他漏洞)捕获到这次通信。
- 或者,Hook网络请求函数,在App获取到Pinning配置后,在内存中将其修改为你可控的哈希值(即代理工具的证书哈希)。
7.3 双向认证与客户端证书
有些App不仅验证服务器,还要求客户端提供证书。这通常表现为在SSL握手时,服务器向客户端索要证书。
- 现象:即使绕过服务器证书Pinning,连接依然失败,抓包工具显示
Alert: certificate_required。 - 解决方案:你需要从App中提取客户端证书(通常是一个
.p12或.bks文件及其密码)。这通常需要逆向资源文件或解密相关代码。提取后,在Burp Suite或mitmproxy中配置客户端证书,才能完成完整握手。
7.4 Frida脚本注入失败或不稳定
Java.perform错误:确保脚本在Java.perform函数内执行,并且目标App的Java虚拟机已完全启动。使用-f参数在App启动时注入,或使用spawn模式。- 应用崩溃:可能是Hook了不正确的函数或参数类型不匹配。仔细核对方法签名(overload)。使用
try-catch包裹可能不稳定的Hook操作。 - 使用
setImmediate:对于需要在App生命周期早期执行的Hook,可以将代码包裹在setImmediate中,确保Java环境就绪。
7.5 常见问题速查表
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
Charles/Burp显示SSL handshake failed | 1. 未安装/信任代理CA证书 2. SSL Pinning生效 | 1. 确认证书已正确安装到系统信任库(Android 7+需Root或修改App)。 2. 使用 justtrustme测试,若无效则进入手动分析流程。 |
| Frida连接被拒绝 | 1.frida-server未运行2. 设备未USB调试/网络连接 3. 端口被占用或防火墙阻止 | 1.adb shell检查进程,ps | grep frida。2. adb devices确认设备连接。3. 尝试 frida -U(USB)或frida -H IP:port(网络)。 |
| 注入Frida后App闪退 | 1. App有反调试/反Frida检测 2. Frida脚本Hook了错误函数导致崩溃 | 1. 检查logcat崩溃日志,寻找检测线索。尝试隐藏Frida。 2. 注释掉部分Hook代码,二分法定位问题Hook点。 |
| 找到验证函数但Hook后仍失败 | 1. 验证逻辑有多处 2. Native层还有验证 3. 比对值被加密 | 1. 扩大搜索和Hook范围。 2. 使用Frida Hook libssl.so等Native库函数。3. 动态跟踪解密函数,在解密后获取明文哈希。 |
| 抓包成功但请求体乱码/加密 | 应用层额外加密 | 1. 分析请求头,寻找加密算法线索(如encrypt、cipher)。2. 搜索常量如 AES、DES、RSA关键词。3. Hook应用层的加密函数,获取密钥或直接输出解密后明文。 |
8. 案例实战:一个虚构混淆App的完整绕过流程
假设我们有一个目标App:com.hardapp.obfuscated,使用Charles抓包失败。
第一步:初步测试与信息收集
- 安装App,配置Charles代理,确认HTTPS请求失败,提示SSL错误。
- 在已Root的设备上安装
JustTrustMeXposed模块,重启后测试,抓包依然失败。确认是“硬骨头”。
第二步:静态分析寻找线索
- 使用
apktool d解包APK。 - 使用
jadx-gui打开APK,全局搜索CertificateException。发现一个名为c.f.a.g的类,其a方法抛出了此异常。 - 查看该方法的调用者,追溯到
com.hardapp.network.o类的b方法,其中调用了c.f.a.g.a(byte[] bArr, byte[] bArr2)。 - 分析
com.hardapp.network.o,发现它在初始化一个网络客户端。两个byte[]参数,一个来自服务器证书,另一个来自一个名为e的静态字段。e字段的值由一个名为d的类的c方法返回。 - 查看
d.c(),发现它从一个资源文件raw/encrypted_pin读取字节,然后调用b.a(byte[])进行解密。b类看起来是AES解密。
第三步:动态验证与Hook
- 编写Frida脚本,Hook关键点:
Java.perform(function(){ // Hook 解密函数,获取明文PIN var decryptClass = Java.use("com.hardapp.obfuscated.b"); decryptClass.a.overload('[B').implementation = function(encrypted){ var result = this.a(encrypted); // 调用原方法 console.log("[*] Decrypted PIN (hex): " + bytesToHex(result)); send(result); // 可以发送到Python端保存 return result; }; // Hook 比较函数,使其直接返回true var compareClass = Java.use("c.f.a.g"); compareClass.a.overload('[B', '[B').implementation = function(pin1, pin2){ console.log("[*] Bypassing certificate comparison."); return true; // 强制验证通过 }; }); function bytesToHex(bytes) { ... } // 辅助函数 - 运行脚本,启动App。在日志中看到解密的SHA256哈希值(例如
A1B2C3...),并确认比较函数被绕过。 - 此时,Charles成功捕获到HTTPS流量。
第四步:持久化方案(可选)如果希望长期有效,可以修改APK:
- 在
smali代码中找到c/f/a/g.smali中的a方法。 - 将其比较逻辑删除,直接让它
return-void或者在最后设置一个成功的返回条件。 - 使用
apktool b重新打包,签名并安装。
通过这个流程,我们完成了从探测、分析、动态破解到静态修补的全过程。核心思想是动静结合:静态分析提供地图,动态调试提供实时导航,而Frida则是我们穿越混淆迷雾的越野车。记住,每个App都是独特的,但解决问题的思路和工具链是相通的。保持耐心,细心观察,你总能找到那条通往流量的路。
