vatt'ghern jaskier's ballads
本文 2 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

一條 pipeline 吐出 20,799 個 raw 漏洞候選,過完所有關卡只剩 7,245 個進到工程團隊手上。讓這條 funnel 站得住的,是把 LLM 降格成可替換算力、再讓兩個來歷不同的 model 互相反證的那層 harness——換掉任何一個 model,這層都原封不動。

Cloudflare 的漏洞獵捕 harness——把 LLM 當可替換元件的對抗式設計

多數「AI 找漏洞」的敘事都押注在單一 frontier model 上:模型夠強,丟給它一個 codebase 就會吐出漏洞。Cloudflare 的這篇文章把賭注下在完全相反的地方——他們主張有效的 AI 安全靠的是一層 model-agnostic 的 orchestration,而不是某個最強的 model。用他們自己的話:「models should be frequently interchanged and cross-tested」,並且「By varying the models across the pipeline」,讓「vulnerabilities are cross-checked by distinct sets of logic.」結論一句話收尾:「The harness is the bit that lasts.」具體做法是兩件事:把所有 state externalise 出去、把 LLM 當成 stateless compute engine;再用兩個不同的 model 跨階段 cross-check。整套系統拆成負責 discovery 的 VDH(Vulnerability Discovery Harness)跟負責第二輪 triage 的 VVS(Vulnerability Validation System),兩者刻意用不同 model,互相 double-check。底下幾個小節分別講這套設計的支柱:八階段的 discovery pipeline、外部化的 state、對抗式驗證、以及一組反直覺的 production 數字。

先說清楚為什麼這個賭注值得一個工程師認真看。把 model 當主角的系統有一個結構性弱點:你的整套價值綁死在某一代 model 的能力與脾氣上,下一代 model 換上來、prompt 行為一變,你辛苦調出來的 pipeline 就可能整條失效。Cloudflare 反過來把 model 降格成「可替換的算力」,把所有會累積、會被 debug、會被優化的東西都留在 model 外面——stage 之間的 contract、SQLite 裡的 state、schema 強制的欄位順序、deterministic 的 dedup 索引。換句話說,當 model 是插槽裡的零件時,你能用工程的方式對待整個系統:可以 retry、可以 A/B、可以在 discovery 用一個 model 而 validation 用另一個,而不需要重寫任何一條 orchestration 邏輯。對任何要把 LLM 放進 production pipeline 的人,這個「把 state 跟 model 解耦」的決定,比「挑哪個 model」重要得多——因為前者是你能維護一年的資產,後者每三個月就會被刷新一次。

先把整條 funnel 的形狀放在眼前。下面這個互動圖把 Cloudflare 公布的幾個關卡數字串成一條過濾鏈——你可以一格一格往右推,看候選池在每一道關卡縮掉多少、survival rate 怎麼掉。這不是示意圖,每個節點都是文章裡的真實數字。

actionable
7,245actionable findings
整條 funnel 的終點——交給工程團隊的 actionable findings
三個節點都是 Cloudflare 直接公布的數字:raw 20,799、survived 12,057、actionable 7,245。這裡刻意只放原文逐字給出的關卡數,不放任何推算值——dedup 砍掉的 5,442 個 duplicate 與全系統 13,841/145 repo 的總量是另一個尺度的數字,留在正文裡單獨講,不混進這條 funnel。拖動滑桿走過每道關卡,右下角顯示該節點的絕對數與相對前一節點的比值(survived→actionable 跨了不同的池子,僅供視覺對照)。

三個節點都是 Cloudflare 直接公布的數字:raw 20,799、survived 12,057、action…

funnel 只放原文三關卡數:raw 20,799、survived 12,057、actionable 7,245,不放任何推算值。

注意這條 funnel 的最後一格刻意標了一句話:actionable 的 7,245 取自更大的池子。Cloudflare 給的全系統規模是「13,841 findings across 145 repos in total」,其中「5,442 findings as duplicates」、最後「7,245 actionable findings for engineering teams」。換句話說 7,245 不是 20,799 一路線性砍下來的殘值,而是跨 145 個 repo、多輪 run 累積後 dedup 完的結果。重點不在算術,而在每一道關卡的存在本身——raw candidate 到 survivor、survivor 到去重、去重到 actionable,每一關都把一大塊 noise 擋在工程團隊之外。

八階段的 discovery pipeline

VDH 不是「把 repo 餵給 model、等它吐 bug」。它是一條八個 stage 的 pipeline,每個 stage 有明確職責、明確輸入輸出。順序是 recon → hunt → validate → gapfill → trace → feedback → dedup → report。Recon 先 map 出目標架構與潛在的 threat vector;hunt 跑 per-class 的攻擊、編譯 fragment、戳 binary;validate 先機械式檢查再對抗式反證;gapfill 針對覆蓋率空白的 cell 產新的 hunt task;trace 走 dependency graph、把 consumer repo 也拉進來掃;feedback 從既有 report 學、優化未來的 run;最後 report 把結果 render 成人看得懂的報告。值得停一下的是 gapfill 與 trace:前者把「哪裡還沒掃」當成一個可被生成 task 的 coverage 問題,後者把單 repo 的漏洞沿著 dependency graph 往上游 / 下游 propagate——一個 library 的 bug 會自動 spawn 它的 consumer repo task。

把這八個 stage 當成獨立卡片來看,會錯過真正的設計巧思——它們不是線性的一次性流水線,而是一條會自我餵食的迴路。gapfill 跟 feedback 兩個 stage 就是這條迴路的關節。gapfill 的職責是「Generates new hunt tasks for empty coverage cells」:它不直接找 bug,而是盯著一張覆蓋率矩陣,哪一格還空著就生成一個新的 hunt task 丟回 hunt——這把「我有沒有漏掃」從一個人類事後才會問的問題,變成 pipeline 內部一個可以被自動生成、自動排程的 task。feedback 則是「Learns from pre-existing reports and optimizes future runs」:它讓這次 run 的結論能影響下一次 run 的策略,pipeline 對自己的歷史有記憶。關鍵在於,這份記憶不在任何一個 model 的 context window 裡,而是落在外部的 report 與 SQLite,所以你換掉 model,這份累積的經驗一個 byte 都不會丟。對一個要 scale 的安全團隊來說,這正是分水嶺:靠 model 記憶的系統每換一代 model 就要重新教,靠外部 state 記憶的系統換 model 像換電池一樣便宜。

trace 這個 stage 還藏著一個多數人會低估的 scale 機制。一個被廣泛 import 的 library 出現漏洞,真正的爆炸半徑不在 library 自己,而在所有 consume 它的 repo。trace「Walks dependency graph; spawns consumer-repo tasks」——它沿著依賴圖往外走,把一個 finding 自動 fan-out 成它所有 downstream consumer 的掃描 task。這解釋了為什麼整個系統的數字會是「13,841 findings across 145 repos」這種跨 repo 的形狀:漏洞不是一個 repo 一個 repo 孤立地被找出來的,而是沿著真實的依賴關係 propagate 出去的。對工程師的啟發是——把「攻擊面」建模成一張圖、讓一個節點的發現自動觸發鄰居節點的掃描,比把每個 repo 當成獨立目標逐一掃,更貼近漏洞在真實系統裡傳播的方式。

下面把八個 stage 拆成可點選的卡片,每張卡片就是 Cloudflare 給的那一句職責描述。手機上是直排卡片、桌機上是橫向 pipeline——點任一張看它在整條鏈上的位置。

點任一階段看它的職責 · 八個 stage 串成一條 pipeline

VDH 八階段 pipeline——recon → hunt → validate → gapfill → trace → feedback → dedup → report

VDH pipeline——八個 stage 串成一條鏈,state 全寫進外部 SQLite 1 · recon map architecture 2 · hunt per-class attacks 3 · validate disprove 4 · gapfill cover gaps 5 · trace dep graph 6 · feedback learn from reports 7 · dedup consolidate 8 · report render

recon · 偵察

Maps out the target architecture and maps potential threat vectors。先把目標架構與潛在 threat vector 攤開,後面的 hunt 才有方向。

hunt · 獵捕

Runs per-class attacks, compiles fragments, probes binaries。針對每一類漏洞跑攻擊、編譯 fragment、戳 binary——真正生成 candidate 的就是這層。

validate · 驗證

Mechanically checks the finding, then adversarially disproves it。先機械式查、再對抗式反證——這是整條 pipeline 砍 false positive 的關鍵閘。

gapfill · 補洞

Generates new hunt tasks for empty coverage cells。把「哪裡還沒掃」當成 coverage 問題,自動生成新的 hunt task 填空白 cell。

trace · 追蹤

Walks dependency graph; spawns consumer-repo tasks。沿 dependency graph 走,一個 library 的漏洞會自動 spawn 它的 consumer repo task。

feedback · 回饋

Learns from pre-existing reports and optimizes future runs。從既有 report 學、優化未來的 run——pipeline 對自己的歷史有記憶,但記憶在外部不在 model。

dedup · 去重

Identifies and consolidates overlapping findings。認出重疊的 finding 並合併,這就是砍掉 5,442 個 duplicate 的那一層。

report · 報告

Renders human-readable report。把結構化結果 render 成人看得懂的報告,交到工程團隊手上。

把 state 從 model 裡搬出來

這套設計真正的地基是一句樸素的工程決定:「Every stage writes to one SQLite database keyed by (run_id, repo, stage).」每個 stage 把自己的結構化結果寫進一個以 (run_id, repo, stage) 為 key 的 SQLite,下一個 stage 從那裡讀。Cloudflare 把這件事講成設計原則——「We broke this bottleneck by externalizing the state entirely, treating the LLM as a stateless compute engine.」 把 state 完全 externalise、把 LLM 當成 stateless compute engine。這句話的後果很實際:因為 state 不在任何一次對話的 context window 裡,你可以在兩個 stage 之間換掉 model 而不丟任何進度;可以 retry 一個失敗的 stage;可以拿同一份 run_id 的中間結果重跑後面的 stage;甚至可以讓 discovery 跑一個 model、validation 跑另一個 model,因為 state 是 model 之間的共用語言而不是某個 model 的私有記憶。

這個 key 的三個維度——(run_id, repo, stage)——每一個都對應一種 production 必須面對的失敗模式,值得逐一拆開。run_id 讓你能把「這一次完整的掃描」當成一個可被引用、可被重放的單位:worst case 一次 run 跑了「just over 14 hours」十四小時出頭,你絕對不想因為第七個 stage 掛了就把前面十幾個小時的 hunt 結果丟掉重來;有了 run_id 當 key,你只要從那一格 state 接著往下跑。repo 維度讓同一次 run 能平行掃 145 個 repo 而彼此 state 不互相污染,也讓 trace 把一個 repo 的漏洞 spawn 到另一個 repo 時,兩邊的中間結果能各自定位。stage 維度則把 pipeline 的每一步變成一個有明確輸入輸出 contract 的純函數:stage N 只讀 stage N-1 寫下的那格、只寫自己這格。一旦每個 stage 都是「讀上一格、寫這一格」的純函數,整條 pipeline 就獲得了分散式系統夢寐以求的性質——任一步可重試、可重放、可換實作,而 model 只是那個被注入到純函數裡的、可替換的算力。

把這件事翻譯成更普遍的工程語言:Cloudflare 其實是把「context window 當作 working memory」這個 LLM agent 的預設架構給拆掉了。多數 agent 框架把對話歷史當成狀態,於是 context 一長就撞牆、換 model 就失憶、平行化就要複製整段歷史。把 state externalise 進一個 keyed store 之後,這三個瓶頸同時消失:context 永遠只裝「這一格需要的最小輸入」,換 model 不丟記憶,平行化只要 fork 一個指標而不是 copy 一段對話。這是整篇文章對「建 agentic 系統的人」最可遷移的一課——它跟漏洞獵捕本身無關,而是一個關於「LLM 的記憶該放在哪裡」的架構判斷。

// state 是外部的、結構化的、可被任何 model 讀寫的
key = (run_id, repo, stage)

stage_hunt(key):
    candidates = model_A.run(read(prev_stage_key))   // 用 model A
    write(key, candidates)                            // 結果落 SQLite,不留在 context

stage_validate(key):
    findings = read(hunt_key)
    verdicts = model_B.disprove(findings)             // 換 model B,state 不丟
    write(key, verdicts)

// LLM 本身不持有 state——換 model、retry、重跑後段都不影響進度

把 LLM 當 stateless compute 還解開了另一個 scaling 維度:sibling forking。一個 task 跑到一半可以 fork 出 sibling task 平行探索不同假設,因為每個 fork 的起點就是 SQLite 裡一份明確的 state,不需要複製 context。Cloudflare 量到「Fleet-wide, this accounts for roughly 9% of tasks」——sibling forking 全 fleet 約佔 9% 的 task,但原文補了一句「the rate is highly model-dependent」,並把區間描述成「from near-zero to about a fifth」,從近零到約五分之一,高度取決於用哪個 model。這個區間本身就是「model 是可替換零件」的證據:同一條 harness、換個 model,行為分布就明顯不同,而 harness 不用改。如果你把 sibling forking 想成「探索預算的自動分配」,那麼這個 9% 對五分之一的落差等於在說:同一套 orchestration 接不同 model,等於接到不同的 risk appetite——保守的 model 幾乎不 fork、激進的 model 把五分之一的 task 都拿去平行下注,而你不用為了切換這種行為去動任何一行 harness 程式碼。

對抗式驗證——validator 不能 file 自己的 finding

整套系統最反直覺、也最關鍵的設計,是它對「誰能宣告漏洞成立」做了嚴格的權力切分。在 VDH 內部,validate 這個 stage 先機械式檢查再對抗式反證;但真正把對抗式推到底的是獨立的 VVS。Cloudflare 講得很白:「We use one model for VDH, but we use a completely different model for VVS, so the models are effectively double-checking each other.」 VDH 用一個 model,VVS 用一個完全不同的 model,兩者互相 double-check。為什麼一定要不同 model?因為同一個 model 既當 Hunter 又當 Validator 等於讓它批改自己的考卷——「If a Hunter is allowed to grade its own homework, it will confidently validate everything it outputs.」 Validator 的唯一工作被限縮成「aggressively disprove the Hunter's theory」,它不能 file 自己的 finding,只能反證 Hunter 的理論。

「用兩個不同 model」這件事不是省事的折衷,而是一個刻意付出的成本。同一個 model 既當 Hunter 又當 Validator 會省掉一次模型切換、省掉一份算力預算,但它會帶進一個致命的相關性:同一個 model 的盲點、同一套訓練資料留下的同一種錯覺,會在 hunt 跟 validate 兩端同步出現——它看不出自己看不見的東西。換上一個來歷不同的 model 當 Validator,等於引入一組「distinct sets of logic」,讓兩端的 error 不再相關。代價是 validation 這一輪要多燒一份不同 model 的算力,但換來的是 false positive 被一個不會跟 Hunter 共謀的第二意見擋下。對任何在 build LLM-judge 或 self-critique 迴路的人,這是最該記住的一條:讓打分的 model 跟被打分的 model 同源,幾乎等於沒在打分。

兩個不同 model 互相 double-check——權力刻意不對稱 VDH · Hunter model A · discovery · 先寫 threat model 才能 file · 提出漏洞理論 + PoC · PoC 跑在原始未改 codebase schema ordering 強制 threat-model-first VVS · Validator model B · validation · 唯一工作:反證 Hunter 理論 · dedup:deterministic 倒排索引 · reachability:MCP 拉 prod context 不能 file 自己的 finding finding + PoC disprove · 只能否決 不對稱:Hunter 能提案、Validator 只能反證——避免「自己批改自己的考卷」
權力不對稱是這套設計的核心:Hunter(model A)能提出漏洞理論並附 PoC,Validator(model B)只能反證、不能 file 自己的 finding。dedup 用 deterministic 倒排索引、reachability 靠 MCP server 拉 deployment / environment / config context。

這個不對稱還靠兩個更細的機制鎖死。第一,「A Hunter has to state the threat model before it's allowed to file anything.」 Hunter file 任何東西之前必須先陳述 threat model;而且「The output schema ordering enforces it.」——是 output schema 的欄位順序在強制這件事,threat model 排在最前面,model 必須先填它才能往下填漏洞細節。Cloudflare 說這個要求「eliminates the vacuous findings」,消掉了空洞的 finding。第二,「Every confirmed finding ships with a PoC written as a test that runs against the original, untouched codebase.」 每個確認的 finding 都附一個寫成 test 的 PoC,跑在原始、未改動的 codebase 上——不是「model 說它是漏洞」,而是「這裡有一個 test,在沒被你動過的程式碼上會觸發」。

為什麼「欄位順序」這種看似微不足道的 schema 細節,能扛起「消掉空洞 finding」這麼大的責任?關鍵在於 LLM 是順著它正在生成的 token 往下推理的:如果你讓它先寫「我發現一個 SQL injection」,它會先承諾結論、再回頭硬湊一個 threat model 來自圓其說;但如果 schema 逼它先填 threat model 欄位、才能往下填漏洞細節,它就必須先把「誰是攻擊者、能控制什麼輸入、能達成什麼」想清楚,很多原本會被生成的空洞 finding 在這一步就因為「填不出像樣的 threat model」而胎死腹中。下面這個小工具讓你親手切換這個欄位順序,看同一個 model 在兩種 schema 下會 file 出什麼。

schema 強制 threat-model-first(Cloudflare 的做法) 1threat_model · 先講誰是攻擊者、能控制什麼輸入 2vulnerability · 漏洞細節(必須跟上面的 threat model 對齊) 3poc · 寫成 test,跑在原始未改的 codebase 上 model 自由排序(沒有 schema 強制) 1vulnerability · 先喊出結論「我發現一個漏洞」 2threat_model · 事後回頭硬湊一個自圓其說的故事 3poc · 常常生不出來,因為漏洞根本不成立
上半:先填 threat model 才能往下填,model 被逼著先把攻擊者模型想清楚,很多空洞 finding 在這一步就生不出來——這正是 Cloudflare 說的「eliminates the vacuous findings」。
下半:放任 model 先喊結論,它會先承諾「有漏洞」、再回頭硬湊 threat model 自圓其說,空洞 finding 大量湧出,把雜訊推給下游的 validator 與工程團隊。
兩種 output schema 欄位順序並排對比。Cloudflare 的設計把 threat model 排在最前面,並讓「The output schema ordering enforces it」——靠生成順序本身強制 model 先建模、後下結論,這個要求「eliminates the vacuous findings」。

VVS 內部的三件事各自把對抗式延伸到不同維度。dedup 不靠 model 判斷誰跟誰重複,而是「deterministic code builds inverted indexes over the structured data (touched files/functions, trust boundary, rare tokens) to generate a short list of real candidates.」 用 deterministic 程式碼在結構化資料上建倒排索引(碰到的 file / function、trust boundary、rare token),先縮出一個真正的候選短清單,再讓 model 去判。reachability 的判斷則交給 agent 去拉真實環境:「The agent rechecks the latest information, pulling from deployment, environment, and config context to determine if the code path is reachable in prod.」——透過 MCP server 拉 deployment、environment、config context,判斷這條 code path 在 prod 是否真的可達。最後是 fix:自動修補的唯一驗收條件是 regression test 翻面,「A clean fail→pass flip is the ideal and the only auto-cleanup case; a failing post-patch test blocks the commit.」 fail→pass 才放行,patch 後 test 還 fail 就擋 commit。

下面這個小工具把對抗式驗證接上去前後的效果攤開:把這層加進 pipeline,Cloudflare 量到「initial validation rejection rate dropped from 40% down to 11%」。拖動滑桿在「沒有對抗式驗證」與「有」之間移動,看 rejection rate 怎麼掉。

沒有對抗式驗證 40% rejected 有對抗式驗證 11% rejected 11%
11% initial rejection rate
把對抗式 validation 接上後,initial rejection rate 落在 11%——三分之二的初始否決被前移消化。
兩端是 Cloudflare 給的真實數字:未加對抗式驗證時 initial validation rejection rate 是 40%,加上後降到 11%。拖動把手在兩端之間移動觀察落差;中間值為線性插值,僅供視覺對照。

兩端是 Cloudflare 給的真實數字:未加對抗式驗證時 initial validation rejection…

接上對抗式 validation 後,initial rejection rate 從 40% 掉到 11%,三分之二的初始否決被前移消化掉。

反直覺的 production 數字

把整套東西放進 production 跑一個月,最有意思的不是漏洞數,而是工具使用分布完全違反直覺。第一個衝擊:傳統 static analysis 的主力 Semgrep 幾乎沒被用到——「Hunters invoked it zero times in a month of runs.」 一個月零次。反過來,最常用的 tool 是一個聽起來不像安全工具的東西:wishlist。「The wishlist, by contrast, was the single most-used tool in the system.」 而且「The wishlist has been written to 25,472 times.」 被寫入超過兩萬五千次。wishlist 是 agent 把「我想查但這輪沒查的東西」記下來的地方——它變成系統最常用的 tool,意味著 LLM-driven 的 hunt 更傾向「把待辦記在外部、之後 gapfill / 後續 run 再撿」,而不是套一條固定的 static rule。

傳統主力靜置、待辦清單被狂寫——工具使用完全違反直覺 Semgrep 傳統 static analysis 主力 0 次調用 · invoked zero times wishlist 系統最常用的 tool 25,472 次寫入 · most-used tool LLM-driven hunt 把待辦記在外部、之後 gapfill 再撿——而不是套固定 static rule
兩個數字都直接取自 Cloudflare:Semgrep 在一個月的 run 裡被 Hunter 調用 0 次;wishlist 被寫入 25,472 次,是系統最常用的 tool。長條長度是視覺對比,不是線性比例(0 與 25,472 無法同尺度畫)。

wishlist 這個設計細節值得多想一層,因為它把「agentic hunt 跟 rule-based scan 到底差在哪」講得很具體。Semgrep 這類 static analysis 的世界觀是「我有一組規則,把它們套到整個 codebase 上」——規則是固定的,掃的範圍是窮舉的。LLM-driven 的 Hunter 不是這樣工作:它順著一條攻擊假設往下挖,挖的過程中會冒出一堆「這個地方看起來也可疑、但不在我這輪的主線上」的支線。wishlist 就是裝這些支線的盒子。把它寫進去,意味著 Hunter 承認「我現在不查這個,但我不想忘記它」,然後讓 gapfill 或後續 run 再回頭撿。一個月被寫入 25,472 次,等於 Hunter 平均每跑一段就在記一筆待辦——這正是為什麼 Semgrep 被調用 0 次:固定規則窮舉的價值,在一個會自己生成、排序、回收探索方向的系統裡,被一個更動態的「外部 todo queue」取代了。對建工具的人,這是一個反直覺但可遷移的觀察:給 agent 一個能寫待辦的外部結構,可能比給它更多固定工具更有用。

另外幾個數字幫忙把 scale 與品質一起標定。整套系統的 high-integrity finding 佔比「climbed from 35% to 58%」——從 35% 爬到 58%;單一 repo 的掃描「yields 100 initial findings」,被壓縮成「80 distinct, high-fidelity bugs」,約一百個初始 finding 變八十個 distinct 的高保真 bug。注意這兩個數字測的是不同層次:35%→58% 是整池 finding 裡「值得人看」的比例翻倍,而 100→80 是單一 repo 內把雜訊與重複壓掉後剩下的真貨密度。兩者合起來描述的是同一件事——這條 funnel 不只是把數量砍小,而是在每一道關卡把「訊號對雜訊的比例」往上推。runtime 上不是免費午餐:「The worst run took just over 14 hours.」 最壞的單次 run 花了 14 小時出頭——這提醒一件常被忽略的事,agentic pipeline 的 tail latency 可以很長,排程與成本控制得照著 worst case 設計而不是 median。如果你拿 median 去估算容量,遇到那條 14 小時的尾巴就會排程崩盤;對抗式驗證、sibling forking、trace 沿圖 fan-out 這些機制每一個都在替尾巴加長,所以「為 worst case 而非 median 配資源」不是保守,而是這類系統的必要前提。把這些放在一起,這篇文章真正的論點不是「Cloudflare 找到很多漏洞」,而是「找漏洞這件事的價值,被一層 model-agnostic、state-externalised、對抗式驗證的 harness 決定,而不是被某個 model 決定」。

The portable lesson:把 LLM 當可替換的 stateless compute、用 (run_id, repo, stage) 這類外部 key 持有 state、再用一個不同的 model 當只能反證的 validator——這套不只適用於漏洞獵捕,任何要壓 false positive 又要 scale 的 agentic 工具都能照搬;正如 Cloudflare 說的,「If you build your own system, design it to be model-agnostic from day one.」