从抓包到复刻:还原飞连 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 客户端至少有两层流量:

  1. 控制面:登录、拉取节点、选择网关、申请 WireGuard 配置、心跳和断开上报。
  2. 数据面: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
2
iptables -t nat -A OUTPUT -p tcp --dport 443 \
-j DNAT --to-destination 192.0.2.10:8080

流量确实到达了 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
2
adb devices
adb shell getprop ro.product.cpu.abi

Mac 端安装 Frida 和 HTTP/2 HPACK 解码依赖:

1
2
3
4
5
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install frida frida-tools hpack
frida --version

下载与客户端版本完全一致、与 Android ABI 匹配的 frida-server。模拟器是 arm64-v8a 时,应使用 Android ARM64 包。

2. 启动 frida-server

1
2
3
4
adb push frida-server /data/local/tmp/frida-server
adb shell "su -c 'chmod 755 /data/local/tmp/frida-server'"
adb shell "su -c '/data/local/tmp/frida-server &'"
frida-ps -U

模拟器每次重启后都要重新执行最后两步。frida-ps -U 能列出进程,才说明版本、架构、Root 权限和 ADB 通道都正确。

清理此前遗留的代理:

1
adb shell settings delete global http_proxy

3. 找到所有进程

1
2
frida-ps -Uai | grep -i corplink
frida-ps -U | grep -E 'com\.volcengine\.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
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
function hook() {
const ssl = Process.getModuleByName("libssl.so");
const SSL_write = ssl.getExportByName("SSL_write");
const SSL_read = ssl.getExportByName("SSL_read");

Interceptor.attach(SSL_write, {
onEnter(args) {
const len = args[2].toInt32();
if (len > 0) {
send({ ssl: args[0].toString(), dir: "out" },
args[1].readByteArray(len));
}
}
});

Interceptor.attach(SSL_read, {
onEnter(args) {
this.ssl = args[0];
this.buf = args[1];
},
onLeave(retval) {
const len = retval.toInt32();
if (len > 0) {
send({ ssl: this.ssl.toString(), dir: "in" },
this.buf.readByteArray(len));
}
}
});
}

setImmediate(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
2
3
CAPTURE_DIR="$(mktemp -d /private/tmp/corplink-httpdump.XXXXXX)"
chmod 700 "$CAPTURE_DIR"
python3 httpdump.py com.volcengine.corplink "$CAPTURE_DIR/capture"

然后在模拟器中完成登录、加载节点、连接、等待至少一次心跳、主动断开,最后用 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/reporttype=100 不带 Sign,但带 timestamp 和数据面鉴权头
断开 /vpn/reporttype=101 body 与心跳同形,仅 type 不同

新版服务端会把 Android 客户端身份参数也纳入请求和签名。参数值与顺序必须稳定,例如:

1
2
3
4
5
6
7
8
9
10
os_version_patch=2025-04-01
&os=Android
&app_version=3.2.16
&os_version=34
&build_number=2008
&model=SM-S9210
&language=en
&client_source=FeiLian
&brand=samsung
&timestamp=<server-adjusted-unix-seconds>

在实现中,我把它抽象成可配置的 android_profile,但仍让 device_id、Cookie 和 WireGuard key 保持稳定:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"android_profile": {
"app_version": "3.2.16",
"build_number": "2008",
"brand": "samsung",
"model": "SM-S9210",
"android_release": "14",
"os_version": "34",
"os_version_patch": "2025-04-01",
"language": "en",
"client_source": "FeiLian"
}
}

这里 android_release 用于 User-Agent,os_version 是 Android SDK level。二者不要混为一谈。

复刻 Sign 签名

1. 外层格式

抓包中的 Sign 头形如:

1
v1;<base64-payload>

Base64 解码后不是裸 HMAC,而是一段很小的 protobuf:

1
2
3
field 1: version = 1
field 3: mask
field 4: 32-byte HMAC digest

手写 protobuf 编码后的字节结构为:

1
08 01 18 <uvarint(mask)> 22 20 <32-byte-digest>

2. 派生 HMAC key

签名 key 不是直接写死的字符串,而是 HKDF-SHA256 的输出:

1
2
3
4
IKM  = Android 二进制中的固定应用常量
salt = nil(等价于 HashLen 个 0)
info = lower(company_name) + "|" + device_id
L = 32

应用常量应从自己获准分析的客户端版本中提取。它属于版本实现细节,升级客户端后要重新验证,不能把某次逆向结果永久当成协议标准。

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
2
3
4
5
6
7
bit 1 = method
bit 2 = URL path
bit 3 = raw query
bit 4 = SHA-256(body)
bit 5 = Cookie header
bit 7 = csrf-token
bit 9 = vpn-token/JWT

最容易踩坑的是 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
2
digest = HMAC-SHA256(derived_key, canonical)
Sign = "v1;" + Base64(Protobuf(version=1, mask, digest))

一个接近实际实现的 Rust 骨架是:

1
2
3
4
5
6
7
8
9
10
11
fn compute_sign(input: SignInput<'_>, mask: u64) -> String {
let info = format!("{}|{}", input.company.to_lowercase(), input.device_id);
let key = hkdf_sha256(APP_EMBEDDED_IKM, None, info.as_bytes(), 32);

let mut canonical = Vec::new();
append_masked_fields(&mut canonical, mask, &input);

let digest = hmac_sha256(&key, &canonical);
let payload = encode_sign_payload(1, mask, &digest);
format!("v1;{}", base64(payload))
}

签名实现必须使用脱敏的固定回归向量测试。至少准备:

  1. 一个无 body 的 0x1fe 请求。
  2. 一个有 body 的 0x1fe 请求,验证 raw body hash。
  3. 两个不同 body/timestamp 的 /vpn/conn 0x21e 请求,避免算法只“碰巧”命中一组样本。
  4. company 大小写、Cookie 顺序、query 顺序和空 body 的边界测试。

4. 哪些请求不要签

/vpn/report 是一个反直觉例子:真机请求带 Cookie、csrf-tokenjwt-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
2
3
4
5
6
{
"type": "100",
"ip": "10.0.0.8",
"public_key": "<client-public-key>",
"mode": "Split"
}

主动断开使用同样的 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,就比官方客户端提前了一轮,同样可能破坏服务端状态机。

最终实现的关键不变量

把整个协议压缩成工程约束,大概是这些:

  1. Android profile 的字段值与 query 顺序稳定,device_id 和 WireGuard key 不随 profile 切换。
  2. 签名与最终发送请求共用同一个 URL、body、Cookie snapshot 和 token snapshot。
  3. /vpn/conn 使用 0x21e/vpn/report 不签名。
  4. 数据面 Cookie 从选中节点的探测响应开始建立。
  5. token 续期只更新 vpn-token,不覆盖数据面 session/CSRF。
  6. JWT 按“当前心跳成功后再推进”的一轮滞后时序更新。
  7. 心跳每次生成新 timestamp,上报原始 VPN IP。
  8. 抓包和测试数据全部脱敏,回归测试使用合成向量。

最终版本连续进行了两轮超过 30 分钟的运行验证,没有再出现此前的定时 code 1000 断开。WireGuard handshake 时间也持续刷新,说明不仅控制面心跳成功,隧道本身也仍在工作。

对应代码已整理在 corplink-rs bytedance-6.2.0,核心实现位于 src/sign.rssrc/client.rssrc/api.rs

复盘

这次最有价值的经验不是某一个常量或接口,而是调试顺序:

  1. 先区分控制面和隧道数据面。
  2. 网络代理与目标 VPN 冲突时,向上移动观测点,直接抓 TLS 明文入口。
  3. HTTP/2 看不到 path 时,不要误判成“没有请求”,先恢复 HPACK 状态。
  4. 签名问题必须逐字节比较 URL、body、Cookie 和 token,不能只比较字段集合。
  5. “能连上”只验证了建连协议;至少跨过一次 token 轮换周期,才算验证了完整会话生命周期。

协议复刻真正难的地方,往往不是发出第一条成功请求,而是保持与原客户端完全一致的状态机。