Android 抓包实战:mitmweb + pproxy(海外出口)+ Frida 解 SSL Pinning

前言

在受限网络里抓一个只对海外开放、且做了证书固定(SSL Pinning)的 Android App,常规「装个 CA + 开个代理」是不够的。这篇把一套跑通的组合记录下来:

  • VpnService/TUN 转发:把手机(或指定 App)的流量喂给 mitmproxy;
  • mitmweb upstream 模式 + pproxy:mitmproxy 负责解密,pproxy 负责把出口换到干净的海外 IP
  • 系统 CA 信任(root):让 App 信任 mitmproxy 的证书;
  • Frida 17 解 SSL Pinning:搞定 App 内置的证书固定。

⚠️ 下文所有 IP:PORT、账号密码、包名都用占位符,替换成你自己的。仅用于授权范围内的调试。

整体链路

1
2
3
4
5
6
7
8
9
10
11
12
手机 App(已绕过 pinning)
│ VpnService TUN(全局 / 仅指定 App

mihomo/Clash 内核 ──► http://127.0.0.1:8080 (经 adb reverse 到 PC)

mitmweb(upstream 模式,解密 HTTPS)
│ http://127.0.0.1:8888

pproxy(http → 上游代理)


海外 HTTP/SOCKS5 代理 ──► 外网(干净的海外出口)

关键点:mitmproxy 只管解密,出口交给 pproxy 链。这样解密和「换 IP」解耦,出口想换哪国换哪国。

组件与环境

  • PC:Windows(miniforge/Python 均可)、mitmproxypproxyfrida-toolsobjection、Node(给 frida-compile 用)。
  • 手机:已 root(KernelSU/Magisk),装好 frida-server
  • 转发端:任意基于 VpnService 的转发器(Clash.Meta / 自建的 mihomo 壳都行),把流量转到 http://<PC>:8080

Step 1 · 手机流量转发到 mitmproxy

用 VpnService 建 TUN,把(全局或指定 App 的)流量转发到上游 http://<PC_IP>:8080(即 mitmweb)。

公司网络连不上 PC? 内网常有防火墙/AP 隔离,PC 的 8080 手机直连不通。用 adb 反向隧道走 adb 通道,绕过网络层:

1
2
adb reverse tcp:8080 tcp:8080
# 转发器里上游填 127.0.0.1:8080 即可(走的是 adb 通道,防火墙/隔离都绕开)

DNS 建议用 redir-host(或 fake-ip + sniffer),保证上游看到的是真实域名而非 fake-ip。

Step 2 · mitmweb(upstream)+ pproxy 换海外出口

2.1 pproxy:把出口换成海外代理

pproxy 是纯 Python 代理,能把本地 HTTP 监听转发到任意上游(http / socks5)。上游代理跑在能连通目标节点的机器上(比如带全局代理/WARP 的 Windows 主机,而不是 WSL——见踩坑)。

1
2
3
4
5
# 上游是 HTTP 代理
python -m pproxy -l "http://:8888" -r "http://<US_PROXY_IP>:<PORT>#<user>:<pass>"

# 上游是 SOCKS5 代理(注意 pproxy 的鉴权语法是 #user:pass 结尾,不是 user:pass@host)
python -m pproxy -l "http://:8888" -r "socks5://<US_PROXY_IP>:<PORT>#<user>:<pass>"

先单独验证 pproxy 出口是不是想要的地区:

1
2
curl -x http://127.0.0.1:8888 https://ipinfo.io/json
curl -x http://127.0.0.1:8888 "http://ip-api.com/json" # 两个库都查,见踩坑

2.2 mitmweb:上游模式,负责解密

1
2
# 让 mitmweb 把解密后的流量交给 pproxy
mitmweb --mode upstream:http://127.0.0.1:8888

如果上游代理本身就是 HTTP 代理,也可以省掉 pproxy,让 mitmweb 直连:

1
mitmweb --mode upstream:http://<US_PROXY_IP>:<PORT> --upstream-auth <user>:<pass>

验证整条链:

1
curl -x http://127.0.0.1:8080 "http://ip-api.com/json"   # 应显示海外出口

Step 3 · 让 App 信任 mitmproxy 的 CA(root)

Android 7+ App 默认不信任用户证书;Android 14 系统证书库搬进了不可写的 Conscrypt APEX。所以要抓别家 App,得把 mitmproxy 的 CA 装进系统证书库——非 root 做不到。

  • Root 设备用 MoveCertificate(Magisk/KernelSU 模块)最省事:把 ~/.mitmproxy/mitmproxy-ca-cert.cer 以用户证书方式导入,模块会在开机时把它注入 APEX 系统库,重启生效。
  • 验证:
1
2
adb shell "su -c 'ls /apex/com.android.conscrypt/cacerts/ | wc -l; \
grep -l -i mitmproxy /apex/com.android.conscrypt/cacerts/* 2>/dev/null'"

系统信任搞定后,不做 pinning 的 App 就能被解密了。做了 pinning 的看下一步。

Step 4 · Frida 17 解 SSL Pinning(重点)

App 报 Client TLS handshake failed ... certificate unknown,就是证书固定:CA 装了也没用,App 只认自己钉死的证书。用 Frida 动态 hook 掉校验。

4.1 起 frida-server(版本要和 PC 端 frida 对上)

1
2
3
4
5
# 下载对应架构/版本的 frida-server(如 android-arm64),push 到设备
adb push frida-server /data/local/tmp/frida-server
adb shell "su -c 'chmod 755 /data/local/tmp/frida-server; \
nohup /data/local/tmp/frida-server >/dev/null 2>&1 &'"
frida-ps -U # 能列出进程即 OK

4.2 ⚠️ Frida 17 的坑:脚本里没有全局 Java

Frida 17 把 Java/ObjC bridge 从内核里移除了,裸脚本写 Java.perform(...) 会直接报:

1
ReferenceError: 'Java' is not defined

两种解法:

方案 A:objection(最省事,推荐交互式用)
objection 的 agent 已经把 frida-java-bridge 编译进去了,天然兼容 Frida 17:

1
2
3
4
# objection 1.12+:explore→start,-g→-n;它是 attach 到运行中的进程
objection -n <package> start
# 进入 REPL 后:
android sslpinning disable

方案 B:自写脚本 + frida-compile 打包 bridge(可常驻/自动化)
脚本顶部显式 import,再用 frida-compilefrida-java-bridge 打进去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// unpin.js —— 覆盖常见 pinning 机制
import Java from "frida-java-bridge";

Java.perform(function () {
const log = m => console.log("[unpin] " + m);

// 1) 用「全信任」TrustManager 覆盖 SSLContext.init
try {
const X509TM = Java.use("javax.net.ssl.X509TrustManager");
const SSLContext = Java.use("javax.net.ssl.SSLContext");
const TrustAll = Java.registerClass({
name: "org.example.TrustAll",
implements: [X509TM],
methods: {
checkClientTrusted() {},
checkServerTrusted() {},
getAcceptedIssuers() { return []; }
}
});
const tms = [TrustAll.$new()];
const init = SSLContext.init.overload(
"[Ljavax.net.ssl.KeyManager;", "[Ljavax.net.ssl.TrustManager;", "java.security.SecureRandom");
init.implementation = function (km, tm, sr) { init.call(this, km, tms, sr); };
log("SSLContext.init hooked");
} catch (e) { log("SSLContext skip: " + e); }

// 2) OkHttp3 CertificatePinner.check -> no-op(最常见)
try {
const CP = Java.use("okhttp3.CertificatePinner");
["check", "check$okhttp"].forEach(mn => {
if (CP[mn]) CP[mn].overloads.forEach(ov => {
ov.implementation = function () { log("OkHttp CertificatePinner." + mn + " bypassed"); };
});
});
} catch (e) {}

// 3) Conscrypt TrustManagerImpl.verifyChain -> 直接返回链
try {
const TMI = Java.use("com.android.org.conscrypt.TrustManagerImpl");
if (TMI.verifyChain) TMI.verifyChain.implementation =
function (chain, anchors, host) { log("verifyChain bypassed: " + host); return chain; };
} catch (e) {}

// 4) X509TrustManagerExtensions.checkServerTrusted -> 返回 List(注意返回类型是 List 不是数组)
try {
const Ext = Java.use("android.net.http.X509TrustManagerExtensions");
const ArrayList = Java.use("java.util.ArrayList");
Ext.checkServerTrusted.implementation = function (chain, authType, host) {
log("X509TrustManagerExtensions bypassed: " + host);
const list = ArrayList.$new();
for (let i = 0; i < chain.length; i++) list.add(chain[i]);
return list;
};
} catch (e) {}

log("pinning bypass installed");
});

打包:

1
2
3
npm init -y
npm install frida-compile frida-java-bridge
npx frida-compile unpin.js -o unpin.compiled.js

用一个常驻的 Python 驱动 spawn 目标 App(在任何 TLS 连接之前就装好 hook,比 attach 更稳):

1
2
3
4
5
6
7
8
9
10
11
12
13
# spawn_unpin.py
import frida, time
PKG = "<package>"
dev = frida.get_usb_device(timeout=10)
pid = dev.spawn([PKG])
session = dev.attach(pid)
script = session.create_script(open("unpin.compiled.js", encoding="utf-8").read())
script.on("message", lambda m, d: print(m.get("payload") or m))
script.load()
dev.resume(pid)
print("resumed; unpinning resident")
while True:
time.sleep(1)
1
2
python -u spawn_unpin.py
# 日志持续刷 [unpin] OkHttp CertificatePinner.check$okhttp bypassed 即成功

验证

  • 转发器内核日志:[TCP] ... host:443 match ... using PROXY[upstream]无 503/超时
  • Frida 日志:OkHttp CertificatePinner.check bypassed 持续输出;
  • mitmweb:能看到该 App 的明文请求,不再 certificate unknown
  • 出口:curl -x http://127.0.0.1:8080 http://ip-api.com/json 显示目标地区。

踩坑总结

  1. gost/pproxy 放在 WSL 里连不上上游节点:WSL2 有独立 NAT,公司网络下常常到不了外部节点(i/o timeout),而 Windows 主机能到(走 WARP 等)。把代理进程放到能连通节点的机器上(一般是 Windows 主机),或改 .wslconfignetworkingMode=mirrored 也未必覆盖到全部路由。

  2. 出口 IP 的地理定位不一致:同一个 IP,ipinfo(用运营商 geofeed)可能判美国,ip-api/MaxMind(偏注册地)可能判别国。选节点时多库都查,挑「都判成目标国」的干净 IP(proxy:false, hosting:false 更佳),否则 App 里蹦出的地址会和你以为的出口对不上。

  3. **Frida 17 移除全局 Java**:裸脚本报 Java is not defined。要么用 objection(agent 已内置 bridge),要么 frida-compilefrida-java-bridge 打进脚本。

  4. objection 是交互式 REPL,需要真终端,不方便脚本化常驻;要自动化/常驻就走「frida-compile + Python 驱动 spawn」。

  5. pproxy 鉴权语法scheme://host:port#user:pass# 结尾),不是 user:pass@host

  6. mitmproxy 只解密、不负责出口能否连通:上游连不上时 mitmweb 会回 502/503,App 就报错——先用 curl -x 把 pproxy→上游 这一段单独验通再说。

参考


Android 抓包实战:mitmweb + pproxy(海外出口)+ Frida 解 SSL Pinning
https://kingjem.github.io/2026/07/13/Android抓包实战-mitmweb-pproxy美国出口-frida解SSLPinning/
作者
Ruhai
发布于
2026年7月13日
许可协议