Post

Claude Code 2.1.88 逆向笔记:fingerprint 和 cch 是怎么回事

Claude Code 2.1.88 逆向笔记:fingerprint 和 cch 是怎么回事

Claude Code 2.1.88 逆向笔记:fingerprint 和 cch

首先,Anthropic 是 ** 🤣

26年开年先给Opencode ban了,然后开始逐渐加紧各种审查

开始逐渐封禁使用第三方反代,中转(如opencode-claude-auth, sub2api)等服务的账号

  • 开号?杀!
  • 充值?杀!
  • 正常使用?杀!

及时是一名个人用户也要小心再小心,各种遥测,数据发送 A\比任何人都知道你的环境

作为用户还是开发者,却不让自定义自己的开发环境,被迫使用claudecode,还要忍受claude code的 遥测。

我相信没有一名开发者愿意接受。

通过mitm抓包官方claudecode得到了以下遥测内容

时区,语言,系统,并会计算每一个设备的uuid

社区上有一个一直已来的未解之谜和缓存问题,就是cch字段是干什么的?有哪些因素会影响缓存命中率?

并且社区的各种开源 中转/反代都没有做以下字段的处理 这很有可能是导致 被封号和缓存异常的 主要原因!

本次逆向感谢codex的帮助👍

逆向anthropic的时候没道德负罪感了() OAI:什么道德审查,干死友商是第一目的(误)

本文逆向 Claude Code 2.1.88 的客户端,主要想搞清楚:

  1. cc_version=2.1.88.xxx 后面那三位 hex 到底怎么算的?
  2. x-anthropic-billing-header 里那个 cch 五位 hex 又是什么?

这俩值有什么区别?

通过抓包表面上看都是短 hex 串,但性质不一样:

  • fingerprint 跟在版本号后面,同样的 prompt 跑多次基本不变。更像是一种请求归因标记。
  • cch 在 billing header 里,每次运行都可能不同。明显是运行时动态算出来的。

用了什么材料

感谢 anthropic 运维事故送上 cc 源码 🙏

四类东西:

npm 包和 sourcemap — 官方 @anthropic-ai/claude-code@2.1.88 自带 cli.js.map,能还原出不少 TypeScript 源码片段。fingerprint 的逻辑基本全在这一层。

本地装的官方二进制 — 装完以后拿到的是个 ELF,没 strip 干净,带个大 .bun 段。

动态调试 — gdb 断点、watchpoint、LD_PRELOAD tracer。

本地验证 — 把恢复出来的算法跑一遍,和实际抓到的值对比。


fingerprint:纯 JS 层,比较简单

这部分完全能从公开的 sourcemap 里恢复。

大致逻辑是:从第一条用户消息里取几个固定位置的字符,拼上一个内置盐值和版本号,做一次常规哈希,截取前几位。

因为输入基本固定(同一条消息、同一个版本),所以算出来的值也很稳定。没什么花活。


cch:坑在 JS 层和 native 层的分界线上

这个才是真正费时间的部分。

一开始被 sourcemap 误导了

cli.js.map 里恢复出来的代码显示,JS 层构造 attribution header 时,cch 的值就是写死的 00000

如果只看这一层,很容易得出”cch 就是个固定占位符”的结论。但实际抓包看到的 cch 明显不是零——每次都是不同的非零 hex。

这说明 JS 层只负责占位,真正的计算发生在更底层

试过一堆错误方向

在搞清楚真正算法之前,我试过很多常见思路:

  • 拿 request-id、session-id 去算各种常见哈希(SHA-256、MD5、CRC32、Adler32、BLAKE2s……)
  • 拿 body 去算零种子的哈希
  • 试各种截取方式(高位、低位、中间位)

全都对不上。

转折点:盯住那块 buffer

后来换了思路,不猜算法了,直接去追”到底是哪一步把 00000 改成了非零值”。

用 gdb 和 LD_PRELOAD tracer 盯住包含 cch=00000 的那块请求 body 缓冲区。终于在某一次命中里看到了:

  • 写入前,buffer 里是 cch=00000
  • 写入后,同一块 buffer,同一个位置,变成了一个非零的五位 hex

关键是——同一块 buffer,原地覆盖,长度不变

这就解释了为什么 JS 层要预留 00000:五个零和五个 hex 字符长度一样,不用改 Content-Length,不用重新分配内存。设计上很干净。

缩到具体写入指令

继续往里追,把写入点定位到了具体的地址。写法不是 memcpy 一个现成字符串进去,而是先得到一个 64 位数值结果,再按 nibble 拆开转成 hex 字符逐个写入。

确认输入

在核心哈希路径被调用之前,抓到了传入的参数:一个指向完整请求 body 的指针和它的长度。此时 body 里的 cch 仍然是 00000

所以输入就是:完整请求 body 的原始字节(cch 槽位保持为零占位符的状态)

确认算法

静态分析 native 代码,看到了一组特征非常明确的状态初始化常量和 update/finalize 流程——是一种带种子的非密码学哈希。

闭环验证

最终在同一次运行里完成了闭环:

  1. 写入前,gdb 抓到计算结果寄存器的值
  2. 写入后,body 里 cch 变成了该值低位对应的 hex
  3. 用同一次运行 dump 出的 prewrite body,本地跑同样的算法,结果和寄存器值完全一致

真正难的地方

现在回头看,算法本身其实不复杂。真正卡人的是两件事:

分层问题。JS 层和 native 运行时层各干各的。如果不意识到这一点,就会一直困在”sourcemap 里只有 00000 但实际不是零”这个矛盾里出不来。

字节级一致性。就算算法完全正确,只要最终 body 在字节级上有任何差异——字段顺序不同、转义方式不同、空白字符不同、数值序列化方式不同——算出来的值就会不一样。所以 cch 本质上是对最终发送 body 的完整校验,不是对某几个业务字段的摘要。


结语

Claude 是好模型,ClaudeCode 是好工具

但是沾上 Anthropic 😢

如果后续有继续更新对抗和检测指纹,本文将持续跟踪


This post is licensed under CC BY 4.0 by the author.