Android 抓包实战全流程:FlowTrans + mitmweb + pproxy + Frida,从解密到重放

本文最后更新于:2026年7月20日 下午

前言

在受限网络里抓一个只对海外开放、做了证书固定(SSL Pinning)、还带反调试的 Android App,「装个 CA + 开个代理」远远不够。这篇把一整套跑通的方案合并记录:设备端转发、PC 端解密+换海外出口、Frida 解 pinning、spawn/attach 取舍、native TLS 的边界,以及抓到之后脱离 app 重放 API 的思路。最后用 Gopuff 做实战。

⚠️ 所有 IP:PORT、账密、包名、client-id、hash 均为占位/脱敏,仅用于授权范围内的调试与学习。

一、整体架构:两半

抓包要解决两件事——①把手机流量导到 PC 的 mitmproxy②解密 + 换干净的海外出口。分工如下:

1
2
3
4
5
6
7
8
9
┌──────────── 手机 ────────────┐        ┌──────────── PC ────────────┐
│ 目标 App │ │ mitmweb ── UI :8081
│ │ (Frida 解 SSL Pinning) │ │ │ (解密 HTTPS) │
│ ▼ │ │ ▼ │
│ FlowTrans (VpnService/TUN) │─adb──► │ pproxy :8888
│ 上游 127.0.0.1:8080reverse │ │ (换海外出口) │
└──────────────────────────────┘ │ ▼ │
│ 海外 HTTP 代理 ──► 外网 │
└────────────────────────────┘
  • FlowTrans(设备端 VpnService/TUN 转发)负责 ①,把(指定 app 的)流量转到上游 127.0.0.1:8080。走 adb reverse tcp:8080 隧道,公司网络的 AP 隔离/防火墙全绕开。
  • mitmproxy 只管解密,出口交给 pproxy,两者解耦,想换哪国 IP 换哪国。

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

二、组件与环境

  • PCmitmproxypproxyfrida-tools、Node(给 frida-compile),Python(miniforge 即可)。
  • 手机:已 root(Magisk/KernelSU),装 frida-server(版本与 PC 端 frida 对齐)。
  • 转发:FlowTrans / Clash.Meta 等任意 VpnService 转发器,上游填 127.0.0.1:8080

三、一键 CLI:capture.py

手动一层层敲太累,封装成一个 click CLI(全文见文末附录)。三个子命令:

1
2
3
4
5
6
7
8
9
10
11
# 常规 Java app(默认 spawn)
python capture.py start --pkg com.example.app --mitmweb

# 有反调试的 app(spawn 超时 → attach),配 FlowTrans 转发 → --no-set-proxy
python capture.py start --pkg com.main.gopuff --mode attach --mitmweb --no-set-proxy

# Instagram 这类 Meta native TLS(Java + BoringSSL 两套一起上)
python capture.py start --pkg com.instagram.android --native --mitmweb

# 收尾
python capture.py stop

设计要点:

  • 自动探测设备--device autoadb devices 抓当前连着的,连不上就 try 已知 IP(笔记本换网络/办公室↔家不用改)。
  • --no-set-proxy:用 FlowTrans 转发时不要再设设备全局代理(settings put global http_proxy)——两者叠加打架,而且抓包进程一停、全局代理指向死端口会让 app 直接断网
  • mitmweb 固定密码:mitmproxy 12 的 web UI 默认要 token(裸访问 403),脚本里用 --set web_password=flowtrans 设固定密码,开 http://127.0.0.1:8081flowtrans 即可(或 ?token=flowtrans)。
  • 出口校验:启动后自动 curl -x pproxy ip-api.com 打印出口国家/城市。

四、Frida 解 SSL Pinning(Java 层)

App 报 Client TLS handshake failed ... certificate unknown,就是证书固定:CA 装了也没用。用 Frida hook 掉校验。

Frida 17 的坑:没有全局 Java

Frida 17 把 Java bridge 从内核移除,裸脚本 Java.perform(...) 直接 ReferenceError: 'Java' is not defined。两条路:

  • objection(交互式最省事):objection -n <pkg> start → REPL 里 android sslpinning disable。agent 内置了 bridge。
  • 自写脚本 + frida-compile(可常驻/自动化):脚本顶部 import Java from "frida-java-bridge",再 frida-compile 打包。

覆盖常见 pinning 机制的核心脚本(unpin.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import Java from "frida-java-bridge";
Java.perform(function () {
// 1) 全信任 TrustManager 覆盖 SSLContext.init
const X509TM = Java.use("javax.net.ssl.X509TrustManager");
const SSLContext = Java.use("javax.net.ssl.SSLContext");
const TrustAll = Java.registerClass({ name:"org.e.TrustAll", implements:[X509TM],
methods:{checkClientTrusted(){}, checkServerTrusted(){}, getAcceptedIssuers(){return[];}} });
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,[TrustAll.$new()],sr); };

// 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(){}); });
} catch(e){}

// 3) Conscrypt TrustManagerImpl.verifyChain -> 返回链
try { const TMI=Java.use("com.android.org.conscrypt.TrustManagerImpl");
if(TMI.verifyChain) TMI.verifyChain.implementation=function(chain,a,h){return chain;};
} catch(e){}
});
1
2
npm install frida-compile frida-java-bridge
npx frida-compile unpin.js -o unpin.compiled.js

spawn vs attach

  • spawn(frida 拉起 app):hook 早于任何 TLS 连接,最稳——但对有反调试的 app(Instagram/Gopuff)会超时 timed out while waiting for app to launch
  • attach(先启动再附加):晚一点,但 Java 层 pinning 是每次连接都校验,attach 照样来得及。反调试 app 用它。
  • spawn-gating 这台设备/这版 frida 抓不到 zygote fork,实测不通。

五、native TLS 的边界

Java hook 只对「走 Java TLS(Conscrypt/OkHttp)」的 app 有效。有的 app 走 native:

  • 导出了符号的 native BoringSSL(部分 Flutter/gRPC):hook SSL_CTX_set_custom_verify(对每个导出该符号的 module 都挂,别只挂第一个)。注意 Frida 17 用 Module.findGlobalExportByName
  • Instagram / Meta 系:主流量走 cronet + 静态链接且符号 strip 的 BoringSSLSSL_read/SSL_write 根本不被调用,按名字 hook / eCapture(缺 SSL_set_fdSSL_do_handshake)都挂不上,只能特征码扫描——成本高,本文不展开。

六、让 App 信任 mitmproxy 的 CA(root)

Android 7+ 默认不信任用户证书,Android 14 系统证书库搬进不可写的 Conscrypt APEX。抓别家 app 得把 mitmproxy 的 CA 装进系统证书库(非 root 做不到)。Root 设备用 MoveCertificate(Magisk/KernelSU 模块)最省事,重启生效。系统信任 + Frida 解 pinning 双保险。

七、踩坑总结

  1. pproxy/gost 放 WSL 里连不上上游:WSL2 独立 NAT,公司网络下常到不了外部节点;放到能连通的 Windows 主机上。
  2. 出口 IP 地理定位不一致:同一 IP ipinfo 判美国、ip-api/MaxMind 判别国。多库都查,挑都判目标国、proxy:false hosting:false 的干净 IP。
  3. pproxy 鉴权语法scheme://host:port#user:pass# 结尾),不是 user:pass@host
  4. mitmproxy 只解密、不管出口连通:上游连不上会回 502/503,先 curl -x 把 pproxy→上游单独验通。
  5. 美国专属 app(如 Gopuff):出口必须干净美区 IP,否则 API 域名可能公网都解析不出来。

八、抓到之后:重放的思路(Gopuff 实战)

抓到明文只是开始,真正有用的是脱离 app 用 curl 重放。以 Gopuff 的 guest token 为例:

排查:token 是个 JWT,iss=identity.gopuff.com,但全程抓不到这个域名。用 mitmweb 的 HTTP API + 脚本遍历所有响应体找「下发 JWT 的那条」,定位到:

1
2
3
POST graphql-federation-gateway.consumer.gopuff.com/graphql
body: {"extensions":{"persistedQuery":{"sha256Hash":"<hash>","version":1}},
"operationName":"CreateGuestToken","variables":{}}

原来 token 走 GraphQL persisted-query mutation 下发,identity.gopuff.com 只是签发方。

能否重放取决于三件事

  1. 认证要不要 secret —— 这里 authorization: Token token=空的,只要静态 x-gopuff-client-id(app 常量)就能换 token;
  2. 参数是否可控 —— persisted query 只放开 variables(改搜索词只改 variables.routeq=,hash 不能动);
  3. 环境约束 —— Gopuff 只认美区 IP,重放要走美国出口。

完整链路

1
2
3
4
5
6
7
空 Token token= + 静态 client-id + 美区 IP
│ CreateGuestToken

guest Bearer JWT(7 天)
│ 带 Bearer 调 Content 查询,q= 放进 variables.route

关键词搜索结果(ProductCollection + next 分页游标)

按这个思路纯 curl 走一遍,两步都 200、返回真实搜索结果,重放成立。方法可迁移到任何「抓到包想脱离 app 重放」的场景——核心就那句话:JWT 的 iss 不等于它的获取入口,靠抓包工具的 API 脚本化定位下发点


附录:capture.py 全文

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
#!/usr/bin/env python3
"""FlowTrans capture chain launcher: frida-server -> pproxy(海外出口) -> mitmweb
-> adb reverse -> Frida SSL-unpin(spawn/attach). Transport 由 FlowTrans(TUN) 转到
127.0.0.1:8080;配 FlowTrans 时用 --no-set-proxy。"""
import os, subprocess, sys, time
from pathlib import Path
import click

FRIDADIR = Path(r"C:\Users\<you>\Android\dl\frida")
DEFAULT_DEVICE = "auto"
KNOWN_DEVICE_IPS = ["172.16.4.60:5555", "192.168.1.10:5555"] # office / home
DEFAULT_PKG = "com.instagram.android"
DEFAULT_US_PROXY = "http://<US_PROXY_IP>:<PORT>#<user>:<pass>"
DEFAULT_PY = r"C:\Users\<you>\miniforge3\python.exe"
DEFAULT_ADB = r"C:\Users\<you>\Android\Sdk\platform-tools\adb.exe"
DEFAULT_LISTEN = 8888
DEFAULT_PROXY_PORT = 8080
DEFAULT_WEB_PORT = 8081
DEFAULT_WEB_PASSWORD = "flowtrans"
DEFAULT_UPSTREAM_URL = "http://127.0.0.1:8888"
_CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0


def _step(m): click.secho(m, fg="cyan")

def _run(args, **kw):
kw.setdefault("timeout", 20)
try: return subprocess.run(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kw)
except subprocess.TimeoutExpired:
click.secho(f" [warn] timed out: {args[:3]}", fg="yellow"); return None

def _launch_bg(args, out_log, err_log):
return subprocess.Popen(args, stdout=open(out_log,"w"), stderr=open(err_log,"w"),
creationflags=_CREATE_NO_WINDOW)

def _kill_by_cmdline(substr):
if os.name != "nt": _run(["pkill","-f",substr]); return
ps=("Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" -EA SilentlyContinue | "
f"Where-Object {{ $_.CommandLine -like '*{substr}*' }} | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -EA SilentlyContinue }")
_run(["powershell","-NoProfile","-Command",ps])

def _adb_devices(adb):
try: out=subprocess.run([adb,"devices"],capture_output=True,text=True,timeout=10).stdout
except subprocess.TimeoutExpired: return []
return [ln.split()[0] for ln in out.splitlines()[1:] if ln.strip().endswith("\tdevice")]

def _resolve_device(adb, device):
if device and device != "auto": return device
devs=_adb_devices(adb)
if not devs:
for ip in KNOWN_DEVICE_IPS: subprocess.run([adb,"connect",ip],capture_output=True,text=True,timeout=8)
devs=_adb_devices(adb)
if not devs: raise click.ClickException("no adb device found. Plug USB or: adb connect <ip>:5555")
return devs[0]

def _shell(adb, device, cmd, root=False):
return _run([adb,"-s",device,"shell", (f"su -c '{cmd}'" if root else cmd)])


@click.group(context_settings={"help_option_names":["-h","--help"]})
def cli(): """FlowTrans capture chain launcher."""

@cli.command()
@click.option("--adb", default=DEFAULT_ADB)
@click.option("--device", default=DEFAULT_DEVICE, help="adb device id or 'auto'")
@click.option("--pkg", default=DEFAULT_PKG)
@click.option("--mode", type=click.Choice(["spawn","attach"]), default="spawn",
help="attach = launch then attach (anti-frida apps)")
@click.option("--us-proxy", default=DEFAULT_US_PROXY)
@click.option("--listen", default=DEFAULT_LISTEN, type=int)
@click.option("--python", "python_exe", default=DEFAULT_PY)
@click.option("--fridadir", default=str(FRIDADIR))
@click.option("--js", "js_scripts", multiple=True)
@click.option("--native", "use_native", is_flag=True, help="Java + boringssl_unpin.js")
@click.option("--set-proxy/--no-set-proxy", default=True,
help="Set device global http_proxy (OFF with FlowTrans TUN)")
@click.option("--mitmweb", "launch_mitmweb", is_flag=True)
def start(adb, device, pkg, mode, us_proxy, listen, python_exe, fridadir,
js_scripts, use_native, set_proxy, launch_mitmweb):
fdir = Path(fridadir); device = _resolve_device(adb, device)
click.secho(f"device: {device} pkg: {pkg} mode: {mode}", fg="green")

_step("[1/6] frida-server on device (root)...")
_shell(adb, device, "pgrep frida-server >/dev/null || "
"setsid /data/local/tmp/frida-server -D >/dev/null 2>&1 &", root=True)

_step(f"[2/6] pproxy {listen} -> upstream (overseas exit)...")
_kill_by_cmdline("pproxy")
_launch_bg([python_exe,"-u","-m","pproxy","-l",f"http://:{listen}","-r",us_proxy],
fdir/"pproxy.log", fdir/"pproxy.err.log"); time.sleep(2)

_step("[3/6] adb reverse 8080 + device proxy...")
_run([adb,"-s",device,"reverse",f"tcp:{DEFAULT_PROXY_PORT}",f"tcp:{DEFAULT_PROXY_PORT}"])
if set_proxy:
_shell(adb, device, f"settings put global http_proxy 127.0.0.1:{DEFAULT_PROXY_PORT}")

if launch_mitmweb:
_step(f"[4/6] mitmweb (proxy :{DEFAULT_PROXY_PORT} -> :{listen}, web :{DEFAULT_WEB_PORT})...")
_launch_mitmweb(f"http://127.0.0.1:{listen}", DEFAULT_PROXY_PORT, detach=True); time.sleep(2)

if use_native: scripts=[str(fdir/"unpin.compiled.js"), str(fdir/"boringssl_unpin.js")]
elif js_scripts: scripts=[s if os.path.isabs(s) else str(fdir/s) for s in js_scripts]
else: scripts=[str(fdir/"unpin.compiled.js")]

_step(f"[5/6] Frida SSL-unpinning ({mode} {pkg})...")
_kill_by_cmdline("spawn_unpin.py")
_shell(adb, device, f"am force-stop {pkg}", root=True)
cmd=[python_exe,"-u",str(fdir/"spawn_unpin.py"),pkg,*scripts,"--mode",mode,"--adb",adb,"--device",device]
if mode == "attach":
_shell(adb, device, "input keyevent KEYCODE_WAKEUP")
_run([adb,"-s",device,"shell","monkey","-p",pkg,"-c","android.intent.category.LAUNCHER","1"])
click.echo(" launched; waiting 8s for classes..."); time.sleep(8)
_launch_bg(cmd, fdir/"unpin.log", fdir/"unpin.err.log"); time.sleep(3)

_step("[6/6] exit IP check")
subprocess.run(["curl.exe","-sS","-x",f"http://127.0.0.1:{listen}",
"http://ip-api.com/json/?fields=query,country,city,isp","--max-time","20"])
if launch_mitmweb:
click.secho(f"\nmitmweb UI: http://127.0.0.1:{DEFAULT_WEB_PORT} (password: {DEFAULT_WEB_PASSWORD})",
fg="green", bold=True)

def _launch_mitmweb(upstream, listen_port, detach, web_port=DEFAULT_WEB_PORT):
args=["mitmweb","--mode",f"upstream:{upstream}","--set","stream_large_bodies=2m",
"--set",f"web_password={DEFAULT_WEB_PASSWORD}","--no-web-open-browser"]
if listen_port: args+=["--listen-port",str(listen_port)]
if web_port: args+=["--web-port",str(web_port)]
if detach: _launch_bg(args, FRIDADIR/"mitmweb.log", FRIDADIR/"mitmweb.err.log")
else:
try: subprocess.run(args)
except KeyboardInterrupt: pass

@cli.command()
@click.option("--upstream", default=DEFAULT_UPSTREAM_URL)
@click.option("--listen-port", type=int, default=DEFAULT_PROXY_PORT)
@click.option("--web-port", type=int, default=DEFAULT_WEB_PORT)
@click.option("--detach", is_flag=True)
def mitmweb(upstream, listen_port, web_port, detach):
_launch_mitmweb(upstream, listen_port, detach, web_port)

@cli.command()
@click.option("--adb", default=DEFAULT_ADB)
@click.option("--device", default=DEFAULT_DEVICE)
def stop(adb, device):
_kill_by_cmdline("pproxy"); _kill_by_cmdline("spawn_unpin.py")
if os.name == "nt":
_run(["powershell","-NoProfile","-Command",
"Get-Process mitmweb,mitmdump -EA SilentlyContinue | Stop-Process -Force -EA SilentlyContinue"])
try: _shell(adb, _resolve_device(adb, device), "settings put global http_proxy :0")
except click.ClickException: pass
click.secho("stopped.", fg="green")

if __name__ == "__main__":
cli()

依赖 spawn_unpin.py(spawn/attach 模式驱动)和 unpin.compiled.js(Frida 17 打包产物)。

参考


Android 抓包实战全流程:FlowTrans + mitmweb + pproxy + Frida,从解密到重放
https://kingjem.github.io/2026/07/20/Android抓包实战全流程-FlowTrans-mitmweb-Frida-重放/
作者
Ruhai
发布于
2026年7月20日
许可协议