小程序HTTPS抓包实战:绕过SSL Pinning与mitmproxy调试

发布时间:2026/10/29 11:18:12
小程序HTTPS抓包实战:绕过SSL Pinning与mitmproxy调试

简介:这份资源面向移动安全研究、逆向工程与接口调试方向的学习者,聚焦支付宝小程序在代理抓包时遇到的 SSL 验证拦截问题,提供一套可参考的绕过思路与配套脚本,适合已具备一定 Frida 与抓包工具使用基础的中高级读者。压缩包共 3 个文件,以 Python 脚本、JavaScript 脚本和 Markdown 说明文档为主,体量约 2KB,其中 Python 文件承担主流程调用,JavaScript 脚本用于注入与钩子逻辑,说明文档则交代使用前提与注意事项,结构精简、便于快速定位关键代码。目前已有 3029 人学习下载,说明该方向具备一定关注度。读者可从中获得针对小程序 SSL 校验的 Hook 切入点、脚本组织方式与调试排错思路,并借助说明文档理解运行环境与依赖要求,从而在自己的测试环境中复现代理抓包流程,加深对客户端证书校验机制的理解。

1. 抓包这件事,先想清楚你要解决的是哪一层问题

移动端调试最让人头疼的场景,往往不是业务逻辑写错了,而是请求发出去之后你根本看不见它长什么样。接口返回 401、签名校验失败、参数被中间层改过,这些在浏览器里 F12 一开就能定位的问题,一旦搬到小程序容器里就变成了黑匣子。很多人第一反应是找现成的抓包工具,装上证书、配好端口,结果发现 HTTPS 流量全是红叹号,或者干脆连请求都发不出去。这时候真正卡住你的不是工具本身,而是目标客户端对证书链做了额外校验——也就是常说的 SSL Pinning(证书锁定)。

这份资源围绕的就是这个具体问题:在移动端调试场景下,如何让一个做了证书校验的小程序客户端愿意信任你本地的抓包代理,从而把加密流量还原成可读的请求响应。它适合两类人:一类是正在做小程序接口联调、需要看清真实请求体的开发者;另一类是接手了别人项目、想搞清楚某个接口到底传了什么参数再动手改代码的维护者。需要提前说明的是,这类操作只应该在你拥有合法授权的测试环境或自己的项目上进行,生产环境和他人系统不在讨论范围内。

2. 技术底座拆解:代理抓包链路是怎么串起来的

2.1 从客户端到代理服务器的一次完整往返

要理解后面所有操作,得先把一条 HTTPS 请求在抓包场景下的路径画清楚。正常情况下,客户端直接和业务服务器建立 TLS 连接;开启代理后,客户端先连到代理服务器,代理再和真实服务器握手,中间人角色由此成立。问题在于,客户端如果内置了证书校验逻辑,它会在 TLS 握手阶段检查服务器证书的指纹或公钥是否匹配预期值,一旦发现是代理签发的证书,握手直接中断。

这条链路可以拆成四个环节:DNS 解析、TCP 连接建立、TLS 握手、HTTP 报文收发。抓包工具通常工作在第三和第四环节之间,它需要向客户端出示一张由本地根证书签发的站点证书。客户端是否接受这张证书,取决于它的信任策略。系统级别的信任库可以通过安装根证书来扩展,但应用级别的校验逻辑绕不过去,这就是为什么有些客户端装了证书依然抓不到包。

常见的代理工具有 Charles、Fiddler、mitmproxy 这几类,它们在 TLS 中间人这块的实现思路基本一致:动态生成站点证书、用本地 CA 签名、把 CA 证书交给客户端信任。差异主要在证书管理方式和脚本扩展能力上。mitmproxy 的优势是可以用 Python 写拦截脚本,对请求做实时改写,这在调试签名逻辑时特别有用。

2.2 证书锁定到底锁的是什么

很多人把 SSL Pinning 理解成一个笼统的“防抓包开关”,实际上它锁定的对象有好几种可能。第一种是锁定证书的公钥(public key pinning),客户端预置了服务器公钥的哈希值,握手时比对;第二种是锁定整张证书的指纹;第三种是锁定 CA 链中的某一级签发者。不同锁定方式对应的绕过难度和操作手法完全不同。

判断目标用的是哪种方式,最直接的办法是看握手失败时的报错信息。如果客户端在代理证书出示后立刻断开,且日志里出现 certificate verify failed 之类的字样,大概率是公钥或证书指纹校验。如果连接能建立但请求被拒,那可能是应用层做了额外校验,比如在请求头里带了设备指纹。这一步的判断很关键,方向错了后面全是白费功夫。

提示:动手之前先确认目标客户端是否开启了双向认证(mTLS)。如果服务端要求客户端出示证书,那么仅靠代理端替换证书是不够的,还需要拿到客户端证书私钥,这种情况通常出现在金融类或企业内部应用中,普通小程序较少见。

2.3 代理环境的搭建步骤

先把基础环境跑通,再谈绕过校验。以 mitmproxy 为例,安装和启动的命令很直接:

# 安装 mitmproxy,建议用独立虚拟环境避免污染全局 python3 -m venv mitm-env source mitm-env/bin/activate pip install mitmproxy # 启动代理,默认监听 8080 端口,Web 界面在 8081 mitmproxy --listen-port 8080 --web-port 8081

启动后需要把 mitmproxy 的根证书导出并安装到目标设备的系统信任库中。访问http://mitm.it可以下载对应平台的证书文件,Android 设备需要把证书放到系统证书目录(需要 root 权限),iOS 设备则在“设置-通用-关于本机-证书信任设置”里手动开启完全信任。

# 导出 CA 证书,便于手动分发 mitmdump --set confdir=~/.mitmproxy # 证书文件位于 ~/.mitmproxy/mitmproxy-ca-cert.pem

参数说明:--listen-port指定代理监听端口,客户端 WiFi 设置里的代理地址要填这台机器的 IP 加这个端口;--web-port是 Web 控制台端口,方便在浏览器里查看流量;confdir指定配置目录,证书和日志都放在这里。如果客户端和代理机器不在同一网段,还需要确认防火墙放行了对应端口。

2.4 用脚本拦截并观察握手细节

光有代理还不够,得能看到握手阶段到底发生了什么。mitmproxy 支持用 Python 脚本挂载事件钩子,在 TLS 握手前后打印关键信息:

from mitmproxy import tls, http def tls_clienthello(data: tls.ClientHelloData): # 打印客户端发起的 SNI,判断它想连哪个域名 print(f"[TLS] SNI: {data.client_hello.sni}") def tls_start_client(data: tls.TlsData): # 握手完成后打印协商结果,确认是否走到了代理证书 print(f"[TLS] cipher: {data.conn.cipher}") def request(flow: http.HTTPFlow): # 请求到达代理时打印方法和路径,确认流量是否被完整捕获 print(f"[HTTP] {flow.request.method} {flow.request.pretty_url}")

这段脚本挂在tls_clienthello上可以在客户端发起握手时就看到它请求的域名,挂在tls_start_client上能看到协商使用的加密套件。如果客户端在校验失败后直接断开,你会看到 SNI 打印出来但后续请求钩子没有触发,这就说明问题卡在握手阶段而非应用层。把脚本保存为observe.py,启动时用mitmproxy -s observe.py加载即可。

3. 核心模块拆解:证书校验的绕过思路与实现

3.1 定位校验逻辑所在的层级

绕过证书校验的第一步不是改代码,而是搞清楚校验发生在哪一层。移动端应用的证书校验可能出现在三个位置:系统网络库(如 OkHttp、NSURLSession)、应用自研的网络模块、以及跨平台框架的桥接层。小程序容器通常有自己的网络请求实现,校验逻辑可能封装在容器 SDK 内部。

判断方法有两种。第一种是静态分析:把安装包解压,用反编译工具查看网络相关类的方法调用链,搜索CertificatePinner、checkServerTrusted、TrustManager这类关键词。第二种是动态观察:在代理端看握手失败的时间点,如果是在 ClientHello 之后立刻断开,说明校验在 TLS 层;如果连接建立后请求才被拒,说明校验在应用层。

# 解压安装包,查看是否有明显的证书锁定配置 unzip -o target.apk -d target_extracted # 搜索字符串常量,定位可能的公钥哈希 grep -r "sha256/" target_extracted/assets/ 2>/dev/null

这段命令只是初步筛查,真正的定位还需要结合反编译工具查看 smali 或字节码。如果搜索到了形如sha256/xxxxxxxx的字符串,基本可以确认使用了公钥锁定。参数上要注意,不同框架的哈希存储位置不一样,有的放在资源文件里,有的硬编码在常量池中。

3.2 用运行时 Hook 替换校验结果

定位到校验方法之后,常见的做法是在运行时替换该方法的返回值。以 Android 平台为例,可以用 Frida 挂载脚本,拦截TrustManager的校验回调:

// 挂载到目标进程后,替换 X509TrustManager 的校验方法 Java.perform(function () { var TrustManager = Java.use("javax.net.ssl.X509TrustManager"); // 找到实现类,这里以常见的 OkHttp 实现为例 var OkTrust = Java.use("okhttp3.internal.tls.OkHostnameVerifier"); OkTrust.verify.overload("java.lang.String", "javax.net.ssl.SSLSession") .implementation = function (host, session) { console.log("[Hook] bypass hostname verify: " + host); return true; // 强制返回校验通过 }; });

这段脚本的逻辑是:在目标进程启动后,找到负责主机名校验的方法,把它的实现替换成直接返回true。参数说明:overload里指定的是原方法的签名,必须和实际方法匹配,否则挂载会失败;implementation是替换后的函数体,host是当前校验的域名,打印出来可以确认 Hook 是否命中了正确的调用点。

需要强调的是,Hook 只对当前进程有效,应用重启后需要重新挂载。另外,如果校验逻辑被混淆过,类名和方法名可能不是上面写的这样,需要先用Java.enumerateLoadedClasses把加载的类列出来,再按关键词筛选。

3.3 代理端的证书动态签发配置

客户端愿意信任代理证书之后,代理端还需要正确签发站点证书。mitmproxy 默认会为每个访问的域名动态生成证书,但如果目标客户端校验的是特定公钥,就需要让代理使用一张和真实服务器公钥一致的证书。这种做法需要提前拿到服务器的公钥信息,操作上属于进阶手段。

from mitmproxy import ctx from mitmproxy.certs import CertStore def configure(updated): # 加载自定义证书存储,指定上游证书来源 ctx.log.info("loading custom cert store") # 实际使用时把生成的证书放到 confdir 下对应目录 # mitmproxy 会优先使用匹配域名的证书文件

更常见的做法是让代理直接透传真实服务器的证书链,但这需要代理端能够拿到服务器的私钥,实际不可行。所以大多数场景下还是走“让客户端信任代理 CA”这条路,也就是前面 Hook 校验方法的思路。两条路各有适用边界:Hook 适合你有调试权限的测试包,透传适合服务端可控的自建环境。

3.4 请求改写与签名重放

抓到包只是第一步,真正有价值的是能改包再发出去。很多小程序的接口带了签名参数,直接改请求体会导致签名校验失败。mitmproxy 的脚本能力在这里就派上用场了:

from mitmproxy import http import hashlib def request(flow: http.HTTPFlow): # 只处理目标接口,避免影响其他流量 if "/api/order" not in flow.request.path: return # 取出原始参数,按业务规则重新计算签名 body = flow.request.get_text() sign = hashlib.md5((body + "secret_salt").encode()).hexdigest() flow.request.headers["X-Sign"] = sign print(f"[Rewrite] new sign: {sign}")

这段脚本在请求发出前拦截,重新计算签名并写回请求头。参数说明:flow.request.get_text()拿到的是请求体原文,签名算法要和客户端保持一致,盐值从逆向分析或配置文件中获取;X-Sign是示例头名,实际字段名以抓到的原始请求为准。改完之后在代理控制台里能看到修改前后的对比,确认无误再放行。

注意:签名重放只应在自己的测试账号和测试环境里做。对生产接口做批量改写可能触发风控,也可能影响真实数据,这条线不要越。

4. 避坑与排查:那些让我返工三次的问题

4.1 证书装了但流量还是红的

现象:系统信任库里已经装了 mitmproxy 的 CA 证书,浏览器访问 HTTPS 正常,但目标应用的所有请求依然显示握手失败。

原因:Android 7.0 之后,应用默认不再信任用户安装的 CA 证书,只信任系统级证书。把证书装到“用户证书”目录对大多数应用无效。

解决:需要把证书放到系统证书目录,这要求设备有 root 权限。操作上把 PEM 文件按哈希值重命名后推送到/system/etc/security/cacerts/,重启生效。没有 root 权限的话,只能走 Hook 替换校验逻辑这条路。

4.2 Hook 脚本挂上了但没生效

现象:Frida 显示脚本注入成功,日志里也有输出,但应用的请求依然抓不到。

原因:校验方法可能不止一处,或者应用用了多进程架构,脚本只挂到了主进程,网络请求实际在子进程里发出。

解决:先用frida-ps -U列出所有进程,确认目标进程名;再用Java.enumerateLoadedClasses搜索所有实现了X509TrustManager的类,逐个挂载。多进程场景下需要对每个相关进程分别注入。

4.3 代理连上了但请求全部超时

现象:客户端显示已连接代理,但所有请求都卡住不动,代理端也看不到任何流量。

原因:客户端可能走了 QUIC 协议(基于 UDP 的 HTTP/3),而代理只监听了 TCP 端口,UDP 流量没有被接管。

解决:在客户端侧禁用 QUIC,或者在代理端同时监听 UDP。mitmproxy 对 QUIC 的支持有限,更稳妥的办法是在应用配置里关掉 HTTP/3,强制走 TCP。

4.4 改包之后接口返回签名错误

现象:请求体改了,签名也按规则重算了,服务端依然返回签名校验失败。

原因:签名可能不只覆盖请求体,还包含了时间戳、随机数、请求路径甚至请求头字段,漏掉任何一项都会导致校验不通过。

解决:把原始请求完整抓下来,逐字段比对签名输入的范围。常见做法是把 method、path、body、timestamp、nonce 按固定顺序拼接后再哈希,具体顺序需要从客户端代码里确认,不能靠猜。

4.5 调试完忘了关代理导致应用异常

现象:调试结束后应用频繁报网络错误,重启也不恢复。

原因:设备的 WiFi 代理设置还指向已经关闭的代理端口,所有请求都发往一个不存在的地址。

解决:调试完成后手动清除设备代理设置,或者在代理端配置一个兜底转发规则,把非目标域名的流量直接放行。我一般会在脚本里加一个域名白名单,只拦截需要观察的接口,其余流量走直连,这样即使忘了关代理也不会把整个应用搞挂。

5. 二次开发与改造:把这套流程固化成可复用的调试工具链

上面这些步骤如果每次都手动来一遍,效率很低。我的做法是把整个流程拆成三个可复用的部分:环境准备脚本、Hook 脚本模板、流量分析脚本,各自独立维护,需要时组合使用。

环境准备这块,写一个 shell 脚本把证书导出、设备推送、代理启动串起来:

#!/bin/bash # 一键准备抓包环境,仅用于自有测试设备 set -e CONF_DIR="$HOME/.mitmproxy" CERT="$CONF_DIR/mitmproxy-ca-cert.pem" # 确保证书已生成 [ -f "$CERT" ] || mitmdump --set confdir="$CONF_DIR" --listen-port 0 & sleep 2 # 计算系统证书所需的哈希文件名 HASH=$(openssl x509 -inform PEM -subject_hash_old -in "$CERT" | head -1) cp "$CERT" "/tmp/${HASH}.0" echo "cert ready: /tmp/${HASH}.0"

这段脚本的关键在subject_hash_old这一步,Android 系统证书目录要求文件名是证书主题的旧版哈希值加.0后缀,名字不对系统不会加载。参数上--listen-port 0表示只生成证书不实际监听,避免端口占用。

Hook 脚本模板则把常用的校验替换点都列出来,用配置项控制启用哪些:

const config = { bypassHostname: true, bypassTrustManager: true, logOnly: false // 设为 true 时只打印不替换,用于观察 }; if (config.bypassTrustManager) { Java.perform(function () { var TM = Java.use("com.android.org.conscrypt.TrustManagerImpl"); TM.checkTrustedRecursive.implementation = function () { if (config.logOnly) { console.log("[Observe] checkTrustedRecursive called"); return this.checkTrustedRecursive.apply(this, arguments); } return Java.use("java.util.ArrayList").$new(); }; }); }

logOnly这个开关很实用:先开着观察哪些校验方法被调用、调用频率如何,确认目标之后再关掉它执行实际替换。这样能避免一上来就盲目 Hook 导致应用行为异常,排查问题时也更容易定位是哪个校验点出的问题。

流量分析部分,我习惯在 mitmproxy 脚本里把目标接口的请求响应落盘成 JSON 行格式,方便后续用 jq 或 Python 做统计分析:

import json from mitmproxy import http def response(flow: http.HTTPFlow): if "/api/" not in flow.request.path: return record = { "method": flow.request.method, "path": flow.request.path, "status": flow.response.status_code, "req_body": flow.request.get_text()[:2000], "resp_body": flow.response.get_text()[:2000] } with open("traffic.jsonl", "a") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n")

截断到 2000 字符是为了防止大响应把文件撑爆,实际分析时如果需要完整内容再单独抓。落盘之后可以用jq -s 'group_by(.path) | map({path: .[0].path, count: length})'快速看哪些接口调用最频繁,优先分析高频接口的参数规律。

验证改造是否成功,我一般走三步:先在logOnly模式下确认 Hook 命中了预期方法,再关掉观察模式看请求能否完整抓到,最后改一个参数看服务端返回是否符合预期。三步都过了才算这套流程在当前目标上跑通。需要提醒的是,这套工具链的适用范围仅限于你拥有调试权限的测试包和自建服务,换一个目标就要重新做定位分析,不能直接套用。

从那以后我每次搭抓包环境,都会先把logOnly打开跑一轮,确认校验点和流量路径都摸清楚了再动手改,这个习惯帮我省掉了不少“改了但不知道改没改对”的返工。希望帮到你。

本文还有配套的精品资源,点击获取