从抓包到复刻:还原飞连 Android 客户端的控制面协议
最近我给 corplink-rs 补齐了新版飞连 Android 客户端的请求签名和 VPN 会话保活。这个过程比预想中绕得多:系统代理会被应用拒绝,透明代理拿不到原始目标,WireGuard 抓包又会与目标 VPN 冲突;即使成功建立隧道,错误的 token 轮换方式还会让连接在一段时间后收到 code 1000 并断开。
最终可复现的路线是:在已 Root 的 Genymotion 中用 Frida Hook libssl.so,同时还原 HTTP/1.1 与 HTTP/2/HPACK;用抓到的明文和静态分析结果复刻 Sign;最后按 Android 客户端的 session/token 时序实现心跳。
本文只讨论自己有权测试的设备、应用和账号。抓包会包含登录态、Cookie、内部地址、路由和 VPN 配置。文中的账号、token、IP、域名和测试向量均为占位符或合成数据;不要把原始抓包提交到 Git。
先拆清楚要复刻什么
一个 VPN 客户端至少有两层流量:
- 控制面:登录、拉取节点、选择网关、申请 WireGuard 配置、心跳和断开上报。
- 数据面:WireGuard/OpenVPN 等隧道承载的业务流量。
这次要还原的是控制面,不是解密用户隧道流量。最终观察到的主链路可以简化成:
sequenceDiagram
participant App as Android 主进程
participant GW as 控制面网关
participant VPN as :vpn 子进程
participant EP as VPN 节点 API
App->>GW: 登录 / OTP / 会话 Cookie
App->>GW: GET /api/vpn/list + Sign
App->>EP: GET /vpn/ping + Sign
EP-->>App: 延迟与节点 Set-Cookie
VPN->>EP: POST /vpn/conn + Sign
EP-->>VPN: WireGuard 地址、Peer Key、路由、DNS
loop 每 60 秒
VPN->>EP: POST /vpn/report (type=100)
end
loop token 到期前
App->>GW: 刷新 vpn-token
App->>EP: 仅同步新 vpn-token
end
VPN->>EP: POST /vpn/report (type=101)
这里有一个非常重要的边界:/api/* 所在的控制面域名和 /vpn/* 所在的节点 IP 是两套 Cookie 作用域。简单地把控制面 Cookie 全量覆盖到节点上,会破坏数据面已经建立的 session。
为什么最初的抓包方案都失败了
系统 HTTP 代理
设置全局代理很简单:
1 | adb shell settings put global http_proxy 192.0.2.10:8080 |
浏览器可以正常工作,但目标 App 会在发现系统代理后拒绝访问。即使系统 CA 已安装,应用自身的代理检测和证书 Pinning 仍然是两道独立的门槛。
跨机器透明代理
另一个尝试是在 Android 上用 iptables 把 80/443 DNAT 到 Mac 上的 mitmproxy:
1 | iptables -t nat -A OUTPUT -p tcp --dport 443 \ |
流量确实到达了 Mac,但 mitmproxy 持续报:
1 | Transparent mode failure: Could not resolve original destination. |
原因是 SO_ORIGINAL_DST 依赖 NAT 所在机器的 conntrack 状态。DNAT 发生在 Android,mitmproxy 却运行在 Mac;Mac 内核根本不知道这个连接原本要去哪个地址。透明代理和 NAT 不在同一台机器时,这条信息链已经断了。
WireGuard/VPN 抓包
mitmproxy 的 WireGuard 模式适合普通 App,但目标本身就是 Android VpnService。Android 同一时刻只能有一个系统 VPN,抓包 VPN 与目标 VPN 会互相顶掉。
tcpdump
tcpdump 能确认目的 IP、端口和 TLS 握手,但 HTTPS body 仍是密文。更糟糕的是,在 VPN App 上长时间执行 tcpdump -i any -s 0,可能同时抓到物理接口与 TUN 接口中的数据,同一批包重复出现,pcap 很快膨胀到几十 GB。
因此,最终不再改变网络路径,而是在进程内抓 TLS 加密前和解密后的明文。
Genymotion + Frida 的最终抓包方案
1. 准备环境
我使用的是 macOS、已 Root 的 Genymotion 和 ARM64 Android 镜像。先确认 ABI:
1 | adb devices |
Mac 端安装 Frida 和 HTTP/2 HPACK 解码依赖:
1 | python3 -m venv .venv |
下载与客户端版本完全一致、与 Android ABI 匹配的 frida-server。模拟器是 arm64-v8a 时,应使用 Android ARM64 包。
2. 启动 frida-server
1 | adb push frida-server /data/local/tmp/frida-server |
模拟器每次重启后都要重新执行最后两步。frida-ps -U 能列出进程,才说明版本、架构、Root 权限和 ADB 通道都正确。
清理此前遗留的代理:
1 | adb shell settings delete global http_proxy |
3. 找到所有进程
1 | frida-ps -Uai | grep -i corplink |
目标是多进程应用:
| 进程 | 主要流量 |
|---|---|
com.volcengine.corplink |
登录、配置和节点列表等 HTTP/1.1 流量 |
com.volcengine.corplink:channel |
长连接/通道流量 |
com.volcengine.corplink:vpn |
/vpn/conn、/vpn/report 等 HTTP/2 流量 |
只 Hook 主进程会漏掉最关键的 VPN 建连请求。Android 的 :vpn 进程由 zygote 独立孵化,也不能假设它是主进程普通 fork 出来的子进程。我的抓包器会先 spawn 主进程,再轮询并附加所有同包名子进程。
4. Hook TLS 明文入口
验证过的版本使用系统 libssl.so。最小 Frida Agent 如下:
1 | function hook() { |
Frida 17 需要使用模块实例的 getExportByName() 和指针实例的 readByteArray()。旧代码中的 Module.findExportByName()、Memory.readByteArray() 会直接报 TypeError: not a function。
SSL_write 的 buffer 是加密前明文,SSL_read 返回后则是解密后明文。这个位置不依赖系统代理,不需要替换服务器证书,也不会与目标 VPN 冲突。
5. 同时解析 HTTP/1.1 和 HTTP/2
只把 buffer 打成字符串仍然不够:
- HTTP/1.1 需要处理 keep-alive、
Content-Length和 chunked body。 - HTTP/2 的 HEADERS 使用 HPACK 压缩,直接 grep 不一定能看到
/vpn/conn。 - HPACK 动态表按连接、按方向演进;中途 attach 可能缺失动态表上下文。
- 一次
SSL_read不等于一条完整 HTTP 消息。
抓包器以 (process, SSL*, direction) 为 key 维护 buffer;HTTP/2 再按 stream id 聚合 HEADERS、CONTINUATION 和 DATA。默认使用 spawn 模式,从应用启动的第一刻安装 Hook;随后每隔很短时间扫描并补挂 :channel、:vpn 进程。
建议将输出写入权限受限的临时目录:
1 | CAPTURE_DIR="$(mktemp -d /private/tmp/corplink-httpdump.XXXXXX)" |
然后在模拟器中完成登录、加载节点、连接、等待至少一次心跳、主动断开,最后用 Ctrl+C 正常结束脚本。
我同时保留四种输出:
| 文件 | 用途 |
|---|---|
capture.log |
人类可读的 HTTP/1.1/HTTP/2 流水 |
capture.json |
去重后的 API inventory |
capture.ndjson |
每次请求/响应一行 JSON |
capture.raw.log |
每次 SSL_read/SSL_write 原始数据兜底 |
怀疑结构化解析漏请求时,先查 raw.log,不要因为 inventory 中没有就断言 App 没发。若只能看到 HTTP/2 Magic 或 HPACK 解码失败,应保持 Hook 运行并重新建立一条连接,让解析器从 preface 和第一组 HEADERS 开始收集动态表。
从明文流量恢复请求模型
抓到完整时序后,可以把控制面归纳为几类请求:
| 阶段 | 请求 | 关键点 |
|---|---|---|
| 租户发现 | /api/match |
根据企业标识获取控制面地址 |
| 登录 | /api/login/*、/api/v2/p/otp |
建立登录 session,并取得 OTP 信息 |
| 节点列表 | /api/vpn/list |
带完整 Android profile 和 Sign |
| 节点探测 | /vpn/ping |
对候选节点并发探测,保留被选节点的 Set-Cookie |
| 建连 | /vpn/conn |
HTTP/2 POST,返回 WireGuard 配置、路由和 DNS |
| 心跳 | /vpn/report,type=100 |
不带 Sign,但带 timestamp 和数据面鉴权头 |
| 断开 | /vpn/report,type=101 |
body 与心跳同形,仅 type 不同 |
新版服务端会把 Android 客户端身份参数也纳入请求和签名。参数值与顺序必须稳定,例如:
1 | os_version_patch=2025-04-01 |
在实现中,我把它抽象成可配置的 android_profile,但仍让 device_id、Cookie 和 WireGuard key 保持稳定:
1 | { |
这里 android_release 用于 User-Agent,os_version 是 Android SDK level。二者不要混为一谈。
复刻 Sign 签名
1. 外层格式
抓包中的 Sign 头形如:
1 | v1;<base64-payload> |
Base64 解码后不是裸 HMAC,而是一段很小的 protobuf:
1 | field 1: version = 1 |
手写 protobuf 编码后的字节结构为:
1 | 08 01 18 <uvarint(mask)> 22 20 <32-byte-digest> |
2. 派生 HMAC key
签名 key 不是直接写死的字符串,而是 HKDF-SHA256 的输出:
1 | IKM = Android 二进制中的固定应用常量 |
应用常量应从自己获准分析的客户端版本中提取。它属于版本实现细节,升级客户端后要重新验证,不能把某次逆向结果永久当成协议标准。
3. canonical 的字段选择
mask 决定哪些字段参与签名。当前验证到两种主要形态:
| mask | 有效字段 | 使用位置 |
|---|---|---|
0x1fe |
method、path、query、bodyHash、Cookie、CSRF | /api/vpn/list、/vpn/ping、设备上报等 |
0x21e |
method、path、query、bodyHash、JWT | /vpn/conn |
字段位映射为:
1 | bit 1 = method |
最容易踩坑的是 canonical 的构造方式:
1 | canonical = method ++ path ++ query ++ bodyHash ++ cookie ++ csrf ++ jwt |
- 所有字段直接拼接,没有分隔符。
- query 不包含
?,顺序与最终发送 URL 完全一致。 - timestamp 应先追加到最终 URL,再从同一个 URL 取 raw query 参与签名。
- 非空 body 使用 SHA-256 的原始 32 字节,不是 hex,也不是 Base64。
- 空 body 直接省略 bodyHash 段。
- Cookie 字符串必须与最终发出的 Cookie header 逐字节一致,包括顺序和
;。 0x21e使用数据面 JWT,不使用 Cookie/CSRF 参与 canonical。
最后计算:
1 | digest = HMAC-SHA256(derived_key, canonical) |
一个接近实际实现的 Rust 骨架是:
1 | fn compute_sign(input: SignInput<'_>, mask: u64) -> String { |
签名实现必须使用脱敏的固定回归向量测试。至少准备:
- 一个无 body 的
0x1fe请求。 - 一个有 body 的
0x1fe请求,验证 raw body hash。 - 两个不同 body/timestamp 的
/vpn/conn0x21e请求,避免算法只“碰巧”命中一组样本。 - company 大小写、Cookie 顺序、query 顺序和空 body 的边界测试。
4. 哪些请求不要签
/vpn/report 是一个反直觉例子:真机请求带 Cookie、csrf-token、jwt-token 和 timestamp,但没有 Sign。给它添加 Sign 反而会被服务端以 code 1000 拒绝。
因此签名不能做成“所有 /vpn/* 一律添加”的全局中间件,应按精确 path 决定 mask。
心跳为什么会在一段时间后断开
建连成功并不等于协议已经复刻完成。早期实现能拿到 WireGuard 配置,也能完成握手,但运行一段时间后会出现:
1 | keep alive error: failed to report connection with error 1000 |
逐轮对比真机时序后,最终定位到几个相互叠加的问题。
1. /vpn/report 需要不断变化的 timestamp
心跳虽然不签名,但仍然需要 timestamp。重复发送完全相同的 URL 会触发服务端的防重放检查。
timestamp 使用服务端 Date header 校正后的 Unix 秒数。如果签名端点返回时间窗口错误,重新读取 Date、重建 URL 和 Sign,最多重试一次;不能只改 header 而复用旧 query。
2. 心跳 body 使用原始分配 IP
/vpn/conn 返回的地址通常会在本地转成 ip/mask 配置 WireGuard,但 /vpn/report body 的 ip 要使用原始 IP,不能把 CIDR 字符串原样上报。
1 | { |
主动断开使用同样的 body,只把 type 改成 101。
3. vpn-token 要续期,但不能覆盖数据面 session
最终稳定版本每 60 秒发送一次心跳,并在约 240 秒后通过控制面的节点列表请求预防性刷新 vpn-token。短暂刷新失败不会立刻杀死隧道,仍会尝试当轮数据面心跳。
最关键的修复是:刷新后只把新的 vpn-token 同步到节点 Cookie,不能把控制面的 session 和 csrf-token 一起覆盖过去。
数据面节点在 /vpn/ping、/vpn/conn 过程中已经建立了自己的 session identity。把控制面刚刷新的整套 Cookie 复制过去,相当于在既有 VPN 会话中突然换了身份,下一次 /vpn/report 就可能返回 code 1000。
4. jwt-token 存在一轮滞后
真机还有一个很微妙的时序:当 Cookie 中的 vpn-token 已轮换为 B 时,当前心跳的 jwt-token header 仍使用 A。只有这次心跳业务成功后,下一次心跳才把 JWT 推进到 B。
sequenceDiagram
participant C as Client
participant G as 控制面
participant E as VPN 节点
Note over C,E: 当前数据面 token = A
C->>G: 刷新控制面会话
G-->>C: Set-Cookie vpn-token=B
C->>C: 仅同步节点 Cookie vpn-token=B
C->>E: /vpn/report Cookie(B), jwt-token(A)
E-->>C: code=0
C->>C: jwt-token 推进为 B
C->>E: 下一次 /vpn/report Cookie(B), jwt-token(B)
如果收到 Set-Cookie 后立刻让当前请求使用 B,就比官方客户端提前了一轮,同样可能破坏服务端状态机。
最终实现的关键不变量
把整个协议压缩成工程约束,大概是这些:
- Android profile 的字段值与 query 顺序稳定,
device_id和 WireGuard key 不随 profile 切换。 - 签名与最终发送请求共用同一个 URL、body、Cookie snapshot 和 token snapshot。
/vpn/conn使用0x21e,/vpn/report不签名。- 数据面 Cookie 从选中节点的探测响应开始建立。
- token 续期只更新
vpn-token,不覆盖数据面 session/CSRF。 - JWT 按“当前心跳成功后再推进”的一轮滞后时序更新。
- 心跳每次生成新 timestamp,上报原始 VPN IP。
- 抓包和测试数据全部脱敏,回归测试使用合成向量。
最终版本连续进行了两轮超过 30 分钟的运行验证,没有再出现此前的定时 code 1000 断开。WireGuard handshake 时间也持续刷新,说明不仅控制面心跳成功,隧道本身也仍在工作。
对应代码已整理在 corplink-rs bytedance-6.2.0,核心实现位于 src/sign.rs、src/client.rs 和 src/api.rs。
复盘
这次最有价值的经验不是某一个常量或接口,而是调试顺序:
- 先区分控制面和隧道数据面。
- 网络代理与目标 VPN 冲突时,向上移动观测点,直接抓 TLS 明文入口。
- HTTP/2 看不到 path 时,不要误判成“没有请求”,先恢复 HPACK 状态。
- 签名问题必须逐字节比较 URL、body、Cookie 和 token,不能只比较字段集合。
- “能连上”只验证了建连协议;至少跨过一次 token 轮换周期,才算验证了完整会话生命周期。
协议复刻真正难的地方,往往不是发出第一条成功请求,而是保持与原客户端完全一致的状态机。