> 一份复盘笔记。记录我最近折腾的几个小项目:怎么让一个自建网关发出的请求,在上游眼里和官方客户端一模一样。涉及身份伪装、请求签名、无感验证码三道门,外加一条对某大模型竞技场的逆向支线和一次 WebView2 的八层地狱。
> 为避免纷争,文中不出现任何具体站点、产品与项目名(相关头字段名做了去前缀处理,错误码保留原样)。仅供学习交流,请别拿去干违反对应服务商条款的事。
0. 先交代一下背景
事情是这样的。我手上有一撮账号,想在不打开官方客户端的前提下,把它们包装成 OpenAI / Anthropic 兼容接口给自己的工具链用。听起来很简单:转发请求就完事了。
真上手才发现,上游服务端压根不是"收到请求就处理"的天真架构,它在请求路径上设了三道门:
- 你是谁? —— 请求头、系统提示词、设备指纹,像不像官方客户端(不像就吃 3012 之类的下马威);
- 你有没有带章? —— Client Signing V4,每个业务请求都要签名 + 工作量证明;
- 你是人吗? —— 某云厂商的无痕验证码,每次请求都要一张"行为良好"的票据。
为了伺候这三道门,前前后后写了三套实现(主要是想验证这条路在不同技术栈下走不走得通):
| 实现 | 技术栈 | 在这个故事里的角色 |
|---|---|---|
| A | TypeScript / Bun | 主参考实现,协议网关 |
| B | Python / FastAPI | 另一种思路,jsdom 子进程求解验证码 |
| C | Rust / Tauri | 桌面端,WebView2 真内核方案 |
先上总架构图,后面每一节对应一道门:

下面一节一节拆。
1. 第一道门:身份伪装 —— 同一个客户端,两副面孔
把官方客户端的流量抓下来看,第一反应是"就这?几个自定义头而已"。第二反应是"等等,为什么同一个客户端里有两套头组?"
真就是这么离谱。逆向 bundle 之后发现,客户端内部有两个函数(bundle 压缩后的代号,g6n 和 TV,一个管 LLM 补全请求的默认头,一个管端点路由的源头),它们发出的头不一样:
g6n(LLM 请求面):X-Agent排在第 8 位内联出现,永远不发X-Device-Mid;TV(上下文面):X-Agent整个消失,X-Client-Language/X-Client-Timezone变成永远在场(缺失时填"unknown"兜底),X-Device-Mid视情况出现。
同一个进程,两副面孔。你不仔细对,就会把 X-Agent 带到不该带的面上,或者把 device_mid 漏给路由请求 —— 然后百思不得其解为什么风控偶尔抽风。
重难点 ①:头的"线序"不由源码顺序决定
这是这一节我踩得最深的坑,值得单独讲。
官方代码大致长这样(JS 对象展开):
const headers = {
"User-Agent": ua, // 基座三件套先放
"HTTP-Referer": ref,
"X-Title": title,
...overrides, // 后面覆盖/追加
};
问题来了:JS 对象的键顺序 = 首次插入顺序。如果后面 ...overrides 里覆盖了一个基座里已有的键,那个键留在原来的位置,不会跑到后面去。所以最终上线缆的头顺序,和源码里读到的书写顺序是两回事。
我一开始照着源码书写顺序发头,对,字节级地"看起来一模一样",就是顺序不对。后来对照真包逐个头排了一遍才意识到要按运行时插入顺序模拟。修完那一刻,风控安静得像什么都没发生过。
(顺便:所有头的值都要过一遍 bundle 里的 printable-ASCII 校验;appVersion 不合法时客户端干脆不发 X-App-Version,并把 UA 降级成 客户端名/unknown —— 这种"失败方式"也得照抄,否则合法值与非法值的分叉行为会暴露你。)
重难点 ②:一致性是伪装的生命
伪装不是一个头一个头地"像",而是所有自述信息互相不打架:
- 请求头里的
X-Platform/X-Os-Version,必须和 system prompt 里Environment段落的Platform:/OS Version:行同源。我的实现里两者走同一条环境变量链,物理上保证不可能出现"头说 linux、prompt 说 windows"这种真实客户端永远产不出的组合; - 求解验证码的 JS 环境里注入的
sec-ch-ua/referer,必须和实际发出的 HTTP 头一致(后面第三节细说); - 多账号场景下,每个账号一个独立生成的虚拟
device_mid。共享指纹等于把所有账号的关联关系主动塞给风控。
> 💡 这一条是我整篇笔记最想传达的方法论:伪装的最高境界不是每个字段都逼真,而是没有任何一个组合是真实世界产生不出来的。
2. 第二道门:Client Signing V4 —— 被一个换行符卡住的那天
这一节是全文最硬的骨头,慢慢来。
上游有一个"签名开关"(feature gate)。开关打开时,每个业务请求都要带一组签名头,证明"我就是官方客户端本客"。整套 V4 协议长这样:

翻译成人话:
- 探开关:先问服务端"这个凭据要签名吗",答案缓存一小时(这步也用
g6n头组,只是多带一个x-api-key); - 握手换私钥:用凭据密钥经 HKDF 派生 HMAC 密钥,签一个握手串发过去,服务端返回一段 AES-GCM 密文;再用同一盐派生的 AES 密钥解密,得到一把 Ed25519 私钥(PKCS#8)。注意私钥不落地,解出来直接进内存的
CryptoKey; - 每请求双保险:一个 Ed25519 签名 + 一个 8-bit 工作量证明(PoW),全部塞进
X-Client-*头。
四条签名串的模板是核心中的核心:

注意到了吗,每一条都是用字面换行符 \n 拼的,不是空格、不是别的分隔符。
重难点 ③:\n 事件 —— 我用两周头发换来的教训
这事值得写成案例研究。
逆向的时候,签名串模板是从 bundle 里抠出来的。抠出来的过程很顺利,但中间隔了一个会话:上一个会话把模板提取出来给我看,显示过滤器把 \n 渲染成了空格(对,就是为了让输出"好看")。下一个会话的我对着那份"看起来更整洁"的提取结果,把它当成了真相,顺手把拼接符"纠正"成了空格。
结果就是:握手永远被拒,我排查了 headers、排查了时区、排查了时间戳精度,唯独没怀疑过模板本身 —— 因为它看起来太合理了。
最后是字节级验证解决的:把 bundle 原始切片直接 JSON.stringify 出来,逐字节比对,换行符赫然在列。实测对照:\n 拼接的握手 → 200 + privateCipher;空格拼接 → 拒。
> 💡 从那以后我给自己立了条规矩:凡是逆向提取的字面量,一律带转义展示、做字节级校验,绝不相信经过"美化"的中间产物。 显示层是会和真相说谎的。
重难点 ④:PoW,一个"礼貌性"的门槛
PoW 本身不难,就是个迷你哈希希:
const seed = sha256(`${apiKeyId}\n${appId}\n${sessionId}\n${ts}`).hex.slice(0, 32);
for (let counter = 0; ; counter++) {
const candidate = `${nonce}${counter.toString(16).padStart(8, "0")}`;
if (sha256(`${seed}\n${candidate}`).hasLeadingZeroBits(8)) return candidate;
}
8 bit 前导零,期望 256 次哈希,一毫秒的事。它防的不是算力,防的是态度 —— 你连这点作业都不肯做,那肯定不是客户端。做风控的人把门槛设计在"真客户端无感、脚本嫌麻烦"的临界点上,挺讲究的。
重难点 ⑤:fail-open,最反直觉也最关键的设计
如果让我自己设计签名,第一直觉是"签不出来就拒发请求"。但把官方客户端的行为逆向完之后我发现:客户端自己是 fail-open 的。
- 开关探测不到 / 没开 → 不签名,直接裸发;
- 握手失败 → 不签名,直接裸发;
- 连续两次 401
VERIFY_SIGNATURE_*→ 对这个 (origin, credential) 永久旁路,以后都裸发。
这彻底改变了实现思路。你要模仿的不只是客户端的"成功路径",还有它的"失败姿势"。上游服务端显然知道客户端会 fail-open(网络抖动总不能把用户卡死),所以裸发不会死,但你自作聪明地 fail-closed 或无脑重试,反而成了异常行为。
同时也有两处我故意偏离客户端的地方,都写了注释备案:老版凭据没有 id.secret 分隔符时,客户端会硬抛错,我选择静默跳过(fail-open 的一致延伸);开关探测失败时客户端每次都重新探,我在热路径上加了 30 秒负缓存(把最坏情况的每请求延迟钉死在"一分钟一次探测")。
> 💡 "哪里跟客户端保持一致、哪里故意偏离、为什么" —— 这三件事都要写下来。三个月后的你会感谢现在的你。
另外有一条免死金牌别错过:客户端有一个"免签路径集合",某些路径天生不签名。先查这个集合,能省掉一整条签名链路的开销,也少一个出错的环节。
3. 第三道门:验证码 —— 不破解,把它当宠物养
如果说签名是硬骨头,验证码就是硬骨头上的骨髓。先说结论,也是整个验证码方案的核心思想:
> 不要逆向验证码算法。在受控环境里,把官方 SDK 原样请过来,让它自己跑通。
理由很功利:SDK 重度混淆,算法迭代快,逆向一次的成本高、有效期短;而"养环境"是一次性投入,SDK 怎么改都无所谓 —— 它要什么我给什么,它自己验自己,通过之后把结果(verifyParam)拿走就行。
一次完整求解的交互长这样(泳道图):

这里有个设计上的小坚持:热路径永远不等求解。池里有票就直接走,池空就宁可让单个请求去吃一次 3007 挑战(fail-open 的又一次胜利),同时把池标记为 urgent、账号进冷却。求解器是后台生物,业务请求是前台生物,两者只通过池子接触。这样验证码服务的抖动永远不会拖死接口延迟。
三种求解环境,三种命
同一个思路,三套实现,成本和逼真度各不相同:
| 方案 | 环境 | 代码量 | 逼真度 | 主要成本 |
|---|---|---|---|---|
| 实现 A | happy-dom,进程内 | ~2500 行(大半是环境桩) | 够用 | 桩要自己造全套 |
| 实现 B | jsdom,Node 子进程 | 53 行 | 够用 | 多一个进程 + IPC |
| 实现 C | WebView2 真内核 | Rust 胶水 | 最高 | 运维地狱(见下一节) |
jsdom 方案能短到 53 行,是因为 runScripts: 'dangerously' + resources: 'usable' 让它天生离真浏览器更近,beforeParse 里补最小一套桩就够:
beforeParse(window) {
window.matchMedia = () => ({ matches:false, media:'', onchange:null,
addListener(){}, removeListener(){}, /* ... */ });
// canvas / webgl 指纹桩:返回稳定值即可,不需要"正确"
const proto = window.HTMLCanvasElement.prototype;
proto.getContext = function (type) {
if (/webgl/i.test(type)) return {
canvas: this,
getParameter: () => 'Intel', // 稳定、普通、不出众
getExtension: () => null, /* ... */
};
return { canvas: this, fillRect(){}, getImageData: (x,y,w=1,h=1) =>
({ data: new Uint8ClampedArray(w*h*4) }), /* ... */ };
};
}
重难点 ⑥:环境伪装五件套
这是验证码这节的重点。所谓"养环境",具体养的是这五样:
1. 指纹一致性。 固定一套普通到不能再普通的指纹:Chrome 127 / Linux x86_64、screen 1280×720、hardwareConcurrency: 12、webdriver: false。关键是"一致"而不是"真" —— 更重要的一步是给 JS 环境装一个网络拦截器,每个出站请求注入对应的 sec-ch-ua / referer / origin。JS 眼里的自己和 HTTP 头上的自己必须是同一个人,服务端一对比就能戳穿所有只改一头的方案。
2. toString 伪装。 风控 SDK 会遍历原型链,把可疑 API 的 toString() 打出来看 —— 原生 API 应该返回 function xxx() { [native code] },被 hook 过的会暴露 JS 源码。于是有个专门的 installNativeToString,把所有 JS 实现的 API 的 toString 也伪装成原生形态。一个函数伪装几十字节,防的是整类"hook 检测"。
3. 行为信号。 Event.isTrusted 恒为 true(合成事件在真浏览器里是 false,必露馅);同时 simulateBehavior 会播放一条约 600ms、带随机抖动的缓动鼠标轨迹,喂给风控 SDK 的行为缓冲区。它不是要骗过"人类行为判定模型",只是要给出一个像样的轨迹形状,让统计特征不掉出正常区间。
4. 指纹桩。 canvas / WebGL 的 getParameter 返回稳定值即可('Intel' 之类)。注意要点是稳定:同一环境两次求解的指纹值必须一模一样,抖动的假指纹比稳定的假指纹可疑一百倍。
5. 结果质量门。 SDK 在环境不达标时会"降级"—— 假装成功,回调给你一个假 verifyParam。拿去重放必吃 3007。所以入池前必须验货:
// 池层质量门:假货进来,3007 就会送上门
if (param.length < 200) return reject("too_short");
const payload = JSON.parse(atob(param)); // 真品 ~280 字符的 base64 JSON
if (!payload.securityToken || payload.securityToken.length < 50)
return reject("no_securityToken");
if (usedCertifyIds.has(payload.certifyId)) return reject("duplicate"); // F008 之源
真品长这样:约 280 字符的 base64 JSON,内含 certifyId + sceneId + securityToken。长度不足或没有 securityToken 的一律拒收 —— 宁可池空,不收假票。
票据池:这套体系的调度核心
池子的参数没什么神秘的:票据 TTL 95 秒(票的有效期本来就短,留足安全边际),min/max 水位控制后台补充节奏(进程内方案 15/60,WebView 方案因为求解成本高降到 3/12)。唯一值得单独说的是 certifyId 去重:验证码服务端对重复提交的 certifyId 会报 F008,而且这个错极具迷惑性 —— 看起来像风控在针对你,其实是你的窗口在某次重试里把旧票又交了一遍。一个全局去重集合,把"风控玄学"降维成"工程 bug"。
另外进程内方案还有个性能小彩蛋:验证码窗口复用池(一个窗口最多求解 8 次再销毁重建),加上 CPU governor 控频,整体 CPU 降了约 48%。养宠物,也要养得省粮。
4. WebView2 八层地狱:一个 CSS 属性引发的血案
前面的方案里,WebView2(实现 C 用的)是逼真度最高的 —— 毕竟真内核。但代价是我度过了好几周的"版本地狱"。这段历史我按版本记,因为它本身就是一份现成的排错决策树。
- v1.11 warmup / claim 两种模式抢窗口,竞态炸裂 → 模式改由窗口 label 决定,一个 label 一条命;
- v1.12 隐藏窗口里 WebView2 暂停了
requestAnimationFrame,验证流程卡死 → 改成常驻可见微型窗(约 170×46 像素,缩在屏幕右下角); - v1.13 滑块显示"验证通过"后流程卡死 → 加 30 秒救援守卫,补全 SDK init 的 region/prefix 参数,全链路埋点;
- v1.14 致命的 F008:reload 不清 storage,certifyId 被重复使用 → 每轮
wipeSdkState()清 localStorage / sessionStorage / IndexedDB;verifyResult:false 不再静默返回;池层加质量门 + 全局 certifyId 去重; - v1.15 窗口被遮挡时 timer / rAF 被冻结(Chromium 后台节流)→ 尝试
additional_browser_args禁节流 + 45 秒看门狗; - v1.16 看门狗发现 reload 对冻结的 renderer 完全无效 → 看门狗升级为销毁重建(换新 renderer 进程);还发现 browser args 按 data-dir 单例注册时会被静默忽略 —— 换独立 profile;
- v1.17 重建后依然偶发冻结 → 微型窗
always_on_top物理防遮挡,看门狗 45s 收紧到 25s; - v1.18 真凶落网:warmup 页面的 CSS 里,
#cap-holder写了display:none。容器不可见 → SDK 认为环境不就绪 → 永久卡在 preparing。改成left:-9999px+opacity:0,并给兜底逻辑从一次性setTimeout改成 3 秒 interval 的阶段状态机。好了。
对,最后那个让我改了七个版本的家伙,是一个 CSS 属性。隐藏窗口、看门狗、销毁重建、窗口置顶……全是给一行样式打的补丁。当然也不能说白干 —— 洋葱是一层层剥的,每一层剥掉之后下面确实又露出一层真的 bug,只是最底下那层薄得离谱。
把这段历史沉淀成决策树,以后再遇到"池子永远是空的"直接查表:

WebView2(其实是整个 Chromium 系)的教训,浓缩成五句话:
- 隐藏 ≠ 后台运行。不可见或被遮挡的窗口,timer / rAF 会被节流冻结,而
--disable-backgrounding-occluded-windows这类开关不可靠(还可能被静默忽略)。最朴素最有效的物理手段是always_on_top。 - reload 救不了冻结的 renderer,销毁重建(新 renderer 进程)才可以。
- interval 可靠,一次性
setTimeout有不触发的案例。所有兜底逻辑并进同一个 interval 轮询的阶段状态机。 - 需要布局的容器不能用
display:none藏起来,要用移出视口(left:-9999px)+ 透明。SDK 会检查容器几何信息。 - 埋点救狗命。
tick n=X visibility=Y stage=Z这一个三元组日志,能直接判断 renderer 死活和 SDK 卡在哪一阶段 —— 上面整棵决策树的每个分支,都是靠它分辨的。
5. 支线:某大模型竞技场 —— 能造出 token,和 token 能用,是两回事
换一个战场放松一下。这次目标是一个主打模型匿名对战评测的竞技场,流程型工作:Fiddler 抓两份 HAR 互相对齐,Node 脚本扒前端 JS chunk 挖 API 路由,最后用 Python + curl_cffi 挂 chrome124 的 TLS 指纹做实测。
第一课来得很快:直连 403,TLS 指纹 + 代理出口才 200。Cloudflare 在 TLS 握手层就把"非浏览器指纹"拦了,这一关跟请求内容毫无关系,纯粹是"你的 TCP/TLS 长得不像 Chrome"。
协议层逆向出奇地顺:部署 ID 从首页 HTML 正则里抠,模型目录免登录,匿名注册直接拿 JWT + 会话 cookie,ToU 同意、用户信息,一条龙全通。连 reCAPTCHA Enterprise v3 的 token 都能纯 HTTP 铸出来 —— anchor 端点的 HTML 里正则提一个初始 token,reload 端点换回真 token;reload 请求体最早用 HAR 里一条 15KB 的真实请求当模板,手写了 mini protobuf 编解码器(只处理 varint 和 length-delimited 两种 wire type),把字段里的 token 换新、那段约 8KB 的浏览器遥测 blob 原样保留。后来发现 reload 甚至接受纯表单编码,连 protobuf 都不用。
然后,墙来了:

同一个 token,两道门,两种命运。 注册接口的校验是宽松的,我的纯 HTTP 造物顺利拿到 JWT;但聊天接口的校验看的是 token 背后的评分,而评分与真实浏览器遥测强绑定 —— 那个 8KB 的 blob 是会话绑定的,没有真实浏览器环境就刷不出及格分。
这次的结论反而很清爽:这不是 bug,是设计。纯 HTTP 伪造 token 是一条死路,属于"硬缺口"而不是"还没找到方法"。真正可行的三条路:
- 打码平台(支持 Enterprise v3 + action 参数的那种)—— 花钱买真实浏览器环境的算力;
- 浏览器 token 工厂 —— 顺手写的那个实验项目走的就是这条路:Bun + playwright-core 驱动一个有头浏览器,在真实页面里执行
grecaptcha.enterprise.execute()拿票,其余请求在 Node 里发; - jsdom 指纹桩仿真 —— 手法与第三节的方案同源,属于同一套世界观的迁移。
> 💡 这条支线最大的收获是认知修正:"能造出 token" 和 "token 能用" 是两回事。风控的主体从来不是 token 本身,而是 token 背后的环境评分。 跟主线这边"不破解、养环境"的路线一对比,等于是同一个真理的两次独立发明。
6. 短支线:额度接口的"多订阅之谜"
一个轻松的小案子收个尾。现象:账号明明有两份订阅(日更的入门套餐 + 活动领取的期限订阅),额度卡片永远只显示一份。
顺藤摸瓜的过程不表,结论很有意思:余额接口永远只返回一个 plan —— 不是显示层的 bug,是接口本来就这么回;多订阅靠 priority 排序,活动订阅(priority 100+)会遮住日更那份(priority 90)。真正返回复数 plans 的是另一个订阅列表接口。
排查中还攒了几个小坑:凭据是 AES-GCM 加密的(enc:v1,密钥从机器用户名和 home 目录派生),所以没法在脚本里直接复现请求,最后给实现 C 加了个只读的 CLI 探针子命令当手术刀;另外 Windows 上构建 Rust 要手动设 LIB 环境变量,本机 git 全局代理失效时用 git -c http.proxy= -c https.proxy= push 单次绕过(千万别去改全局配置)。
查了一圈,接口没病,是我期望它有病。这种结论最好 —— 至少不用改代码。
7. 复盘:这套活儿的四条心法
写到这里,把三套实现 + 一条支线沉淀成四句话:
1. 跟着客户端走,别自作聪明。 头顺序是运行时插入顺序、失败时 fail-open、某些路径天生免签、appVersion 非法时静默降级 —— 全部以真客户端的行为为准,包括它的失败姿势。你唯一被允许的"自由发挥",是那些客户端在网关场景下做不到合理的事,并且每处偏离都要写明理由。
2. 不破解,养环境。 验证码这条路,逆向算法是下策:成本高、易碎、迭代快。"把官方 SDK 请到受控环境里,让它自己验自己"是一次性投入,SDK 怎么升级都无所谓。竞技场那条支线从反面证明了同一件事:环境评分才是风控的主体,伪造 token 是死路,伪造环境才有活路。
3. 一致性是伪装的生命。 JS 环境与 HTTP 头要一致,请求头与系统提示词要一致,指纹与行为要一致,账号与 device_mid 要一致。单点逼真毫无意义,组合的真实性才是风控真正在验的东西。
4. 可观测性救狗命。
\n 事件靠字节级 dump 破案,WebView2 八层地狱靠 tick n=X visibility=Y stage=Z 一个三元组日志分层排查。这类活儿里,日志不是锦上添花,是唯一的破案工具 —— 因为错误信息永远不会告诉你真相(F008 不会说"你复用了 certifyId",3007 不会说"你的票是假的")。
附:坑清单(速查版)
| 坑 | 表象 | 真相 |
|---|---|---|
| 显示过滤器污染提取结果 | 签名永远被拒 | 模板分隔符是字面 \n,被显示层"纠正"成了空格 |
| 头顺序照源码抄 | 偶发风控 | JS 展开语义,线序 = 运行时插入顺序 |
| 多账号共享指纹 | 关联封禁 | 每账号独立虚拟 device_mid |
| 降级假 verifyParam | 重放必吃 3007 | SDK 环境不达标会假装成功,入池前过质量门 |
| F008 | 像风控针对 | certifyId 被复用,reload 前要清三库 + 全局去重 |
| 隐藏 WebView2 窗口 | 验证卡死 | Chromium 节流冻结 timer/rAF,always_on_top + 重建 |
display:none 容器 |
SDK 永久 preparing | 用 left:-9999px + opacity:0 藏 |
| 纯 HTTP 造 reCAPTCHA token | 注册过、chat 403 | 评分绑真实浏览器遥测,硬缺口 |
写完这篇回头一看,这些项目说起来是"过验证",实际上反复在练的是同一门手艺:对着一个不给你文档的黑盒系统,用观察、假设、实验把它的真实规则一寸寸描出来,然后决定哪里的规则照抄、哪里的规则绕行。
风控方在明处设计"真客户端无感、伪造者难受"的临界点;我们在暗处寻找那个临界点的边界。这门手艺没有终点 —— SDK 会更新,指纹会升级,签名会到 V5。但方法是不变的:
> 别猜,去测。别信显示层,信字节。别跟规则过不去,先弄清规则是谁定的、为谁定的。
(完)
评论 (0)