差分測試:逆向工程中攔住模型幻覺的唯一關卡

最近更新: 2026-07-30 10:58:49

模型讀完你的葉節點函式,很篤定地下了結論:「這是 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。雙方的熵都固定後,輸出才會恢復可重現性,差分才有意義。端對端測試通過後,再把 clockrng 換回真正的實作。

這同樣不是模型能幫你解決的事。熵藏在哪裡、如何注入,是執行期行為;你得靠實際執行兩次逼它現形,而不是光靠讀程式碼。

從最內層開始比,不要只盯著最終輸出

假設你的重寫版本最終輸出和黑箱對不上。別只盯著那串最後的位元組,因為它是多個巢狀層次共同產生的結果,而你還不知道是哪一層出了錯。

簽名程式通常有分層結構:最內層是 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

claude-sonnet-5

閱讀單一失敗 diff,根據位元組解釋差異

中等推理能力;歸因必須落到具體層次

GPT-5.6 Sol

gpt-5.6-sol

歸因無法收斂時,深挖根本原因(例如常數擾動)

強推理能力;可跨多輪中間狀態推斷

Claude Opus 5

claude-opus-5

吞入大量 layer dump/整批 fixture 來尋找分歧

長上下文

Kimi K3

kimi-k3

真正的主力是第二個 tier。差異歸因值不值得特別挑模型,自己跑一輪測試就會知道。流程很短:

  1. 從差分測試套件中挑一筆確實失敗的案例,附上黑箱與重寫版本各自的 layer dump。

  2. 分別把同一份 diff 交給 gpt-5.6-solclaude-opus-5,只問兩件事:第一個分歧在哪一層,以及最可能屬於四類偏差中的哪一類。

  3. 只看它的歸因是否落到特定 byte 與特定 layer,還是只丟給你模糊的「可能是 padding 問題」。

  4. 歸因精準度就是你的選擇標準,因為它直接決定你要修改幾輪,才能讓這筆案例轉綠。

只要一輪,你就能比任何 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% 折扣。

結語

逆向工程中的重寫工作,其實只靠兩根支柱:模型提出假設,assert 做出裁決。

模型是看過各種公開實作的可疑點產生器。它能在幾秒內告訴你「這裡可能被改過」,但永遠不知道這次自己是否判對。差分測試則是把「可能」轉成「是/不是」的機器:邊界輸入逼出分支、同一輸入跑兩次逼出熵來源、逐層剝離鎖定錯誤層、fixture 則幫你分辨「是我寫錯」還是「對方改了」。

四階段流程偏差點追查文章交給你的每一項判斷,最後都必須通過這道關卡。模型說什麼都不算,只有 assert 說的才算。