返回列表

2026年8月16日 · 约 11 分钟读完

从原生到 Flutter:同一个 HTTPS,两种抓包路径

记录同一个目标——看清 App 的 HTTPS 流量——在原生 Android 和 Flutter 上分别怎么落地:证书信任、流量导流和主机名校验三个环节,各自卡在哪、怎么解。文末附可直接复用的完整抓包流程,下次遇到类似架构照着走。

同一个目标,两条不同的路

做 App 协议分析,最常干的一件事就是:看清它到底跟服务器聊了什么。

这个目标听着朴素,真动起手来却会分岔。同样是看 HTTPS 明文,原生 App 和 Flutter App 走的是完全相反的两条路:原生 App 换个证书、挂个代理,基本就通了;Flutter App 可能折腾一整天,最后还常常栽在同一个坑里——把”证书域名对不上”当成”证书被锁死了”。

这篇文章不针对任何具体 App,只讲两种架构各自的抓包思路和踩过的坑。不会出现真实域名、凭据、设备标识和健康数据,接口地址都做了替换。

不管哪条路,TLS 抓包其实都在回答三个问题:

  1. 流量肯不肯经过你 —— App 的连接有没有走你的代理?
  2. 你的证书被不被信任 —— 客户端认不认你签的证?
  3. 证书名字对不对 —— 证书上写的域名,跟 App 连的一致吗?

工具里那个”SSL 代理”开关,本质就是同时搞定这三件事。原生和 Flutter 的差别,就藏在三件事各自卡在哪一步上。

原生 App:走系统代理,CA 装到系统级

原生 App 一般用 OkHttp 这类网络库。这类库有个很老实的习惯:认系统设置的全局代理。

原生 App 走系统全局代理,代理按 CONNECT 域名动态签发证书的抓包链路

流量:代理会替你按域名发证。 App 每次发 HTTPS 请求,都会先跟代理说一声”我要连 example.com:443”。代理看到域名,用自己的 CA 现签一张证书发回去,TLS 就建起来了。

所以第一个问题基本白送:流量会经过你,代理还会主动按真实域名发证书。做法就两行:

# 手机通过 USB 反向端口访问本机代理
adb reverse tcp:9090 tcp:9090
# 设置全局 HTTP 代理
adb shell settings put global http_proxy 127.0.0.1:9090

本机再跑一个能解密的代理(Proxyman、mitmproxy 都行)监听 9090。到这一步,理论上就通了。

卡点:装进”用户证书”区没用。 但”理论上”往往栽在第二步。我把代理 CA 装进 Android 的”用户证书”区,结果还是抓不到。原因一句话:

Android 从系统 24 开始,默认不信任用户自己装的证书。

只要 App 没在自己的网络安全配置里明确放行”用户证书”(base-config 里写 certificates src="user"),它就只认系统自带的证书。你在用户区装得再多,对它都是空气——请求全部握手失败,代理只能看到连接断开。

所以原生路径想通,CA 必须装到系统级。

系统级 CA:一个开机就生效的 Magisk 模块。 麻烦在 Android 14 之后。系统证书目录被挪进了一个运行时只读、升级还会被还原的分区(APEX),硬改不行。正解是做一个 Magisk systemless 模块,在开机早期把证书目录盖上去:

/data/adb/modules/mitm_ca_systemless/
├── module.prop
├── post-fs-data.sh
└── system/etc/security/cacerts/<ca-hash>.0

原理一句话:开机时把系统证书和我加的证书合并成一个目录,bind-mount 盖到 APEX 的证书目录上。

# post-fs-data.sh:合并系统目录和 APEX 里的证书,再 bind-mount 覆盖 APEX
mkdir -p merged_conscrypt_cacerts
cp /system/etc/security/cacerts/* merged_conscrypt_cacerts/
cp /apex/com.android.conscrypt/cacerts/* merged_conscrypt_cacerts/
mount -o bind merged_conscrypt_cacerts /apex/com.android.conscrypt/cacerts

重启后 CA 在系统信任区里可见,原生链路就通了。

混合架构:一套设置全抓到。 很多原生 App 是混合架构:核心业务走原生接口,活动、福利这类页面用 WebView 加载远程 H5。好消息是 H5 也走系统网络栈,不另起炉灶。同一个代理 + 同一套系统级 CA,原生接口和 H5 一次全抓到。

关于 Frida:别死磕。 有些原生 App 带反调试保护(加固、native 反调试库都有),Frida 一上去进程就被杀。但抓包是”换证书 + 导流”,纯旁观;Frida 是”注入 + 改行为”,主动干预。App 防得住主动干预,防不住一个它以为可信的 CA。

Flutter App:绕过代理,证书自己说了算

Flutter 这边完全是另一个世界。同样的代理设置,结果天上地下。

第一堵墙:根本不走系统代理。 代理和反向端口都设好了,其他 App 的流量清清楚楚,唯独 Flutter App 的目标域名一条都没有。

原因在 Flutter 的网络实现:它用 Dart 自己的一套网络栈,TLS(BoringSSL)直接编进了 libflutter.so,socket 自己建、证书自己验,不理会 Android 的全局代理。用 Frida 冷启动 probe 一下,能看到它自己的一套入口:

SecureSocket_Connect
SecureSocket_Handshake
SecureSocket_RegisterBadCertificateCallback
SecurityContext_TrustBuiltinRoots

上传链路同样在 Dart 层闭合,所以常见的”hook OkHttp、改 TrustManager”在这里大概率没用。对 Flutter,设代理这条路直接走不通。

第二堵墙:流量拉过来了,却卡在证书阶段。 不走代理,那就强制让它走。用 iptables 把目标域名的 443 端口流量硬拉到本机:

adb reverse tcp:9443 tcp:9443
adb shell "su -c 'iptables -t nat -A OUTPUT -p tcp -d <IP> --dport 443 -m comment --comment TLS_CAPTURE -j DNAT --to-destination 127.0.0.1:9443'"

验证下来:iptables 命中了,Mac 侧也有连接,网络层面确实导流成功。但抓本机回环端口,看到的是一个固定的死亡模式:

ClientHello
  → ServerHello / certificate
  → 客户端直接 FIN

客户端在证书阶段主动掐断了连接,始终没进 HTTP 明文。这时候最容易犯的错,是把这当成”证书锁定”(pinning),然后一头扎进 BoringSSL 的校验回调里打转。

先分清:锁定,还是域名对不上? 我没急着猜,直接连上被导流的对端,看它到底发来什么证书:

openssl s_client -connect 127.0.0.1:9443 </dev/null 2>/dev/null | openssl x509 -noout -text | grep -A1 "Subject Alternative Name"

结果一目了然:

Subject Alternative Name:
  DNS:localhost
  IP Address:0.0.0.0
  IP Address:127.0.0.1

问题就在这里。App 要连的是 api.example.com,而对端因为监听在本机,默认发出来的是张 localhost 证书。再回头看那个 probe:它命中 SecurityContext_TrustBuiltinRoots,说明 Dart 用的是系统内置根——代理 CA 已经进系统信任区,证书链本身能过。

链可信,不等于名字对。 证书链验得过去,但 SAN(证书上写的域名)对不上,Dart 照样关连接。这才是根因。

Flutter App 绕过系统代理,通过 iptables 导流和本地 TLS relay 用正确 SAN 证书终结 TLS 的链路

解法:一个能”说对域名”的本地 TLS relay。 既然卡在”发出去的证书说不清自己是哪个域名”,那就自己写一个能说对域名的终结端,分三步。

① 签一张只用于本地分析的证书。 用已进系统信任区的代理 CA 私钥,签一张 SAN 为 api.example.com 的证书:

Subject CN: api.example.com
Subject Alternative Name: DNS:api.example.com
Issuer: 代理 CA

② 起一个本地 TLS relay。 它监听 127.0.0.1:9444:对 App 一侧是 api.example.com 的 TLS 服务器(用上面那张证书),对官方后台一侧是普通 TLS 客户端(用真实 TLS 重新连 api.example.com:443),中间把两端的字节流都存下来。

③ 导流。 查清目标域名当前的 IP,逐个加 DNAT,再建 adb reverse:

adb reverse tcp:9444 tcp:9444
adb shell "su -c 'iptables -t nat -A OUTPUT -p tcp -d <IP> --dport 443 -m comment --comment TLS_CAPTURE -j DNAT --to-destination 127.0.0.1:9444'"

换上这个 relay,握手立刻通过,明文请求和响应稳定落盘,App 业务也正常返回。Flutter 这条路,到此打通。

两种架构的差别

原生与 Flutter 两种架构的抓包差别对比图
网络栈走系统代理证书信任来源常见于
原生 OkHttp / Retrofit是系统 CAKotlin 原生 App
WebView 加载的 H5是系统 CA原生 App 内的活动/福利页
Flutter Dart 网络栈否系统内置根Flutter App
React Native(Android)是(底层是 OkHttp)同 OkHttpRN App

一句话总结差别:原生要解决”流量怎么经过你”,Flutter 要解决”证书怎么证明是你”。 iOS 上原则类似,只是手机和电脑之间用的是 USB 转发而不是 adb reverse。这篇文章的两个案例都是 Android,iOS 的细节没展开。

完整可复用流程:下次直接照着做

下面是能直接抄的完整流程,四步走完:判断路径 → 公共准备 → 二选一抓包 → 回滚。命令都能原样执行,把 api.example.com 换成实际目标域名。

第一步:30 秒判断走哪条路

从 APK / DEX 看网络栈,libflutter.so 在不在,决定后面全部方向:

看到结论
libflutter.soFlutter/Dart,自带网络栈 → Flutter 路径
Lokhttp3/、Lretrofit2/原生 OkHttp → 原生路径
libreactnative.soRN(Android 侧是 OkHttp)→ 原生路径
自定义 BaseWebView有 H5 页面,原生路径会覆盖它
原生 API + WebView H5两条都要,原生路径设置覆盖 H5
unzip -l app.apk | grep -E "libflutter|libreactnative"   # 看原生库
unzip -p app.apk classes.dex | strings | grep -m3 -E "Lokhttp3/|Lretrofit2/"   # 看网络栈

顺带判断 CA 装哪:解包找 network_security_config,没有 base-config → 必须装系统级;写了 certificates src="user" → 用户 CA 就够,省掉 Magisk。

第二步:公共准备

root + Magisk 的设备、adb 可连,一个含私钥的代理 CA(Proxyman 或 mitmproxy 的 pem)。装系统 CA 要用文件名,先记 hash:

openssl x509 -in <ca.pem> -noout -subject_hash_old

第三步:原生 App 照着抄

# ① 设全局代理 + USB 反向端口(本机起代理监听 9090)
adb reverse tcp:9090 tcp:9090
adb shell settings put global http_proxy 127.0.0.1:9090

# ② 装系统级 CA:Magisk systemless 模块,开机时 bind-mount 合并目录到
#    /apex/com.android.conscrypt/cacerts(Android 14+),模块内放一张 <hash>.0
adb push <module_dir> /data/adb/modules/
adb reboot

# ③ 验证:操作 App,代理里应出现目标域名的明文
#    混合架构下原生接口和 H5 一次全抓到

# ④ 回滚
adb shell settings put global http_proxy :0
adb reverse --remove tcp:9090
adb shell "su -c 'touch /data/adb/modules/<模块名>/remove'"
adb reboot

第四步:Flutter App 照着抄

# ① 确认是 Dart 栈(libflutter.so 在不在)
unzip -l app.apk | grep libflutter

# ② 透明导流到本机(先只验证网络层通了)
adb reverse tcp:9444 tcp:9444
dig +short api.example.com          # 记下当前 IP(A 记录)
adb shell "su -c 'iptables -t nat -A OUTPUT -p tcp -d <IP> --dport 443 -m comment --comment TLS_CAPTURE -j DNAT --to-destination 127.0.0.1:9444'"

# ③ 定位断点:看被导流对端发的证书 SAN,分清"域名对不上"还是"真锁定"
openssl s_client -connect 127.0.0.1:9444 </dev/null 2>/dev/null | openssl x509 -noout -text | grep -A1 "Subject Alternative Name"
#   SAN 是 localhost / 127.0.0.1 → 域名对不上,不是锁定

# ④ 用系统已信任的代理 CA 签一张 SAN = 目标域名的证书
openssl req -new -newkey rsa:2048 -nodes \
  -keyout api.example.com.key -out api.example.com.csr -subj "/CN=api.example.com"
openssl x509 -req -in api.example.com.csr \
  -CA <ca.pem> -CAkey <ca.key> -CAcreateserial \
  -out api.example.com.crt -days 7 \
  -extfile <(printf "subjectAltName=DNS:api.example.com\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth")

# ⑤ 起本地 TLS relay:对 App 侧用这张证书终结 TLS → 明文落盘 → 真实 TLS 转发上游
python3 tls_relay.py --listen-port 9444 --remote-host api.example.com \
  --cert api.example.com.crt --key api.example.com.key --out-dir captures/

# ⑥ 最终导流。IP 会变,抓包前重新 dig、更新 DNAT
adb shell "su -c 'iptables -t nat -A OUTPUT -p tcp -d <新IP> --dport 443 -m comment --comment TLS_CAPTURE -j DNAT --to-destination 127.0.0.1:9444'"

# ⑦ 验证 + 回滚
# 验证:relay 落盘的明文里能解析出 HTTP,且 App 业务正常
adb shell "su -c 'while iptables -t nat -S OUTPUT | grep -q TLS_CAPTURE; do iptables -t nat -D OUTPUT 1; done'"
adb reverse --remove tcp:9444

第五步:症状排查矩阵

症状根因处置
代理看不到目标域名,其他 App 正常App 绕过了系统代理透明导流
导流后连接到了,但没有明文证书阶段被客户端掐断看 SAN,按 SAN 签对证书
ClientHello → ServerHello/cert → FIN证书域名对不上不是锁定,先改 SAN
SAN 对、CA 链可信,仍拒绝真·证书锁定才去 hook 客户端校验回调
全局代理下握手失败release 不认用户 CA系统级 CA(Magisk)
iptables 计数不涨DNAT 没生效 / IP 变了重新解析 A 记录,核对 OUTPUT 链

回滚全清单(做完必跑)

adb shell settings put global http_proxy :0                                  # 清全局代理
adb reverse --remove tcp:9444; adb reverse --remove tcp:9090; adb reverse --remove-all   # 清反向端口
adb shell "su -c 'while iptables -t nat -S OUTPUT | grep -q TLS_CAPTURE; do iptables -t nat -D OUTPUT 1; done'"   # 清 DNAT
adb shell "su -c 'touch /data/adb/modules/<模块名>/remove'"; adb reboot       # 卸载系统级 CA

关键经验

  • 判断网络栈是最关键的一步:libflutter.so 在不在,决定后面全部方向。
  • 用户 CA ≠ 系统 CA:Android 7 起默认只信系统 CA,用户区装了白装。
  • 流量到了 ≠ 证书对:导流成功只代表网络通了,SAN 不对照样被掐,一定要连上去看证书。
  • 域名对不上 ≠ 锁定:ClientHello → ServerHello/cert → FIN 先查 SAN,别一头扎进校验回调。
  • 目标域名 IP 会变:每次抓包前重新 dig,IP 变了 DNAT 就失效。
  • ALPN 固定 http/1.1:relay 两端都固定,省得跟 HTTP/2 纠缠。
  • 有加固别死磕 Frida:原生 App 走被动 MITM 才是正解。
  • 明文证据只留 method / path / 字段名 / 结构,token、设备标识、真实响应体只在本地。

走过的弯路

把”证书域名对不上”当成”证书被锁死”。 看到”证书阶段被掐断”,第一反应是锁定,于是花力气去 hook BoringSSL 的各种校验回调。其实 CA 链早被系统信任了,只是 SAN 对不上。分辨方法很简单:锁定是 SAN 对、CA 链可信,客户端还拒绝;域名对不上是证书 SAN 压根不是它要连的域名。openssl s_client 看一眼证书,一步分清。

只验证”流量到没到”,没验证”证书长什么样”。 导流那一步,iptables 命中了、连接也建起来了,就以为快通了。其实流量到了,证书不对,还是白搭。以后遇到重定向抓包,把”连接到达”和”证书内容”分开验证。

以为装进”用户证书区”就完事。 原生那边一开始把 CA 装进用户区,以为装了就完。Android 7 以后默认不信用户 CA,不装到系统级,release 永远不认。

试图用 Frida 硬刚反调试。 加固库把 Frida 挡得死死的。但对”看明文”这个目标,Frida 从来不是必需项,被动 MITM 才是正解。分清”观察”和”修改”,能省很多力气。

回头看

回头看,我最初的设想是”装个代理、换个证书,顶多处理一下锁定”。真做下来才发现,决定成败的是架构对两件事的假设:流量走哪条路,证书代表谁的身份。

原生 App 站在通用路径上,代理知道它要连谁,证书按域名现发,一切顺理成章。Flutter 把”谁来当 TLS 对端”从框架手里抢了回来,那你就得自己把身份证明补齐——发过去的证书,得能让它认你就是那个域名。

证书链可信,从来不代表域名可信;流量到了对端,也不代表证书能被接受。把这三件事拆开,一步步验证,比记住任何工具的按钮都有用。