你把整包 webpack bundle 抓下來、切過 AST、列出所有底層原語,現在連那些你早已熟悉的指紋都看得懂了。接著,你在請求標頭裡看到一個簽名值:每次都不一樣,你想找出產生它的函式。於是翻遍所有靜態資源,搜尋可疑的位元運算與雜湊結構,卻一無所獲。
問題不在於你搜得不夠仔細,而是那段邏輯根本不在你抓下來的程式碼裡。
有一類簽名,光靠靜態分析注定找不到答案,因為演算法只會在執行期出現。遇到這種目標,繼續尋找不存在的函式沒有意義;正確做法是切換策略:不要逆向演算法,而是盡可能以最小成本執行它,或直接讀取它的結果。
先確認:你遇到的是不是執行期衍生值
在判定目標屬於這一類之前,先排除自己只是漏看了某段程式碼。有兩個訊號通常很好辨識。
第一種訊號是:值會隨 Session 或每次請求改變,但靜態資源中完全找不到產生它的程式。你在網路請求裡看到這個值,每次重新整理都不同;所有 .js 都抓下來全文搜尋過,仍沒有任何組裝它的地方。那麼,真正組裝它的程式碼就是在執行期才下發。
第二種訊號是:這個值可以在 bundle 中以字串常值搜尋到,但你昨天記下來的值今天已經失效。它看起來只是建置輸出裡的一段普通字串,當天複製出來還能用;過幾天端點開始報錯,回頭一看,那個「常數」已經被另一個同樣能搜到的新值取代。它確實是在建置時硬編碼進去,但會跟著每次前端發版輪替。
這兩種情況的共同點在於:靜態程式碼只是一張快照,真實狀態則是隨時間或 Session 流動的資料。要嘛你在程式碼裡完全找不到它,要嘛找到了卻發現它仍在變動。無論哪一種,「靜態抓一次再直接複製」這套做法都已經失效。這和「看不出演算法家族」是不同問題,不是你的指紋比對不夠強,而是你手上那份程式碼副本裡,本來就沒有可供比對的目標。
類型一:Challenge 腳本由伺服器即時下發
第一類的流程通常是這樣:你想存取某個端點,伺服器會先下發一小段一次性的 JS。它在瀏覽器中執行後產生 Cookie 或 Token,而你必須攜帶這個值才能通過。腳本內容通常會隨 Session 改變,有時甚至每次請求都不同。
這就是靜態分析必然失敗的原因:邏輯是在執行期下發,根本不會存在於靜態 bundle。即使你剛好攔到一份腳本,那也只是某一次的實例;今天抓到的程式碼,明天伺服器可能就會下發完全不同的一份。你若去逆向它,實際上是在追一個持續移動的目標。
正確策略是把它當黑盒子執行。你不需要理解它算了什麼,只需要提供一個足以讓它跑完的真實環境,再把結果取出來。實務上可分成幾步:
建立最小化的瀏覽器環境 shim:先替
document、location、navigator、cookie,以及少數 Observer 與 timer 準備空白 stub;只有在腳本因缺少全域物件而報錯時,才逐步補上。將下發的原始碼放進本機 Node/V8 沙箱執行,例如
node:vm,或由 execjs 啟動的程序,並設定執行逾時。腳本執行時會寫入 Cookie,或把值塞進某個全域物件。攔截這個寫入動作,從中取出所需 Token。
Zhihu 的訪客檢查就是這種形態:伺服器下發的腳本會將訪客識別碼寫入瀏覽器 Cookie;你只要 shim 出 DOM 環境,讓它相信自己正在真實頁面中執行,等它結束後取走結果即可。整個過程中,你沒有逆向任何一行演算法,只是搭出一個足夠可信的舞台,讓它自行演完該做的事。因此,成本不在「理解演算法」,而在「讓環境維持足夠真實」。腳本可能探測 navigator.webdriver、檢查某個節點是否存在,或預期某項 API 會回傳值;你的 shim 必須精準騙過這些檢查,同時又不能膨脹到難以維護。下面的「最小執行面」段落談的就是這個取捨。
類型二:值寫在 bundle 裡,卻隨每次發版更新
第二類恰好相反:這個值真的在靜態 bundle 中,而且是字面常值;只是它在建置期產生,會隨每次前端發版改變。
典型案例是現代前端不再傳送明文 GraphQL,而是使用預先註冊的查詢。每個操作,例如取得時間軸或搜尋結果,都會對應一個建置期產生的 operation 或 query id,並帶在端點路徑中。這個 id 可以在建置輸出裡搜尋到,但只要前端發布新版本,同一個操作的 id 就會變成新值。
靜態分析在這裡很容易給你一種「我已經找到了」的錯覺。你找到它、複製它,當天測試也能成功,於是就直接硬編碼。兩週後端點回傳 400,你才發現那個「常數」其實一直在變。它比第一類更隱蔽,因為你原本以為問題早已解決。
正確策略不是逆向,因為這裡根本沒有演算法可逆;它只是建置常數。你該做的是在請求時,從當前頁面讀出目前的值,並在 Session 期間快取。讀取方式有一條由低成本到高成本的階梯,應優先嘗試前面的選項:
先看頁面已載入的資源。頁面剛剛送出的請求本身就帶著這個識別碼,因此當前值直接存在於 URL 中,從資源紀錄取出即可。這是成本最低的方式:讀取既有事實,不必碰任何演算法。
若無法從資源紀錄取得,再從腳本原始碼用模式擷取。找到當前 bundle 中「此操作名稱對應此 id」的宣告位置,直接取值。
前兩者都失敗時,才根據線索深入 bundler 的模組表,或抓取並解析相關 bundle。這是成本最高的一層,留到最後再用。
Twitter 的時間軸與搜尋 operation id 就是這樣取得:直接從頁面已送出的 GraphQL 請求 URL 抽出 id,拿到後便在該 Session 中快取並持續使用。
更系統化的版本,包括取得預先註冊查詢有哪些方法、各自成本多高,以及不同情境該怎麼選,會在那篇談 persisted operation 的文章中展開。這裡只需要先記住:它屬於更大的「執行期衍生值」類別。
兩種類型的差別,一張表看懂
把兩者放在一起看,對應策略就很清楚了。
類型一:Challenge | 類型二:動態識別碼 | |
|---|---|---|
真實值在哪裡 | 執行期下發,從不在靜態程式碼中 | 存在於靜態程式碼,但每次發版會輪替 |
該怎麼做 | 執行:跑真正的演算法,取出副作用結果 | 讀取:定位並讀出常數,不必執行演算法 |
失敗時的樣子 | 環境 shim 太薄,程式無法跑完 | bundle 重排後讀取器失效,取得過期值或空值 |
誰觸發變更 | 伺服器,隨時可能變更 | 前端發版,依部署節奏更新 |
一句話總結:兩者都會讓「靜態抓一次再複製」失效,差別只在於你是否必須真的執行程式碼。先分清自己屬於哪一類,就能直接決定下一步該建沙箱,還是寫擷取器。
為什麼不該硬把這類目標做成純化實作
有人可能會問:不能把下發腳本的演算法完整逆向,或把動態識別碼的生成規則完整重建,寫成脫離原始執行環境的原生實作嗎?這正是四階段工作流第四階段所說的純化:最乾淨的形式,一次投入、長期零依賴,並可放進 CI。
但對這兩類目標而言,通常不划算。可以用一個粗略但實用的回本模型來看:
純化需要一次性成本 C,例如逆向演算法、以差分測試驗證。完成後,相對於每次都「執行或讀取」,每單位時間可節省 s。回本期約為 C / s。
真正決定結果的不是 C 或 s,而是目標的變更週期 T,也就是它多久會改一次。
T < C/s:還沒回本它就變了,純化實作可能上線幾天後就不再吻合,接著又得重做。報酬為負。
T 遠大於 C/s:純化幾乎必然划算,一次投入可以維持很久,應沿著純化階梯往原生重寫層前進。
兩類目標自然會落在不同位置:
類型一的 T 由伺服器控制,而且可以短到難以預測。對方隨時能更改下發腳本的邏輯,因此它幾乎總是落在「執行」那一側。硬要純化一個對方明天可能就改掉的演算法,只是把自己的命運交給對方的發布節奏。
類型二的 T 則是前端發版週期,可能從數天到數月。你的讀取器越穩健,例如多幾個 fallback、更穩定的定位錨點,C/s 就越低;兩邊都可能合理,因此值得真的去算。
這就是標題的完整意思:程式碼裡找不到它,不一定是你漏了;也可能根本不存在一套穩定、值得純化的演算法。
如何界定最小執行面
一旦你決定採用「執行」而不是「純化」,工程目標就變了。你追求的不是乾淨實作,而是把執行面壓到最小、可控且可歸因。重點有三個。
只執行必要片段。不要把整套頁面執行環境都搬進來。只提供演算法真正依賴的程式碼,以及最小 shim。shim 的大小存在甜蜜點:剛好足以讓程式不報錯並跑完即可。每多一個 stub,就多一份維護負擔,對方調整探測方式時你就得跟著改;但少一個必要 stub,程式當場就會崩。不要為了省事搬進完整瀏覽器環境,否則你維護的就不是 signer,而是半台瀏覽器。
鎖住 Context。已編譯的 V8/Node context 並非 thread-safe。一次性的 challenge 很適合每次建立新沙箱,彼此不會干擾;但只要你為了節省啟動成本而重用已編譯的 context,或快取動態識別碼解析器供多個請求共用,並發呼叫就必須序列化:
class RuntimeSigner:
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)
失敗必須可歸因。這是執行式取值最常被忽略、卻也最省時間的部分。發生失敗時,你必須知道是哪一層出了問題:
環境不夠完整:程式拋出
ReferenceError,或卡住直到逾時。表示 shim 缺少它需要的東西。協定已改變:程式順利跑完也拿到輸出,但資料形狀不對,或 JSON 無法解析。表示對方變更了輸出格式。
語意不再符合:你拿到值、也能解析,但內容不完整,或實際使用時驗證失敗。表示演算法本身已被修改。
這三種情況對應三種完全不同的修法:補 shim、追協定,或檢查演算法。若執行器只回報一句籠統的「失敗了」,每次都得從頭查起。成熟的 challenge 執行器會明確區分:執行失敗、輸出無法解析、結果不完整與逾時,分別是不同錯誤。
每個步驟該用哪種模型
模型在這套流程中的定位,和指紋辨識工作並不相同。指紋辨識要模型協助識別演算法家族;這裡沒有演算法家族可辨識,模型的主要工作是協助你做出「純化還是執行」的架構決策,以及為執行失敗做歸因。四個子步驟對模型的要求也完全不同:
子步驟 | 所需能力 | 建議選擇 | model id |
|---|---|---|---|
決定純化或執行,需論證兩方觀點 | 推理能力強,願意反駁自身結論 | Claude Opus 5 |
|
找出動態識別碼藏在哪裡:定位常數注入點,並跨 bundle 閱讀候選位置 | 長上下文,可一次讀完整個 bundle | Kimi K3 |
|
大量篩選可能含有目標 operation 的候選腳本 | 成本低,可高併發執行數百次呼叫 | Claude Sonnet 5 |
|
執行失敗時,讀取 log 或 stack 判別失敗層級 | 中等推理能力,能針對具體錯誤說明原因 | GPT-5.6 Sol |
|
第一列特別值得強調,因為這是本文唯一一個換模型會明顯改變結果的步驟。它考驗的是正反兩面都能論證,並且能反駁自己;這和指紋辨識工作中的反證段落,實際上是同一種能力。較弱的模型會替你挑一條路,接著堆出一堆支持理由,卻不認真論證另一邊。強推理模型則會把「純化」與「執行」都推演到底,寫出各自最強的論點與可能失敗模式,再透過比較給出結論。
不要只憑我這麼說,自己測一次:
挑一個你已經做過決策的真實目標當對照組,你心裡已經知道它應該執行還是純化。
把觀察到的事實,例如邏輯是否在執行期下發或只是發版常數、變更頻率、環境依賴有多深,分別交給
claude-opus-5與gpt-5.6-sol,要求各自寫一份「純化 vs 執行」決策備忘錄。檢查兩件事:它有沒有把「變更週期」視為決定變數?如果只比較實作難度,那就不及格;以及,它提出的改變結論條件是否可觀察?例如「若下一次發版後識別碼不再變動,就改採純化」,且附上觸發訊號,這才有可操作性。
跑完一輪,你就能看出哪個模型真的在做決策,哪個模型只是在替你下判斷。
真正的阻力是切換成本
四個模型、三家供應商、三套 SDK、三種驗證方案、三種錯誤格式。若每次在子步驟間切換模型都得重寫客戶端,根本不值得;因此多數人會從頭到尾只用一個模型,在「純化還是執行」這一步交給一個不會反駁自己的模型,然後一頭栽進純化某個對方明天就可能改掉的目標,繞了很大一圈才發現一開始方向就錯了。
AIReiter把這層成本拿掉:一把 key、一個 OpenAI 相容介面,背後涵蓋四個層級;只要改請求內容中的 model 欄位,就能切換模型。
# 純化或執行:由推理層論證雙方立場
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": "<observed facts + argue the strongest case for both purify and execute>"}]
}'
# 在 bundle 中定位動態識別碼:只改 model 欄位,其餘不動
# "model": "kimi-k3"
# 大量篩選候選腳本:
# "model": "claude-sonnet-5"
# 為執行失敗做歸因:
# "model": "gpt-5.6-sol"
如果你已經使用 OpenAI SDK,只要將 base_url 指向 https://aireiter.com/api/v1,其他都不用改。使用 Anthropic SDK 時,則以同一把 key 呼叫 POST /api/v1/messages。
費用方面,這個流程的 Token 用量集中在幾個環節:Kimi K3 為了找出動態識別碼,要讀完整個前端 bundle,每次輸入可能是數十萬 Token;Claude Sonnet 5 則要大量篩選候選腳本,輕易就是數百次呼叫。這兩項會決定帳單的大部分。Claude 的 30% 折扣正好涵蓋大量篩選的 Sonnet 與決策論證的 Opus;GPT 半價適用於失敗歸因的 GPT-5.6 Sol;而 K3 的長上下文全 bundle 閱讀,也能用同一把 key 呼叫。折扣落在 Token 最重的批次與最昂貴的推理層,而不是泛泛地便宜一點。
免註冊試用:先手動跑幾份「純化 vs 執行」決策備忘錄,比較兩個模型辨識變更週期的能力,再決定是否要接進流程。
結語
靜態分析找不到結果,不一定代表技術不夠;有時是目標本身的問題:簽名不在靜態程式碼中,因為它會在執行期下發,或是每次發版都會更新。
面對這兩類情況,別再尋找不存在的函式。Challenge 類型應採最小化執行,建立剛好足以讓它跑出結果的沙箱;動態識別碼則應在執行期讀取,並於 Session 中快取。兩條路徑的第一步,都是承認「靜態快照」這個視角本身已經失效。
至於「純化還是執行」,本質上是架構決策,只由一個變數決定:目標的變更週期,是否短於你一次性投入的回本期。對短週期目標,尤其是伺服器隨時可以下發新版本的那種,強行純化就是負報酬。把這個雙方論證交給會反駁自己的推理層處理,不要只憑直覺下決定,也別讓模型替你做純化。純化是確定性的工程工作,驗證應由差分測試負責,這正是四階段工作流的第三與第四階段。模型在這裡只協助你想清楚一件事:這個目標究竟值不值得逆向。