白天在公司天天对着业务逻辑、RPC 链路和弱网重传当牛马,整个人都麻了。晚上回到自留地折腾逆向与协议伪装,本来以为是个纯粹的解压活,结果被风控上了一堂生动的对抗课 :sakta_emoji_3:

做协议伪装或者自建兼容网关的老哥,最容易陷入一个思维定势:满脑子都在死抠“正常握手流程怎么走”、“签名怎么算出来”、“Token 怎么成功换回来”。我们下意识觉得,只要每一步都返回 200,只要所有参数都填对,上游就拿我没办法。

真去线上跟风控策略过招,你就会被现实狠狠上一课。很多自建网关之所以莫名其妙吃 403 或者被风控打标记,根本不是因为成功路径算错了,而是当网络抖动、密钥异常、版本不匹配时,你做出了一个正常客户端绝不可能做出的举动。


0. 先交代一下背景

事情其实源于我维护的自建兼容层:要把一批上游凭据伪装成官方桌面客户端发出请求。

当时上游设了极其严密的 Client Signing V4 链路:先探测 feature gate 签名开关,再拿本地凭据跟服务端做基于 HKDF 的私钥握手协商,接着用派生出的私钥打 Ed25519 签名,顺带附带一个 8-bit 的前导零 PoW 证明,全部塞进 X-Client-* 序列头里。

作为游戏服务端牛马,我的职业病第一反应是:强校验、严谨、Fail-Closed。签名算不出来?那请求坚决不能发,必须拦截并抛出异常,或者疯狂指数退避重试握手,绝不向线上发出“不合规”的脏包。

结果呢?账号一连串吃风控。抓包复盘了真客户端好几天,我才发现自己到底错得有多离谱。

MERMAID · 图表源代码
flowchart TD
    A["触发业务请求"] --> B{"签名开关开启 & 缓存有效?"}
    B -->|超时 / 探针挂掉| C["旁路降级:不签名直接裸发"]
    B -->|是| D{"握手派生 Ed25519 私钥"}
    D -->|握手失败 / 网络断流| C
    D -->|成功| E["计算 8-bit PoW + 生成 V4 签名头"]
    E --> F["发出完整签名请求"]
    F --> G{"收到上游 401 VERIFY_SIGNATURE_*?"}
    G -->|连续两次命中| H["标记当前凭据永久旁路"]
    H --> C
    G -->|200 OK| I["正常完成"]

上图就是真客户端的代码逻辑。它骨子里压根不是一个铁面无私的校验器,而是把 fail-open(失败放行) 刻进了骨髓。


1. 第一道破绽:请求头的“线序”与非法值分叉

很多写爬虫或网关的新手,以为请求头只要 key-value 对得上就行。但现实是,上游网关和 WAF 甚至在统计请求头的物理线序(Wire Order)。

逆向客户端源码时,很多人会直接对着 JS 对象的字面量去写 Python 或 Go 代码:

javascript
const headers = {
  "User-Agent": ua,
  "HTTP-Referer": ref,
  "X-Title": title,
  ...overrides,
};

但在 V8 运行时里,JS 对象的键顺序取决于属性首次被插入的时序。如果后面的 ...overrides 覆盖了前面已有的属性,该键依然停留在最初插入的槽位,根本不会挪到末尾。如果你直接拿常规字典格式化发出请求,线序从一开始就是错的。

更要命的是非法值的分叉姿势:

  • 当环境变量里的 appVersion 不合法或缺失时,真客户端并不是抛出 Error,也不是留空发个 X-App-Version: ;
  • 它的处理方式极其奇葩:直接把 X-App-Version 从头列表里彻底剔除,同时将 User-Agent 降级为 Client/unknown。

如果你在网关里写了个强校验逻辑,版本不对就补默认值,或者自作聪明填一个干净的格式,风控系统一眼就能从时序和非法值分叉特征里抓到你。

> 💡 伪装的核心法则:伪装的最高境界不是每个字段都逼真,而是没有任何一个组合是真实世界产生不出来的。连失败时的丑态,都必须一模一样。


2. 握手与开关:谁让你搞 Fail-Closed 强校验的?

回到 Client Signing V4。这套握手流程包括三处极度反直觉的 fail-open:

① 开关探测超时时的裸发旁路

客户端启动后,会拿凭据去探测签名开关。如果网络抖动、DNS 延迟,或者探针返回了 5xx/超时,真实客户端的处理既不是死锁等结果,也不是阻止用户操作,而是直接绕过签名逻辑,以未签名状态裸发业务请求。

因为在大规模 C 端场景下,业务可用性永远高于防御层。风控设计之初就默认了公网弱网环境下会有大量合法的裸发流量。

② 握手派生私钥失败的处理

握手换取私钥需要进行一次 POST 交换,用 HKDF 派生密钥解密 AES-GCM 获得 Ed25519 私钥。如果这里网络断开或者证书报错,客户端的做法同样是:本次业务请求直接降级裸发。

如果你在自建网关里加了个“握手失败阻断流程”、“原地重试 5 次”,你在服务端监控里的 QPS 曲线和时序分布瞬间就成了夜空中最亮的星。

③ 连续 401 后的永久旁路熔断

最精彩的是连续鉴权失败的兜底策略:如果服务端返回了两次 401 VERIFY_SIGNATURE_*,官方客户端会把当前 (origin, credential) 组合直接打上旁路标记,后续所有业务请求全部停用 V4 签名,直到整个进程重启。

上游明确知道公网时钟漂移、本地 Crypto 异常可能导致部分客户端签名持续算错。为了保住留存,服务端规则里预留了降级容忍区。结果你自己的网关天天在那里严格校验、死磕重试,反而成了唯一的异常源。


3. 为什么“自作聪明”反而是最大破绽?

搞服务端开发的牛马们都懂,线上系统最怕的就是自作聪明的程序员。在逆向对抗里,这个规律被放大了十倍:

  1. 时序与状态机是全链路留痕的:风控看的不单单是当前一个报文,而是你前后几个包的关联状态。一个刚吃完 401 的连接,正常客户端应该立刻降级或退避,你却立刻按毫秒级间隔原地发来一模一样的签名包,这不是脚本是什么?
  2. 容错路径才是指纹沉淀最深的地方:正常路径大家都看 bundle 源码,抄得七七八八;但底层遇到网络闪断、GC 卡顿、坏包时的异常捕获(Catch 块),往往充斥着历史技术债和妥协。这些妥协和妥协后的降级行为,才是客户端最不可伪造的指纹。

搞协议伪装,不要试图教上游或者官方客户端怎么写代码。官方客户端拉了一坨什么样的屎山,你就得一板一眼地把那坨屎山的形状、排泄节奏乃至它 fail-open 的失败姿势原汁原味地演出来。

字节级对齐,时序级模拟。别去算命,多抓包看原始报文,少点过度设计,线上才能平平安安 :sakta_emoji_84: