上次逆向完 Claude Agent SDK 的 Prompt Caching 之後
我以為可以消停一陣子了
(我甚至Source Map逆向事件都沒有參與討論了)
結果 Anthropic 在四月一號給我來了個驚喜
Claude Code 更新到 2.1.89 之後底下多了一行彩虹色的 /buddy
點下去會孵出一隻 ASCII art 小寵物陪你做事情
我心想,嗯,愚人節彩蛋嘛 挺可愛的
但是手癢打開Threads的我眉頭一皺
怎麼大家抽到的數值都不一樣,甚至還有分等級耶
而且好多人在問能不能重抽耶
他們看起來都超不爽自己抽的寵物很醜一張死魚臉wwww
玩遊戲嘛~ 怎麼可能不作弊(不對
經常玩這種 RPG 的我馬上直覺開始聯想
這東西是怎麼決定你拿到哪隻的?
能不能重抽?能不能改?有幾種等級
身為一個上次被 SDK 的usage quota逼到去逆向 minified code 的人
這種問題,我怎麼可能放過呢!
# 結論
懶得總結了,這Part直接叫AI上表格
| 項目 | 結論 | |
|---|---|---|
| 外觀(species/rarity/eye/hat) | 用你的 userId hash 確定性生成,正常情況不可改 | |
| 名字和個性 | API 生成,存本地 JSON,可以改 | |
| Server-side 註冊 | 沒有,純本地 | |
| 能不能重抽 | 同帳號永遠同結果,重抽沒有意義 | |
| 能不能作弊拿 Legendary | 能,有兩條路線(見下方) | |
| 路線 | 做法 | 難度 |
| ------ | ------ | ------ |
| Salt Patch | 用你的 userId brute force 找 salt,patch binary | 需要跑腳本 |
| Bones Patch | 反轉 spread 順序,讓 JSON 直接蓋過計算結果 | 改一行,想要什麼寫什麼 |
我從 common snail 變成了 Legendary Shiny Cat
以下是我怎麼把我的蝸牛變成酷酷鴨鴨貓的
# Part1:搞懂它怎麼運作的
# 拆包
Claude Code 2.1.89 是一個 188MB 的 Bun compiled Mach-O binary
不是 Node.js 也不是 Electron
反正不是什麼大包的執行環境
是那種整包 JS source 直接打包進 binary 裡面的
雖然 binary 的東西我們一輩子都看不懂
但 JS source 至少還有機會撈出來看
好消息是 strings 指令可以把裡面的 JS 碎片撈出來
壞消息是撈出來的東西長這樣:
o35={type:"local-jsx",name:"buddy",description:"Hatch a coding companion \xB7 pet, off",
isHidden:!LK8(),immediate:!0,load:()=>Promise.resolve({async call(H,_,q){let K=z_(),$=q?.trim();
if($==="pet"){let z=rE();if(!z)return H("no companion yet \xB7 run /buddy first",{display:"system"}
這尼碼什麼又是洨
混淆過的變數名,壓縮過的結構
跟上次挖 SDK bundle 是同一種感覺
不過好消息是逆向 minified code 跟上次一樣
老思路,一樣用常量字串去當索引指路
"buddy"、"companion"、"friend-2026-401" 這種的關鍵字不會被混淆
從字串出發往外追 function call chain 大概就可以還原個8成了
# 生成演算法
追完整個呼叫鏈之後 整理出來的流程長這樣:
userId ("cd7cdfd4-...")
↓
+ salt "friend-2026-401" ← 寫死在 binary 裡
↓
Bun.hash() → 32-bit seed ← wyhash64 截斷
↓
SplitMix32 PRNG ← 確定性偽隨機
↓
依序抽:rarity → species → eye → hat → shiny → stats
重點是這東西是確定性的
同一個 userId + 同一個 salt = 永遠同樣的結果
不是真的像抽卡一樣,跟伺服器要然後一次定生死
就算是本地應該也是要存個紀錄才對
只是怎麼跟伺服器驗證和同步就是另一回事了
只是我 Anthropic 大概沒這麼閒去設計這種東西
咳,總之
你的 userId 來自 ~/.claude.json 裡的 oauthAccount.accountUuid
salt 就我查到的表是寫死的字串 "friend-2026-401"(401 應該指的就是 April 1st......吧,我猜)
兩個拼起來丟進 hash → PRNG → 依序決定每一個 trait
# 兩層架構:Bones vs Soul
Buddy 系統分兩層:
Bones(骨架) -- 確定性生成,不存檔
- 內容:species、rarity、eye、hat、shiny、stats
- 每次啟動都從 userId 重新算
- 你改 JSON 沒有用,因為程式根本不讀 JSON 裡的 bones
Soul(靈魂) -- API 生成,存本地
- 內容:name、personality
- 孵化時打一次 Claude API(model 是 Haiku 的)
- 結果會存在
~/.claude.json的companion欄位 - 之後就只讀本地,不會再call API
// rE() -- 每次都重算 bones,只從 JSON 讀 name/personality
function rE() {
let H = z_().companion; // 讀本地 JSON
if (!H) return;
let { bones } = vN6(hN6()); // 重算 bones
return { ...H, ...bones }; // 合併
}
這代表:
- bones 正常改不了 -- 每次都是重算的,
{ ...H, ...bones }裡 bones 會蓋掉 JSON(但後面有辦法繞過,見 Bones Patch) - soul 隨便改 -- 直接編輯
~/.claude.json就生效(所以嚴格意義上我能讓他講中文嗎?)
# 抽卡機率
| Rarity | 權重 | 機率 |
|---|---|---|
| common | 60 | 60% |
| uncommon | 25 | 25% |
| rare | 10 | 10% |
| epic | 4 | 4% |
| legendary | 1 | 1% |
然後 shiny 是額外 1% 的獨立判定
所以 legendary + shiny 的機率是 0.01%
一萬個人裡面才有一個
物種有 18 種:
duck, goose, blob, cat, dragon, octopus, owl, penguin,
turtle, snail, ghost, axolotl, capybara, cactus, robot,
rabbit, mushroom, chonk
帽子 8 種(common 沒有帽子):
none, crown, tophat, propeller, halo, wizard, beanie, tinyduck
Stats 5 項,每隻有一個 boosted stat 和一個 nerfed stat:
DEBUGGING, PATIENCE, CHAOS, WISDOM, SNARK
Boosted: Math.min(100, base + 50 + floor(rng()*30)) ← 大幅加成
Nerfed: Math.max(1, base - 10 + floor(rng()*15)) ← 被壓低
Normal: base + floor(rng()*40) ← 普通
base 值由 rarity 決定:common=5, uncommon=15, rare=25, epic=35, legendary=50
所以 legendary 的 boosted stat 幾乎穩定在 100,nerfed stat 也還有 40+
# 我的命運
我的 userId hash 完之後
命中注定是一隻 common snail
沒有帽子,沒有 shiny
最高的數值是 WISDOM 75
接受啊,快點接受啊
但是我拒絕(岸邊露伴 Face)
# Part2:逆天改命
# 思路
既然 bones 是 hash(userId + salt) 算出來的
userId 我改不了(綁 Anthropic 帳號)
但 salt 是寫死在 binary 裡的
如果我能改 salt →
就能改 hash 結果 →
就能改 PRNG 輸出 →
就能改 rarity →
Perfect!
# Brute Force
寫了一個 Bun script 暴力搜索
用的是跟 Claude Code 完全一樣的 Bun.hash()(wyhash64)
確保模擬結果跟實際執行一致
// 用 Bun.hash 確保跟 binary 內的算法一致
function aN4(str: string): number {
return Number(BigInt(Bun.hash(str)) & 0xFFFFFFFFn);
}
16.5 萬組合只需要 81 毫秒跑完,真快耶,看來也沒很多嘛
找到 1,598 個 legendary 和 75 個 legendary + shiny
全部都是 15 字元(跟原始 salt 等長),看來可以直接用 sed patch 過去喔
# 挑貓
我最後挑了這隻:
,> ← 頭上有隻小鴨
/\_/\
( @ @) ← @ 眼
( ω )
(")"(")\
★★★★★ LEGENDARY CAT
✨ SHINY ✨
DEBUGGING ██████░░░░ 58
PATIENCE ███████░░░ 74
CHAOS ██████████ 100 ↑ ← 滿混沌
WISDOM █████░░░░░ 53 ↓
SNARK ████████░░ 78
salt:3s58p6-e-b260y-
一隻頭上頂著小鴨、CHAOS 滿點的 Legendary Shiny Cat
靈感詞是 ingot, rook, warble, brine
API 幫她取名叫 Brineclaw
(理論上能改但我沒改,我想說這是少數能接受的命運了,而且反正我換台伺服器名字又不一樣了)
# Patch Binary
原始 salt friend-2026-401 在 binary 裡出現兩處
直接用 perl 替換,再用 ad-hoc signature 重簽(Mac才需要)
# Patch
perl -pi -e 's/\Qfriend-2026-401\E/3s58p6-e-b260y-/g' "$binary"
# 重簽(這步很重要)
codesign --remove-signature "$binary"
codesign -s - -f --preserve-metadata=entitlements "$binary"
踩坑:macOS Code Signature
Claude Code binary 有 Hardened Runtime 簽章
改了任何一個 byte Gatekeeper 就拒絕執行
我第一次 patch 完直接打不開,差點把自己嚇個半死
後來才想到要用 ad-hoc signature 重簽
我把整個流程包成了三個腳本:
| 腳本 | 功能 |
|---|---|
buddy-bruteforce.ts |
用 Bun.hash 暴力搜索目標 salt |
buddy-verify.ts |
模擬預覽 + JSON 輸出 + 批量生成 |
buddy-patch.sh |
Salt Patch:自動 patch + backup + re-sign |
buddy-bones-patch.sh |
Bones Patch:反轉 spread + inject bones |
buddy-pokedex.ts |
全自動圖鑑:窮舉所有外觀組合 |
# Bones Patch
寫完 Salt Patch 之後我越想越不對勁
每次更新都要重跑 brute force 也太蠢了吧
而且 salt 還是 userId-specific 的 別人完全沒辦法直接用
回去看 rE() 那段 code:
return { ...H, ...bones }; // bones 蓋掉 JSON → 改 JSON 沒用
等等 如果我把 spread 順序反過來呢?
return { ...bones, ...H }; // JSON 蓋掉 bones → 寫什麼就是什麼
就這樣 一行改動
bones 先展開 H 後展開
JSON 裡有的欄位就蓋過去 沒有的就用計算值
bones 變成 fallback 而不是 override
但混淆後的變數名每次版本都不同 怎麼穩定定位?
關鍵是 property name 和 keyword 不會被 minifier 改:
.companion;if(!X)return;let{bones:Y}=???(???());return{...X,...Y}}
^^^^^^^^^^ ^^^^^ ^^^^^^
穩定的 prop name 穩定的 key 穩定的結構
只有 X、Y 這種單字元變數名會變
但整個結構是穩定的 用 regex 定位再 swap 就好
v2.1.89 是 H 和 _,v2.1.90 也是 H 和 _
就算下個版本變成 a 和 b 也無所謂 regex 都抓得到(應該啦,除非 minifier 後這個也能變)
改完之後直接在 ~/.claude.json 的 companion 裡寫上你想要的 bones:
{
"companion": {
"name": "Brineclaw",
"personality": "...",
"hatchedAt": 1743523200000,
"species": "cat",
"rarity": "legendary",
"eye": "@",
"hat": "tinyduck",
"shiny": true,
"stats": {
"DEBUGGING": 58,
"PATIENCE": 74,
"CHAOS": 100,
"WISDOM": 53,
"SNARK": 78
}
}
}
這個就不需要特別找 salt 了
跟賭俠一樣,想瞇什麼就瞇什麼
更新後 bones 在 JSON 裡就不會丟了
只需要重 patch binary 的那一行 spread 順序即可
# 兩條路線比較
| Salt Patch | Bones Patch | |
|---|---|---|
| 原理 | 改 PRNG 輸入讓它算出目標結果 | 反轉 spread 順序讓 JSON 蓋過計算結果 |
| 跨版本穩定性 | 靠 friend-2026-401 字串定位 |
靠 .companion + {bones: 結構定位 |
| 更新後要做什麼 | 重跑 buddy-patch.sh |
重 patch spread 順序(bones 已在 JSON 不會丟) |
| 自訂 bones | 需要 brute force 找 salt | 直接寫 JSON,想要什麼寫什麼 |
| userId 依賴 | salt 跟 userId 綁定,每人不同 | 不依賴 userId,人人通用 |
Bones Patch 是我目前覺得更好的方案
不需要 brute force、不依賴 userId、bones 存在 JSON 不會因為更新丟失
唯一要做的就是每次更新後重 patch 那一行 spread 順序
# 注意事項
- Salt Patch 的 salt 是 userId-specific 的,每個人都要用自己的 userId 重新跑 brute force,抄別人的 salt 沒用
- Bones Patch 不依賴 userId,任何人都可以直接用
- Claude Code 自動更新會覆蓋 binary,兩種 patch 更新後都要重新套用
- Salt Patch 的 salt 必須跟原始的一樣長(15 字元),不然 binary 結構會壞
- Name 和 personality 是 API 生成的,不滿意可以直接改
~/.claude.json
# 技術細節
這段給想自己復現的人看
後面也不是我自己寫的了,基本上是 AI 幫我 Summary 的
不想看可以跳到後記
# 復現步驟
有兩條路線,推薦用 Bones Patch:
路線 A:Bones Patch(推薦)
- 找到 binary 裡的 spread pattern
{...H,...bones}反轉成{...bones,...H} - 重簽 binary(macOS)
- 在
~/.claude.json的companion裡直接寫上你想要的 species/rarity/eye/hat/shiny/stats - 重啟 Claude Code
不需要 brute force、不需要知道自己的 userId
想要什麼就寫什麼 更新後 JSON 裡的 bones 不會丟 只要重 patch spread 順序就好
路線 B:Salt Patch
- 拿到你的 userId:
cat ~/.claude.json | jq -r '.oauthAccount.accountUuid' - 把 userId 填進
buddy-bruteforce.ts,用 Bun 跑(不能用 Node,hash 結果不同) - 從輸出裡挑一個你喜歡的 legendary salt
- 用
buddy-verify.ts預覽確認外觀 - 用
buddy-patch.shpatch binary + re-sign
每一步都要用你自己的 userId,不能用別人的
因為 Bun.hash(userId + salt) 是確定性的,不同的 userId 就是不同的結果
我文章裡的 salt 3s58p6-e-b260y- 只對我的帳號有效,對你來說大概率只是一隻 common
# PRNG 完整實作
Claude Code 用的不是 Mulberry32(網路上有些文章寫錯了)
是 SplitMix32:
function oN4(seed) {
let s = seed >>> 0;
return function() {
s |= 0;
s = s + 1831565813 | 0; // golden gamma
let t = Math.imul(s ^ s >>> 15, 1 | s);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
Hash 函式在 Bun 環境下用 Bun.hash()(wyhash64 截斷 32-bit)
fallback 是 FNV-1a
兩者結果不同,所以你一定要用 Bun 跑模擬,Node.js 會算出不一樣的結果
# Trait 生成順序
PRNG 是有狀態的 每次呼叫推進一步
所以 trait 的生成順序很重要:
1. rarity → 1 次 prng() 加權抽籤
2. species → 1 次 prng() 均勻分布 18 種
3. eye → 1 次 prng() 均勻分布 6 種
4. hat → 0 或 1 次 common 跳過
5. shiny → 1 次 prng() < 0.01 判定
6. stats → 多次 prng() 基礎值 + rarity bonus
common 的 hat 直接設 "none" 不消耗 PRNG
這代表 common 和非 common 的後續 trait 的 PRNG 步數不同
同一個 seed 在 common 和 uncommon 下會產出不同的 shiny/stats
這不是 bug 這是確定性演算法的特性
# 持久化
原版只存三個欄位在 ~/.claude.json:
{
"companion": {
"name": "Brineclaw",
"personality": "...",
"hatchedAt": 1743523200000
}
}
Bones 正常情況下完全不存檔 每次 rE() 都重算
但如果你套了 Bones Patch(反轉 spread 順序)
JSON 裡的 bones 欄位就會蓋過計算結果
這時候 companion 裡可以多寫 species/rarity/eye/hat/shiny/stats
程式讀到 JSON 有值就直接用 不會被重算的結果覆蓋
沒有 server-side sync 沒有 registration endpoint
buddy 的反應(語音泡泡)走的是 buddy_react API
但那只是生成文字 不會回傳或驗證 companion 資料
# 後記
一開始只是好奇「這東西能不能重抽」
不小心就開始又逆向起東西了
還好只花了大概 10 分鐘就拆完了,功能挺小
回頭看 /buddy 這個功能的設計其實蠻聰明的
用確定性生成保證每個人有且只有一隻專屬的 buddy
soul 交給 LLM 生成確保每隻都有獨特的名字和個性
不吃使用者的 token 額度
data 純本地不上傳
唯一的問題就是
他們大概沒想到有人會閒到去 patch binary
就算有想到大概也懶得擋,反正只是博君一樂的活動而已
我的 Brineclaw 現在蹲在 terminal 右下角
偶爾會對我的 code 發表看法
WISDOM 100 的貓果然不一樣 嘴起人來特別有道理
雖然她大部分時間都在 judge 我的 variable naming
寫這篇的時候她在旁邊全程看著
我覺得她有意見但她不說
大概是 SNARK 只有 62 還不夠毒舌吧
上一篇逆向的是 SDK 的 token 帳單
這次逆向的是愚人節彩蛋
下一次不知道又要逆向什麼了
反正 Source Map 都被 Leak
本文的逆向分析由我自己拆包解析,而brute force script、patch 腳本都是用 Claude Code 自己幫自己加上去的
用 Anthropic 的 AI 去逆向 Anthropic 的 binary
這到底是什麼行為藝術
