1. 为什么SSL Pinning是移动端测试的第一道坎
做过移动端安全测试的人都有一个共识:抓包这件事,在普通应用上跟在做了证书校验的应用上,完全是两个世界。普通应用你配好代理,装好根证书,流量就乖乖躺在抓包工具里了。但一旦目标应用开启了SSL Pinning(证书绑定),你的抓包工具就会瞬间变成摆设——所有请求要么直接失败,要么返回一堆乱码,连接被应用主动掐断。
SSL Pinning的本质,是应用在建立TLS连接时,不信任系统证书库,而是只信任自己内置的那张证书或公钥。这就好比你进小区,物业说“只认业主自己发的门禁卡,不认派出所开的临时通行证”。系统根证书就是那张“临时通行证”,应用不认,所以中间人代理的证书自然被拒之门外。
在移动端测试的日常工作中,绕过SSL Pinning是绕不开的基本功。不管是做接口安全评估、数据泄露排查,还是业务逻辑漏洞挖掘,第一步永远是让流量能正常被看到。看不到流量,后面的一切都无从谈起。而Frida作为动态插桩框架,配合一键化的Hook脚本,是目前公认最高效、最通用的绕过方案之一。它不需要你反编译重打包,不需要修改APK签名,直接在运行时把校验逻辑“劫持”掉,应用自己都察觉不到。
这篇文章面向的是有一定移动端测试基础、但还没系统掌握Frida绕过SSL Pinning的从业者。我会从原理讲到实操,从脚本编写讲到常见翻车场景,把“第二板斧”这件事彻底讲透。你不需要是逆向高手,只要跟着思路走,就能把这套方法用到自己的测试项目里。
2. Frida绕过SSL Pinning的底层逻辑拆解
2.1 证书校验发生在哪个环节
要绕过SSL Pinning,首先得知道它在哪里“卡”你。在Android平台上,TLS连接的证书校验通常发生在两个层面:一是Java层的TrustManager和HostnameVerifier,二是Native层的SSL_CTX_set_verify或X509_verify_cert等OpenSSL/BoringSSL函数。大部分应用只做Java层校验,少数安全要求高的会下沉到Native层。
Java层的校验逻辑通常长这样:应用自定义一个X509TrustManager,在checkServerTrusted方法里拿到服务器证书链,然后跟内置的证书或公钥做比对。如果比对失败,直接抛CertificateException,连接终止。Frida要做的,就是Hook住这个方法,让它永远不抛异常,或者直接替换成系统默认的TrustManager。
Native层的校验则更底层,通常是在JNI里调用OpenSSL的验证函数。绕过方式类似,但需要Hook的是Native函数地址,对脚本编写的要求更高一些。不过好消息是,绝大多数应用——包括很多金融类App——都只在Java层做校验,因为Native层实现成本高、兼容性差。
2.2 Frida的Hook时机为什么关键
Frida的Hook不是越早越好,也不是越晚越好,时机不对,脚本跑了等于没跑。举个例子:如果你在应用启动后才注入脚本,而此时应用已经完成了证书校验、建立了连接,那你Hook得再完美也没用——连接已经建好了,后续流量走的是已建立的通道。
正确的做法是在应用进程启动的瞬间就完成注入。对于Android应用,通常用frida -U -f 包名 -l script.js --no-pause这种方式,让Frida在应用主进程fork出来的时候就挂上去。这样当应用执行到证书校验代码时,你的Hook已经就位了。
但这里有个坑:有些应用会把证书校验放在一个独立的进程里,或者用多进程架构。你注入到主进程,校验却发生在另一个进程,脚本自然不生效。遇到这种情况,需要用frida-ps -U先看清楚目标应用有哪些进程,然后针对性地注入。
2.3 一键脚本的核心Hook点
所谓“一键Hook脚本”,本质上就是把多个Hook点打包到一个脚本里,运行时自动遍历所有已知的校验入口,逐个尝试Hook。一个成熟的SSL Pinning绕过脚本通常覆盖以下Hook点:
- Java层TrustManager:Hook
X509TrustManager的所有实现类,替换checkServerTrusted方法为空实现。 - Java层HostnameVerifier:Hook
HostnameVerifier.verify,强制返回true。 - OkHttp的CertificatePinner:很多应用用OkHttp做网络库,它有自己的
CertificatePinner.check方法,需要单独Hook。 - Native层SSL验证:Hook
SSL_CTX_set_verify和X509_verify_cert等函数,让它们直接返回成功。 - WebView的SSL错误:Hook
WebViewClient.onReceivedSslError,忽略SSL错误。
脚本的逻辑是“宁可多Hook,不可漏Hook”。因为不同应用用的网络库不同,校验方式也不同,把所有可能的入口都堵上,才能保证一次成功。
3. 手把手搭建Frida环境与脚本注入
3.1 服务端与客户端的版本对齐
Frida的环境搭建,最容易被忽略的就是版本对齐问题。Frida分为两部分:运行在目标设备上的frida-server,和运行在测试机上的frida命令行工具。这两者的版本必须完全一致,否则会出现各种莫名其妙的连接失败。
我见过太多人在这上面浪费时间:frida-ps -U报错“unable to connect to remote frida-server”,折腾半天以为是设备问题,结果一查版本,一个16.0.8,一个16.1.2。所以第一步永远是确认版本:
# 查看本地frida版本 frida --version # 查看设备上frida-server版本 adb shell /data/local/tmp/frida-server --version如果不一致,去Frida的GitHub Release页面下载对应版本的frida-server,推送到设备上替换。注意要选对架构:arm64、arm、x86_64,选错了跑不起来。
3.2 frida-server的部署与权限处理
部署frida-server的步骤本身不复杂,但权限问题经常卡人。标准流程是:
# 推送frida-server到设备 adb push frida-server /data/local/tmp/ # 赋予执行权限 adb shell chmod 755 /data/local/tmp/frida-server # 以root身份启动 adb shell su -c /data/local/tmp/frida-server &这里的关键是su -c。如果你的测试设备没有root,frida-server无法以足够权限运行,注入就会失败。对于没有root的设备,可以考虑用frida-gadget方案,把gadget注入到APK里重打包,但这会改变APK签名,某些应用会检测签名变化。所以做安全测试,一台root过的测试机是基本配置。
另外,有些设备上/data/local/tmp目录挂载了noexec选项,导致frida-server无法执行。遇到这种情况,可以换到/dev目录下运行,或者重新挂载/data分区。
3.3 一键脚本的编写与参数调优
一键脚本的核心结构其实不复杂,但细节决定成败。下面是一个经过实战检验的脚本骨架:
Java.perform(function() { // Hook TrustManager var TrustManager = Java.use('javax.net.ssl.X509TrustManager'); var SSLContext = Java.use('javax.net.ssl.SSLContext'); var TrustManagerImpl = Java.registerClass({ name: 'com.test.TrustManagerImpl', implements: [TrustManager], methods: { checkClientTrusted: function(chain, authType) {}, checkServerTrusted: function(chain, authType) {}, getAcceptedIssuers: function() { return []; } } }); // 替换SSLContext的TrustManager var SSLContext_init = SSLContext.init.overload( '[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom' ); SSLContext_init.implementation = function(keyManager, trustManager, secureRandom) { SSLContext_init.call(this, keyManager, [TrustManagerImpl.$new()], secureRandom); }; // Hook OkHttp CertificatePinner try { var CertificatePinner = Java.use('okhttp3.CertificatePinner'); CertificatePinner.check.overload('java.lang.String', 'java.util.List').implementation = function(host, peerCertificates) { console.log('OkHttp CertificatePinner bypassed for: ' + host); return; }; } catch(e) { console.log('OkHttp CertificatePinner not found, skipping'); } });这个脚本的关键点在于:SSLContext.init的Hook必须在应用初始化SSLContext之前完成。如果应用已经创建了SSLContext,你再替换TrustManager就不起作用了。所以脚本注入的时机要尽可能早。
参数调优方面,--no-pause参数很重要。默认情况下,Frida在注入后会暂停应用,等你输入%resume才继续。加上--no-pause,应用会直接跑起来,适合自动化测试场景。另外,如果应用有反调试检测,可能需要加上--runtime=v8来切换JS引擎,或者用frida-server的--disable-debugger选项。
4. 实战中那些脚本跑不通的场景
4.1 应用启动即闪退的排查链路
脚本注入后应用直接闪退,是最常见也最让人头疼的问题。排查这个问题的完整链路应该是这样的:
第一步,先确认是不是Frida本身被检测了。很多应用会检测frida-server的进程名、端口号(默认27042),或者扫描内存中的Frida特征字符串。你可以先把frida-server改名,换一个非默认端口启动:
adb shell su -c /data/local/tmp/fs -l 0.0.0.0:8888 &然后用frida -H 127.0.0.1:8888连接。如果改名换端口后不闪退了,说明是Frida特征检测。
第二步,如果改名换端口还是闪退,可能是脚本本身有问题。把脚本精简到只Hook一个最简单的点,比如只HookTrustManager,看是否还闪退。如果精简后正常,说明是某个Hook点触发了应用的自我保护逻辑。
第三步,如果精简后依然闪退,可能是注入时机太早,应用还没初始化完就被Hook了。试试延迟注入,用setTimeout把Hook逻辑延后几百毫秒执行。
4.2 多进程架构下的注入遗漏
有些应用采用多进程架构,主进程只负责UI,网络请求在另一个进程里完成。你注入到主进程,Hook脚本跑得好好的,但流量就是抓不到。这时候需要用frida-ps -U列出所有进程,找到那个负责网络的进程,单独注入。
更麻烦的是,有些应用的进程是动态创建的,启动时不存在,运行到某个阶段才fork出来。这种情况下,可以用Frida的ChildAdded事件来监听新进程:
// 在宿主脚本中监听子进程 Process.enumerateThreads(); // 或者用frida的命令行参数 --enable-child-gating不过这种方案对脚本编写要求较高,实际测试中更常用的做法是:先把应用跑起来,触发网络请求,然后用frida-ps -U找到当时活跃的进程,再注入脚本。
4.3 Native层校验的识别与处理
当Java层的Hook全部失效时,就要考虑Native层校验了。识别方法很简单:用frida-trace跟踪SSL_CTX_set_verify和X509_verify_cert的调用:
frida-trace -U -i "SSL_CTX_set_verify" -i "X509_verify_cert" 目标进程如果看到这些函数被调用,说明应用在Native层做了校验。绕过方式是用Interceptor.attach直接替换函数实现:
var SSL_CTX_set_verify = Module.findExportByName("libssl.so", "SSL_CTX_set_verify"); if (SSL_CTX_set_verify) { Interceptor.replace(SSL_CTX_set_verify, new NativeCallback(function(ctx, mode, callback) { return; // 不做任何验证 }, 'void', ['pointer', 'int', 'pointer'])); }需要注意的是,不同应用链接的SSL库可能不同,有的是libssl.so,有的是libboringssl.so,需要根据实际情况调整模块名。
5. 绕过之后的验证与流量分析衔接
5.1 如何确认绕过真的生效了
脚本跑完不报错,不代表绕过成功了。必须验证。最直接的验证方式是看抓包工具里有没有流量。如果之前抓不到,现在能抓到明文请求,说明绕过生效。
但有时候流量能抓到,却是乱码或者不完整的。这通常是因为应用用了自定义的加密协议,或者对请求体做了额外加密。SSL Pinning绕过只解决了传输层的问题,应用层的加密需要另外处理。
另一个验证方式是看Frida的日志输出。脚本里每个Hook点都加了console.log,如果看到“OkHttp CertificatePinner bypassed”之类的输出,说明对应的Hook被触发了。如果某个Hook点始终没有输出,说明应用没走那条路径,或者Hook没生效。
5.2 抓包工具侧的配置要点
Frida绕过SSL Pinning之后,抓包工具这边也要配合好。以常用的抓包工具为例,需要确保:
- 代理设置正确,设备上的代理指向测试机IP和抓包工具端口。
- 抓包工具的根证书已经安装到系统证书库(Android 7.0以上需要装到系统级,普通用户证书不被信任)。
- 如果应用有证书透明度(Certificate Transparency)检测,可能还需要在抓包工具里配置CT日志。
这里有个细节:Android 7.0之后,应用默认不信任用户安装的证书。所以即使你绕过了SSL Pinning,如果抓包工具的证书没装到系统级,流量依然抓不到。把证书装到系统级需要root,把证书文件放到/system/etc/security/cacerts/目录下,并设置正确的权限。
5.3 从抓到流量到发现问题的思路
流量抓到之后,真正的测试才刚开始。我的习惯是先看请求结构:URL路径、参数名、请求头,这些往往能暴露接口的设计逻辑。然后看响应:有没有敏感信息泄露,比如用户ID、手机号、token。
重点关注几类问题:一是越权,把请求里的用户ID改成别人的,看能不能拿到数据;二是注入,在参数里塞特殊字符,看后端有没有过滤;三是逻辑漏洞,比如修改价格、跳过验证步骤。SSL Pinning绕过只是打开了门,门后面的东西才是测试的目标。
6. 脚本维护与对抗升级的经验之谈
6.1 脚本通用性与针对性的平衡
一键脚本的诱惑在于“一次编写,到处运行”,但现实是,每个应用都有自己的脾气。我的做法是维护一个基础脚本库,包含所有通用的Hook点,然后针对特定应用写一个“补丁脚本”,只包含该应用特有的Hook逻辑。测试时先跑基础脚本,如果不生效,再叠加补丁脚本。
这样既保证了通用性,又保留了针对性。基础脚本库定期更新,把新遇到的校验方式加进去。补丁脚本用完就归档,下次遇到类似应用可以翻出来参考。
6.2 应用更新后的快速适配
应用版本更新后,SSL Pinning的绕过脚本经常失效。原因可能是混淆规则变了,类名方法名变了,或者校验逻辑重写了。快速适配的方法是:先用frida-trace重新跟踪一遍,看新的校验入口在哪里,然后调整脚本里的类名和方法名。
如果应用用了加固,类名可能被完全混淆成a.a.a这种形式,硬编码类名就不现实了。这时候可以用Frida的Java.enumerateLoadedClasses动态遍历所有类,找到实现了X509TrustManager接口的类,然后Hook它。这种方式不依赖具体类名,适应性更强。
6.3 对抗检测的几种实用手段
应用的反调试检测手段在升级,我们的对抗手段也要跟上。除了前面提到的改名换端口,还有几个实用技巧:
- 隐藏Frida线程:Frida会在目标进程里创建名为
gum-js-loop、gmain等线程,有些应用会扫描这些线程名。可以用Frida的--disable-thread-name选项,或者用脚本修改线程名。 - 绕过端口检测:不用默认的27042端口,改用随机端口。
- 内存特征抹除:有些应用会扫描内存中的
frida字符串,可以用Frida的Memory.scan反向操作,把特征字符串改掉。 - 使用定制版frida-server:自己编译frida-server,修改默认特征,这是最彻底的方式,但门槛也最高。
这些手段不是每个项目都需要,根据目标应用的检测强度来选择。大部分应用只做基础检测,改名换端口就够了。遇到检测特别严的,再逐级加码。
6.4 法律与合规的边界意识
最后必须强调一点:所有这些技术手段,只能在合法授权的测试环境中使用。对自己开发的应用做测试,或者受客户委托做安全评估,都是正当的。但未经授权对他人应用进行Hook、抓包、篡改,是明确违规甚至违法的。
在实际项目中,我通常会保留完整的测试授权记录,明确测试范围和时间窗口。测试结束后,及时清理设备上的Frida相关文件和脚本。这既是对客户负责,也是对自己负责。技术本身没有对错,关键在于使用它的人是否守住了边界。
这套Frida加一键Hook脚本的方案,我在多个移动端测试项目中反复验证过,对于大多数没有重度加固的应用,成功率在九成以上。剩下的那一成,要么是Native层校验特别复杂,要么是应用有定制化的安全SDK,需要更深入的逆向分析。但作为“第二板斧”,它已经能解决绝大部分SSL Pinning绕过需求了。真正重要的是理解原理、掌握排查思路,这样遇到新情况时才能随机应变,而不是死记脚本。