LLM 輔助 JS 逆向工程:AI 能做的四件事,以及不能交給它的三件事

最近更新: 2026-07-30 10:22:26

拿到一個經過 webpack 打包的簽名 SDK,裡頭變數不是 a,就是 _0x3f2b;控制流程被打平,字串常數則藏在陣列裡靠索引取用。你的目標其實很明確:把它改寫成獨立實作,而且輸出必須和原始程式逐位元組一致。

現在多數人的第一反應,都是把整包程式碼丟給大型語言模型,問一句「這段程式在做什麼?」問題通常也就從這一步開始。模型回覆的解釋看起來很合理,照著實作後,結果卻連一個位元組都對不上。

這不代表模型不夠強,而是分工放錯了位置。做去混淆時,模型擅長的是辨識模式與提出假設,不擅長的是確認事實。它能從一堆位元運算裡看出某個密碼學原語的骨架,但無法告訴你自己的判斷是否正確;這件事只能由測試裁決。

以下這套四階段流程,就是圍繞這個分工設計;文末也會談三件我絕不交給模型的事。

第一階段:機械式切分,不要交給模型

很多人會想:「上下文視窗都這麼大了,整包貼進去不就好了?」別這麼做,原因有兩個。

第一是浪費。混淆後的 bundle 大多是 polyfill、執行期 shim,以及和目標毫無關聯的業務模組。你花成本把它們塞進上下文,換來的卻是被稀釋的注意力。

更關鍵的是:上下文越大,模型幻覺可落腳的位置越多。它可能把兩個毫不相干模組的特徵拼成一個內部自洽、實際上根本不存在的結論。這種錯誤比明顯的胡言亂語難抓得多。

切分是確定性的工作,直接用腳本處理:

  • 用 AST 工具(@babel/parseracorn)把 bundle 拆成模組與函式,再依作用域建立索引;

  • 抽出所有數值字面量與字串常數,依出現頻率和位元寬度分組;

  • 建立呼叫圖,標出入度為 0 的節點(入口點)與出度為 0 的節點(底層原語);

  • 找出位元運算密度異常高的函式——在同一個函式裡密集出現 ^>>><<&,通常就是演算法核心。

真正該交給模型看的,是這些底層原語。它們通常只有幾十行、不依賴上層狀態,而且輸入與輸出邊界清楚。一個 40 行的葉節點函式,加上它引用的常數,正是模型能穩定處理的粒度。

完成這一階段後,你應該會有一份「候選原語清單」:每筆包含函式本體、引用常數,以及呼叫來源。後續每次模型呼叫,都只處理清單中的一筆,一次一個。

第二階段:讓模型辨識演算法家族

這正是模型最難被取代的環節。

密碼學與編碼演算法都有很強的指紋:特定常數、特定的位移量組合、特定的迴圈結構。人類靠經驗累積才能辨認;模型看過大量公開實作,這類比對對它來說相對自然。

以下是幾個公開案例,讓你理解什麼叫做「指紋」:

  • 0x811c9dc50x01000193 同時出現時,分別是 32 位元 FNV-1a 雜湊的 offset basis 與 prime——這是共同作者 Landon Curt Noll 維護的 FNV reference page 所公布的標準值(頁面以十進位列為 2166136261 與 16777619);

  • 0x61707865, 0x3320646e, 0x79622d32, 0x6b206574 是 ASCII 字串 "expand 32-byte k" 的小端序字組,也就是 ChaCha20 初始狀態常數,可見於 RFC 8439 §2.3

  • 若結構類似 a += b; d ^= a; d = rotl(d, 16); c += d; b ^= c; b = rotl(b, 12);,且四個 rotation amount 是 16/12/8/7,那就是 ChaCha20 quarter round 的特徵;同系的 Salsa20 使用 7/9/13/18,光靠這四個數字就足以區分兩者;

  • 一張有 64 個項目、開頭為 0xd76aa478 的常數表,是 MD5 的 T-table(RFC 1321 §3.4);

  • 一張以 0x63, 0x7c, 0x77, 0x7b 開頭的 256 位元組表,是 AES S-box(FIPS 197,Table 4);

  • 0xEDB88320 是反射式 CRC-32 多項式,也就是 gzip spec, RFC 1952 採用的值。

成敗關鍵在於你怎麼問。問「這段程式做什麼?」只會得到一段說明文字。你要的是可驗證、結構化的判斷,因此提示詞必須強制模型輸出三個部分:候選項、證據、反證。

Below is a leaf function extracted from an obfuscated bundle, together with
every numeric constant it references.

<function>
{{function body}}
</function>

<constants>
{{constant list, with locations}}
</constants>

Answer in the following structure. Do not write prose.

1. Candidate algorithm families (at most 3, ranked by likelihood)
   For each: the name, and which class it belongs to
   (hash / stream cipher / block cipher / encoding / compression / checksum)

2. Supporting evidence
   Every piece of evidence must point to a specific constant value or a
   specific line above. "Structurally similar" and other unverifiable
   phrasing is not allowed.

3. Counter-evidence and deviation points
   If this were the standard implementation of that algorithm, what should
   appear here but doesn't? What appears that a standard implementation
   would never contain? Are these deviations a "variant," or "I got it wrong"?

4. The minimal test to decide this hypothesis
   Give 3 concrete inputs and, if the hypothesis holds, the shape of the
   output each should produce. Include boundary cases.

第 3 部分是這份提示詞最有價值的地方。標準實作的偏差點,正是這段程式被修改過的位置:自訂字母表、替換過的常數、調整後的輪數。真正需要你花功夫重寫的,也只有這些偏差;其餘大部分都能直接參考公開實作。如何讓模型把每一個偏差都攤出來,是演算法指紋辨識文章的主題。

第 4 部分則把模型的判斷直接轉成下一步要跑的測試,省掉一次來回溝通。

順帶一提,這一階段很常碰到非標準編碼。判斷方式其實很機械:字母表長度為 65(64 個字元加 1 個 padding 符號)、每組處理 6 位元、輸出長度為 ceil(n/3)*4,那就是 Base64 家族;若字母表順序不同,就是自訂表。又例如字典從 256 起算並持續成長,輸出碼寬會隨字典填滿而增加,這就是 LZW。光看輸入輸出長度關係就能驗證,根本不必讀程式碼。

第三階段:把假設變成差異測試

模型給你的只是個假設。在你把測試寫出來之前,它仍然只是一句話。

這裡沒有捷徑,而且它是整套流程中唯一能攔下幻覺的關卡(為什麼是唯一的關卡,詳見差異測試文章)。把原始實作當成黑箱,逐筆將你的改寫版本與它比對:

# Differential-test skeleton: original as black box, rewrite compared case by case
CASES = [
    b"",                      # empty input: exposes initial state and padding logic
    b"\x00",                  # single zero byte
    b"\xff",                  # single high byte: checks sign-bit handling
    b"a" * 63,                # one below the block boundary
    b"a" * 64,                # exactly one block
    b"a" * 65,                # one above: checks padding and carry
    bytes(range(256)),        # full byte coverage: checks alphabet mapping
]

for case in CASES:
    assert rewritten(case) == blackbox(case), case.hex()

有幾條實務上的準則值得記住:

跨越邊界的案例資訊量最高。區塊演算法最容易在 64n64n±1 暴露 padding 邏輯。若整輪測試只有一筆失敗,該輸入的長度往往直接告訴你是哪一層出了問題。

同一筆輸入要跑兩次。若結果不同,實作裡混入了亂數或時間戳。這時必須找出注入點,讓外部能覆寫它;否則根本無法做差異比較。改寫簽名邏輯時,這是最常見的卡點:不是演算法錯了,而是熵來源還沒被拆出來。

逐層拆開比,不要一口氣比完整輸出。先讓最內層雜湊一致,再處理編碼層,最後才是組裝層。完整輸出不一致時,你無法知道錯在哪;拆層後,第一個失敗的層就是問題所在。

把固定測試向量提交成 fixture。一張已知輸入 → 已知輸出的表,能讓你在上游更新後迅速判斷:「是我寫錯了,還是對方改了?」這份 fixture 的價值只會隨時間增加。

在這一階段,模型的工作是產生測試案例、協助解釋差異,而不是裁定對錯。真正判定對錯的是 assert

第四階段:依傳輸層級逐步落地

當所有假設都通過後,就該把它變成可長期運行的程式碼。這裡有明確的優先順序,越前面的層級越值得爭取:

  1. 以目標語言原生重寫。完全脫離原始執行環境,只依賴標準函式庫。這是唯一不需要額外程序、額外依賴,且能乾淨接入 CI 的形式。

  2. 在本機 JS 引擎執行最小片段。有些邏輯短期內不值得完全純化,因此保留一小段原始 JS,在本機 Node/V8 執行。要注意,JS 引擎 context 並非 thread-safe;多執行緒呼叫同一個編譯 context 時,必須加鎖:

class Signer:
    def __init__(self, source: Path) -> None:
        self._context = execjs.get("Node").compile(source.read_text())
        self._lock = threading.Lock()  # V8 context is not thread-safe

    def call(self, fn: str, *args):
        with self._lock:
            return self._context.call(fn, *args)
  1. 被動式瀏覽器橋接。若某些狀態只能從真實頁面執行環境取得,目前就只能靠瀏覽器。這應視為暫時方案,務必在介面說明中明確標註,絕不能讓它變成預設實作。

這套降級順序值得寫進專案慣例。每往下一層,依賴面、故障模式與部署成本都會跳升一個量級——第 1 層是純函式,第 3 層則是需要人維持 session 的外部程序。預設就爭取第 1 層,能避免「先能動再說」在不知不覺間堆出的長期成本。三個層級該怎麼劃分,以及如何避免被動橋接失控,可參考純化階梯文章

給一個參考數字:一個混淆過的簽名 SDK,完整走過四個階段後,可收斂成不到 600 行的獨立實作,而且只依賴執行環境內建的 crypto。這種壓縮比不代表模型「理解」了原始檔案;真正原因是演算法家族一旦辨識正確,絕大多數程式碼都能直接從公開實作取得。

不同階段,該用不同模型

這四個階段需要的能力完全不同。從頭到尾只用一個模型,不是浪費成本,就是犧牲準確度:

階段

真正需要的能力

我的選擇

model id

切分後的結構映射

長上下文,可一次讀完一個模組的呼叫圖

Kimi K3

kimi-k3

辨識演算法家族與提出反證

推理能力強,能找偏差,也會反駁自己的結論

Claude Opus 5

claude-opus-5

大量符號重新命名、補齊註解

成本低,可高併發執行數百次呼叫

Claude Sonnet 5

claude-sonnet-5

差異歸因(測試失敗時閱讀 diff)

中等推理能力,能針對特定位元組解釋差異

GPT-5.6 Sol

gpt-5.6-sol

第二階段尤其值得細看。辨識演算法家族,是唯一會因為換模型而明顯改變結果的步驟,因為它測試的正是「你看過多少公開實作」以及「你是否願意反駁自己」。面對同一個葉節點函式,較弱的模型會很有自信地給出錯誤答案;較強的模型則可能在反證段落裡劃掉自己的候選項。

別只相信我說的差異,自己測一次就知道。流程如下:

  1. 從自己的混淆 bundle 挑 3 個葉節點函式,至少有 1 個你已經知道答案,作為對照組。

  2. 使用第二階段的三段式提示詞,分別把相同輸入餵給 claude-opus-5gpt-5.6-sol

  3. 只看兩件事:是否命中候選家族;以及反證段落是否真的在反駁自己,還是只是換個說法重述候選項

  4. 反證段落的品質就是模型選型標準——它直接決定你在第三階段會寫出多少無效測試。

第三與第四階段幾乎不需要挑模型,能正常跑的都可以。第一階段只需要長上下文模型,替你省掉自己撰寫分段檢索的工夫。

真正的阻力不是選模型,而是切換成本

四個模型、三家供應商,代表三套 SDK、三種驗證方式、三種錯誤格式。為了省一點費用就重寫客戶端並不划算——這也正是多數人最後會用同一個模型處理所有工作的原因。

AIReiter 把這層摩擦拿掉:一把 key、一套相容 OpenAI 的介面,背後可用全部四個模型;切換時只需改請求本文中的 model 欄位。

# Algorithm-family ID: the reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-opus-5",
    "messages": [{"role": "user", "content": "<Stage 2 prompt + leaf function + constants>"}]
  }'

# Bulk symbol renaming: change the model field, leave everything else
#   "model": "claude-sonnet-5"

如果你已經使用 OpenAI SDK,只要把 base_url 指向 https://aireiter.com/api/v1,其他都不用改。若使用 Anthropic SDK,則以相同的 key 呼叫 POST /api/v1/messages

價格方面,Claude 模型享定價 7 折,GPT 模型則是半價。對這套流程來說,影響比聽起來更大:第三階段的大量符號重新命名,很容易累積到數百次呼叫;第一階段的長上下文處理,單一輸入就有數十萬 token。這兩者才是成本大宗,而折扣正好落在最昂貴的部分。

有三件事,不該交給模型

第一,不要直接讓它產出最終實作。要求模型「完整重寫」時,得到的通常是一份看似完整、也能執行,但藏著細微偏差的程式碼。你無法定位偏差,因為這份程式並非由你逐層驗證而來。正確做法是讓它為每個原語提出假設,你逐一驗證後自行組裝。雖然較慢,但你會知道每一行為什麼要這樣寫。

第二,不要讓它判斷結果是否正確。「你能確認這個實作是對的嗎?」是沒有出口的問題——模型往往會同意你。唯一能裁定對錯的是差異測試。模型說對但測試失敗?以測試為準。模型說錯但所有測試都通過?一樣以測試為準。

第三,不要讓它替你做合規判斷。你是否可以碰觸目標、是否可以公開結論、取得的資料可如何使用,都取決於你的司法管轄區、目標的條款與具體用途。模型對這些事沒有事實依據;它的答案只是模仿它見過的免責聲明語氣。這個判斷必須由你自己做,或交給真正的法律顧問。

結語

模型在這類工作中的定位很明確:它是一個看過大量公開演算法實作的模式辨識器,能在幾秒內交給你候選假設。它不是答案來源,也不是驗證者。

這套流程的骨架與是否使用 AI 無關:機械切分、提出假設、驗證、純化。模型出現前,這四步本來就是既有流程。模型壓縮的只是「提出假設」這一步,將翻參考資料所需的數天縮短為數分鐘;其餘三步所需的成本,和過去一樣沒有改變。

當你把這四個步驟中的模型呼叫固化成腳本,整套流程就會跑得很快。這時剩下的摩擦,只有切換模型——那是基礎設施問題,可透過統一介面上的模型選擇解決。