模型讀完你的葉節點函式,很篤定地下了結論:「這是 ChaCha20,只是輪數被改成由金鑰導出的變數。」聽起來有理有據,甚至讓人想直接照著重寫。
先別急。在你把這句話變成能執行的 assert 之前,它就只是一句話而已。不管模型見過多少公開實作,它都無法驗證這份實作是否真的判對;這不是語言理解問題,而是事實問題。唯一的答案,是讓兩邊對同一組輸入執行,再逐位元組比對輸出。
能回答這個問題的工具只有一個:差分測試。它是四階段流程中的第 3 階段,也是所有偏差點追查假設最終都必須抵達的終點。模型負責提出可疑之處,差分測試負責作出裁決。
別再問模型「這樣寫對嗎?」
剛開始用模型逆向時,最常問的一句話通常是:「你能確認這個實作正確嗎?」
這個問題從三個層面來看,都是死路一條。
第一,模型傾向順著你的期待回答。「這個實作正確嗎」本身就暗示了你希望它正確;模型很容易捕捉到這種語氣,回你一句「是的」。
第二,它其實沒有判斷依據。所謂「正確」,唯一的標準是黑箱程式與你的重寫版本,在同一批輸入下是否產生相同輸出;但那些輸出不在模型的上下文裡。它沒有可供比較的 ground truth,只能憑程式碼看起來是否「合理」來猜,而混淆程式碼最擅長的,正是讓自己看起來合理。
第三,你問錯對象了。正確性不是可協商的意見,而是某個等式是否成立的事實。不要把問題交給模型,交給 assert。模型說對、測試失敗,測試贏;模型說錯、所有測試都通過,還是測試贏。只要把最終裁決交給模型,你的基礎就建立在幻覺之上。
邊界測資才是真正有價值的輸入
差分測試的骨架很單純:把原始程式當黑箱,讓它與重寫版本針對同一批輸入逐筆比較。真正有價值的不是那個迴圈,而是你餵進去的輸入集合。
拿一萬個隨機的普通字串去跑,就算全部綠燈也證明不了什麼。正常輸入大多只走主路徑,偏差點往往藏在邊角。真正能帶來資訊量的是邊界條件:
# Boundary cases: derived from block size B
def boundary_cases(B: int) -> list[bytes]:
return [
b"", # empty: exposes initial state and padding logic
b"\x00", # single zero byte
b"\xff", # single high byte: checks sign-bit / unsigned handling
b"A" * (B - 1), # one below the block boundary
b"A" * B, # exactly one block
b"A" * (B + 1), # one above: checks carry and padding
bytes(range(256)), # full byte coverage: checks the alphabet map covers the whole domain
]
每一筆都在盤問特定分支。高位元組 \xff 會迫使符號位元處理現形:JS 裡 >>> 與 >> 的差異、Python 是否漏了 & 0xff,都會在這一筆暴露。最兇的是 B-1 / B / B+1 這組三連測資,區塊填充邏輯通常只有在這裡才會顯露。完整位元組覆蓋則用來測自訂字母表;映射表多一個字元、少一個字元,這筆測試都會直接失敗。
差分測試本體不到十行:
# Original as black box, rewrite compared case by case
def diff_test(blackbox, rewritten, B: int) -> None:
for case in boundary_cases(B):
got, want = rewritten(case), blackbox(case)
assert got == want, f"len={len(case)} hex={case.hex()}"
還有一點值得注意:當你的偏差點假設涉及標準演算法時,未必一定要靠原始黑箱程式比對。公開標準往往就附有權威測試向量。例如 RFC 8439 §2.1.1 提供了 ChaCha20 quarter round 的固定輸入與輸出:輸入 a=0x11111111, b=0x01020304, c=0x9b8d6f43, d=0x01234567,會得到 a=0xea2a92f4, …。先讓你的重寫版本通過標準向量,再拿去比對目標黑箱,就能清楚區分兩類問題:是「我把 ChaCha 實作錯了」,還是「目標程式修改了 ChaCha」。
注意 assert 訊息裡帶了 len(case)。這是整套測試裡最有用的一行診斷資訊。七筆裡若只有 B+1 失敗,問題幾乎肯定落在填充或進位;若只有完整位元組覆蓋失敗,問題就在字母表映射。失敗案例的長度會直接指向錯誤所在的層,不必靠猜。
同一筆輸入,務必跑兩次
重寫簽名邏輯時,最常見的卡點不一定是演算法辨識錯誤,而是某個尚未抽離的熵來源。
要抓出這種問題,只需要一行:同一筆輸入跑兩次。
# Same input twice: differing results mean a hidden RNG / timestamp
def assert_deterministic(fn, case: bytes) -> None:
assert fn(case) == fn(case), "unfixed entropy source or timestamp present"
如果兩次結果不同,代表實作混入了 time.time()、nonce、自增計數器,或其他每次呼叫都會變動的值。這時連差分比較都做不了:黑箱每跑一次都給不同答案,你要拿什麼當比較基準?
解法不是刪掉熵來源,刪掉後簽名就錯了;而是把它從程式內部抽成可注入參數,並在差分測試時固定住:
# Lift the entropy source into an injectable parameter, pin it with a stub for diffing
class Rewritten:
def __init__(self, clock=time.time, rng=os.urandom):
self._clock = clock # formerly an inline time.time(), now injected
self._rng = rng
def __call__(self, data: bytes) -> bytes:
ts = int(self._clock()) # for diffing, clock=lambda: 0
nonce = self._rng(16) # for diffing, rng=lambda n: b"\x00" * n
...
目標黑箱也要做同樣處理:找到它注入時間戳或亂數的位置,再設法固定它,例如在頁面中 hook Date.now,或為 Node 傳入固定 seed。雙方的熵都固定後,輸出才會恢復可重現性,差分才有意義。端對端測試通過後,再把 clock 與 rng 換回真正的實作。
這同樣不是模型能幫你解決的事。熵藏在哪裡、如何注入,是執行期行為;你得靠實際執行兩次逼它現形,而不是光靠讀程式碼。
從最內層開始比,不要只盯著最終輸出
假設你的重寫版本最終輸出和黑箱對不上。別只盯著那串最後的位元組,因為它是多個巢狀層次共同產生的結果,而你還不知道是哪一層出了錯。
簽名程式通常有分層結構:最內層是 hash 或區塊加密,外面包一層編碼,例如 Base64 家族、hex 或自訂表,再由最外層負責組裝,例如串接前綴、插入欄位、加上長度標頭。逐層剝離的意思是由內往外比,當前一層通過後,才往外推進一層:
# Peel layer by layer: match the innermost first, then move outward
LAYERS = ["digest", "encode", "assemble"] # inner → outer
def first_divergent_layer(bb_dump, rw_dump, case: bytes) -> str | None:
for layer in LAYERS:
if bb_dump(case)[layer] != rw_dump(case)[layer]:
return layer # the first layer to diverge is the faulty one
return None
這要求你的重寫版本能輸出每層的中間狀態,也要求你能從黑箱取得對應中間值,通常得靠執行期插樁。這些額外工作非常值得:第一個出現差異的層,就是錯誤所在,排查範圍會立刻收斂。
這也直接連回偏差點的四個分類:若在 digest 層分歧,通常是常數被擾動,或輪數遭到修改;若在 encode 層分歧,多半是字母表被重新排列;若在 assemble 層分歧,通常是某些材料被嵌進輸出。分歧發生在哪一層,就告訴你該回到指紋辨識文章中的哪一類問題去找。
Fixture 的價值會隨時間累積
差分測試轉綠的那一刻很痛快,但那只代表當下。上游明天發了新版本,今天驗證過的實作可能立刻完全失效。
真正會隨時間增值的產物,是提交進 repository 的 fixture:一張已知輸入對應已知輸出的表。
# Fixture: known input → known output, committed to the repo
# Re-run on every upstream change; green yesterday, red today = upstream changed, not you
VECTORS = load_json("vectors.json") # [{"in": "<hex>", "out": "<hex>"}, ...]
for v in VECTORS:
got = rewritten(bytes.fromhex(v["in"])).hex()
assert got == v["out"], v["in"]
它的價值會在上游變動時立刻兌現。某天 CI 轉紅,昨天你根本沒改的實作開始對 fixture 失敗,這個訊號極其珍貴:它排除了「是不是我改壞了」,將問題唯一指向「上游變了」。沒有 fixture,你可能會花半天除錯一段其實完全正確的程式,因為你分不清究竟是自己出錯,還是對方改了規則。
最適合寫進 fixture 的輸入,就是前面那些邊界案例;它們本來就是你手上覆蓋率最高的一組測資。
到了這一步,模型只做兩件事
差分測試裡的分工可以講得很直接:模型只做兩件事,沒有資格參與最終判定。
第一,批次產生案例。針對多種 primitive 大量列出邊界輸入,或組出只差一個 bit 的輸入批次來探測可疑常數,這是列舉工作,不需要推理能力;選便宜、高併發的 tier 最划算。
第二,解釋差異。當案例失敗時,把兩側的 layer dump 擺到模型面前,讓它根據具體位元組說明第一個分歧點在哪裡,以及最可能屬於四類偏差中的哪一類。這一步需要中等推理能力,也要求模型能「對著位元組解釋」;這才是本文中模型真正派上用場的地方。
裁決權始終屬於 assert,這點不會改變。模型的解釋只是線索,不是結論;線索指錯方向很正常,而 assert 會把它攔下來。
這兩件事對模型能力的要求不同。整個流程只用一個 tier,不是浪費預算,就是犧牲精準度:
差分測試子步驟 | 所需能力 | 建議選擇 | model id |
|---|---|---|---|
批次產生邊界/控制案例(跨多種 primitive) | 低成本、高併發;列舉不需要推理 | Claude Sonnet 5 |
|
閱讀單一失敗 diff,根據位元組解釋差異 | 中等推理能力;歸因必須落到具體層次 | GPT-5.6 Sol |
|
歸因無法收斂時,深挖根本原因(例如常數擾動) | 強推理能力;可跨多輪中間狀態推斷 | Claude Opus 5 |
|
吞入大量 layer dump/整批 fixture 來尋找分歧 | 長上下文 | Kimi K3 |
|
真正的主力是第二個 tier。差異歸因值不值得特別挑模型,自己跑一輪測試就會知道。流程很短:
從差分測試套件中挑一筆確實失敗的案例,附上黑箱與重寫版本各自的 layer dump。
分別把同一份 diff 交給
gpt-5.6-sol與claude-opus-5,只問兩件事:第一個分歧在哪一層,以及最可能屬於四類偏差中的哪一類。只看它的歸因是否落到特定 byte 與特定 layer,還是只丟給你模糊的「可能是 padding 問題」。
歸因精準度就是你的選擇標準,因為它直接決定你要修改幾輪,才能讓這筆案例轉綠。
只要一輪,你就能比任何 benchmark 排行榜都更直接地看出差異。
真正麻煩的是切換成本
這四個 tier 來自三家供應商:三套 SDK、三種驗證機制、三種錯誤格式。只為了在子步驟之間切換 tier,就把 client 串三次,並不划算。這也正是為什麼大多數人最後會用單一 tier 做所有事,拿一個只會含糊其詞的模型處理差異歸因,改了好幾輪還不知道問題在哪。
AIReiter 把這層複雜度攤平:一把 key、一個相容 OpenAI 的介面,四個 tier 全都在後面;切換時只要改請求內容裡的 model 欄位。
# Difference attribution: the mid-reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.6-sol",
"messages": [{"role": "user", "content": "<diff of the two layer dumps + locate the first divergent layer and deviation category>"}]
}'
# Attribution won't converge, escalate to dig the root cause: change one field
# "model": "claude-opus-5"
# Bulk-generate boundary cases:
# "model": "claude-sonnet-5"
已經在用 OpenAI SDK?把 base_url 指向 https://aireiter.com/api/v1,其他都不用改。使用 Anthropic SDK?用同一把 key 呼叫 POST /api/v1/messages 即可。
價格方面,Claude 模型比定價低 30%,GPT 模型則是半價。對這套工作流來說,折扣剛好落在呼叫最密集的環節:差異歸因是差分測試中最常被呼叫的部分。每個失敗案例都要跑一輪;每次上游改版,就得重建 fixture,並為新一批失敗案例逐一歸因。主力的 gpt-5.6-sol 是半價的 GPT 模型,因此最密集的工作直接砍半;偶爾升級到 claude-opus-5 深挖根因,呼叫次數雖少,同樣可享 Claude 模型 30% 折扣。
免註冊直接試用——先手動把一份真實 diff 丟給兩個 tier,比較兩者的歸因精準度,再決定要串接哪一個。
結語
逆向工程中的重寫工作,其實只靠兩根支柱:模型提出假設,assert 做出裁決。
模型是看過各種公開實作的可疑點產生器。它能在幾秒內告訴你「這裡可能被改過」,但永遠不知道這次自己是否判對。差分測試則是把「可能」轉成「是/不是」的機器:邊界輸入逼出分支、同一輸入跑兩次逼出熵來源、逐層剝離鎖定錯誤層、fixture 則幫你分辨「是我寫錯」還是「對方改了」。
