一個沒有 GC、永遠只增不減的全域表,加上一行 String.to_atom(params["type"]),攻擊者只要把 query string 換成 ?type=a1、?type=a2、?type=a3……灌個幾十萬次,你那台跑得好好的 BEAM 就會 system_limit 然後整個倒下——沒有 segfault,沒有 OOM killer,是 VM 自己決定不活了。
Atom 用完,VM 就掛——拆解 BEAM 那張全域 atom 表
讀完這篇,你會知道一件對 Erlang/Elixir 工程師來說近乎反直覺的事:在 BEAM 上,「把使用者輸入轉成 atom」這個看起來人畜無害的動作,是一條乾淨俐落的 denial-of-service 路徑。它不需要 buffer overflow,不需要競態,不需要任何記憶體安全漏洞——它只是利用了 atom 這個型別一條寫死在 VM 設計裡的性質:atom 進了那張全域表就再也出不來。我們會從「atom 到底是什麼」講起,看清楚那張表為什麼填得滿、填滿之後 VM 為什麼選擇直接死掉,再追一個 HTTP 請求裡的字串是在哪一個環節被永久化成 atom 的,最後落到你下週就能套用的防禦:哪些函式要禁、用什麼替代、怎麼讓 linter 幫你守住。
先給一個讓人願意停下來的數字。Erlang Ecosystem Foundation 作為 CVE Numbering Authority 已經發布了一批生態系的 CVE,其中 35.8% 落在 uncontrolled resource consumption(CWE-400)這一類——也就是「攻擊者用可控的少量輸入,逼系統消耗不成比例的資源」。而在這一類裡頭,atom exhaustion 是最大的貢獻者之一。換句話說,BEAM 生態系超過三分之一的已知安全漏洞,根源不是什麼精巧的記憶體破壞,而是這種「資源被慢慢吃光」的模式,atom 表首當其衝。這不是一個冷門的理論攻擊,是真的被人寫進 CVE 編號裡的東西。下面這條比例尺把這個 35.8% 放回整個 CVE 母體裡,讓你一眼看清它在「所有已知漏洞」中佔多大一塊。
單一比例尺:把 EEF 已發布 CVE 當成 100%,染色那一段就是 uncontrolled resource c…
EEF 已發布 CVE 中 CWE-400 佔 35.8%,atom exhaustion 是其中比例最高的單一子類。
atom 不是字串——它是一張全域表裡的一個編號
先把 atom 這個型別講清楚,因為下面所有的問題都長在它的實作細節上。
從語法上看,atom 是 :ok、:error、:http、:https 這種東西,在 Erlang 裡寫成 ok、error(小寫開頭的 identifier)。Elixir 的 module 名稱(MyApp.User)、map 的 key(%{name: "x"} 裡的 name)、boolean(true/false 本質上就是 atom)、nil——這些底層全都是 atom。對寫程式的人來說,atom 的體驗就是「一個有名字的常數」,比較兩個 atom 是否相等快得像比較兩個整數。
而這正是重點。atom 比較之所以這麼快,是因為 VM 並沒有真的去逐字元比對它們的名字。每一個 atom 在 VM 內部其實是一個整數索引——它指向一張全域的 atom table。當你寫下 :https,VM 做的事是:去 atom table 查「https」這個名字在不在,在的話回傳它的索引,不在的話就新增一筆、配一個新索引、再回傳。之後程式裡所有的 :https 都共用同一個索引。比較兩個 atom 是否相等,就退化成比較兩個整數索引——O(1),不碰字串。
這套設計拿來換取效能,代價藏在三個性質裡,每一個都很關鍵:
- 全域(global):atom table 是整個 VM 共用的一張表,不分 process、不分 application。任何一個 process 新增的 atom,會留在所有 process 都看得到的那張表裡。
- 不做垃圾回收(not garbage collected):這是最反直覺的一條。BEAM 的每個 process 都有自己的 heap、會被 GC,但 atom table 不在 GC 的管轄範圍內。一個 atom 一旦進了表,直到 VM 關閉為止都不會被回收——即使再也沒有任何程式碼引用它。
- 容量有上限(fixed-size):atom table 不是無限大的。它有一個上限,預設大約是一百萬個 atom(這個值可以用 VM 啟動參數
+t調整)。一旦表被填到上限,下一個試圖新增 atom 的動作就會觸發 system_limit——VM 直接 crash。
把這三條串起來,攻擊的形狀就浮現了。GC 不會幫你清掉 atom,所以這張表只增不減;它有上限,所以「只增不減」遲早會撞牆;它是全域的,所以任何一個能新增 atom 的入口,撞牆之後拖垮的是整台 VM,不是單一 process。一般的 memory leak 你還能靠重啟某個 process 或等 GC 紓解;atom leak 不行——唯一的「回收」手段是重啟整個 VM。
這裡值得停下來比較 atom 和它最像的鄰居:binary(字串)。表面上 :user_id 和 "user_id" 看起來只是引號的差別,但在 VM 眼裡它們是兩種命運完全相反的東西。binary 活在 process 的 heap 上(或大 binary 的共享 heap 上),會被 reference counting 和 GC 回收——你產生再多 binary,只要沒人引用,遲早被清掉。atom 活在全域 atom table 裡,沒有 reference count、沒有 GC、沒有「沒人引用了所以可以清掉」這回事。所以同一份「把外部字串拿來用」的程式碼,用 binary 表達是安全的(短命垃圾),用 atom 表達就可能是一條 leak。攻擊者要找的,正是那些「本該用 binary 卻被寫成 atom」的地方。
為什麼 BEAM 不替 atom table 做 GC?因為 atom 的整套效能優勢正建立在「索引永不變動」這個前提上。比較兩個 atom 只比較整數索引、pattern match 一個 atom 常數可以編譯成跳表、atom 可以安全地在 process 之間用訊息傳遞而不需要拷貝名字——這些都依賴「一個 atom 一旦配到索引 N,索引 N 就永遠是它」。要 GC atom 就得能搬動或重編索引,而那會讓上述每一條優化都失效。設計者選擇了「atom 永久、但要小心別亂創」這個 trade-off。在 atom 全部來自原始碼字面量的年代,這個 trade-off 完全合理——原始碼是有限的,atom 自然有限。問題是當 atom 開始來自runtime 的外部輸入,「有限」這個前提就被打破了。
下面這個原文裡反覆強調的句子,是整篇文章的軸:
「Creating atoms from input is unsafe unless the set of possible values is finite, known, and enforced.」
翻成白話:從輸入創建 atom 這件事本身不必然危險——危險的是「可能的值是無限的、未知的、或沒被強制限制」。如果你的輸入只可能是 "http" 或 "https" 這兩個值,而且你有辦法把它「強制」收束成這兩個之一,那把它轉成 atom 一點問題都沒有。問題出在你沒有這個保證的時候——比方說輸入是一個 HTTP query parameter,攻擊者想塞什麼進去就塞什麼。下面這個 widget 把這條「只增不減直到撞牆」的動態畫出來,你可以調整攻擊者灌入新 atom 的速率,看那條曲線多快逼近上限、VM 在哪一刻掛掉。
+t 調)。底部那條既有 atom 是 VM 啟動時就佔住的 module 名、library 常數等等;上方持續堆高的是攻擊者用 :"id_1"、:"id_2"…… 灌進來的新 atom。注意它從不下降——沒有 GC 會幫你清。撞到紅線那一刻,下一次 atom 創建觸發 system_limit,VM crash。這裡把上限縮放成一個示意值(演示用 50 萬,真實預設約一百萬,可由 +t 調)
atom 表只增不減、無 GC,在預設約百萬上限下攻擊者灌滿後下一次創建即觸發 system_limit 讓整台 VM crash。
拖到最高速率你會發現 VM 在幾秒內就掛掉——這不是誇飾。每個 atom 在表裡只佔幾十個 bytes,一百萬個 atom 換算成記憶體不過幾十 MB,遠遠談不上把機器的 RAM 吃光。也就是說,攻擊者要打垮你不需要把伺服器的記憶體灌爆,他只需要讓那張表的計數撞到上限就好。一個能持續發出請求的攻擊者,灌一百萬個不同字串是再輕鬆不過的事。這是 atom exhaustion 比一般 OOM 攻擊更陰險的地方:成本極低、訊號極小、後果是整台 VM 倒下。
算一筆粗略的帳會更有體感。假設預設上限約一百萬、你的應用啟動後常駐約二十萬個 atom(module 名、依賴 library 的常數),那留給攻擊者的餘裕約八十萬。如果某個入口每個請求新增一個 atom,攻擊者用幾百個並發連線、每秒幾千次請求,幾分鐘內就能把餘裕吃光。如果那個入口是 keys: :atoms 的 JSON 解析、每個請求能塞幾萬個 key,那就不是幾分鐘,是幾個請求的事。注意這條攻擊有個讓 incident response 特別頭痛的性質:它沒有漸進式的告警曲線。記憶體用量幾乎不動、CPU 也不高,監控儀表板上一切正常——直到 atom 計數撞線那一瞬間 VM 直接消失。你不會看到「快不行了」的中間態,只會看到「好好的」然後「沒了」。
還有一個放大係數值得留意:BEAM 預設是 fault-tolerant 的,crash 的 process 會被 supervisor 重啟。但 atom exhaustion crash 的不是 process,是整個 VM——supervisor 跟著一起死。如果你跑的是多節點 cluster,攻擊者甚至可以把同一份惡意請求廣播到每個節點,一次放倒整個 cluster。「讓它自己重啟」這套 BEAM 引以為傲的韌性,在 VM 層級的 crash 面前完全失效。
那個字串是在哪一行變成永久 atom 的——追一個請求的入口
知道了機制,下一個問題是:在一個真實的 web 應用裡,使用者輸入到底是在哪裡被轉成 atom 的?這通常不是工程師故意寫的,而是某個「方便」的 API 在背後悄悄做掉的。
真實流量裡,不可信輸入的入口比想像中多。query string 和 path parameter 是最明顯的,但別忘了:HTTP header 的值、cookie 名稱、JSON/MessagePack body 的 key、XML 的 tag 名、表單欄位名、GraphQL 的 field 名——這些全部都是攻擊者可控的字串。任何一個被「順手」轉成 atom 拿去當 map key 或 pattern match 的地方,都是一個潛在入口。危險的不是某個特定的 framework,而是「拿外部字串當 atom 用」這個習慣本身。
最危險的入口是那些「無條件新增 atom」的函式。它們拿到一個字串/binary,不管這個字串以前有沒有出現過,都會去 atom table 查、查不到就新增。以下是 Erlang/Elixir 兩邊的對應,每一個都接受任意輸入並無條件創建 atom:
- Erlang:
binary_to_atom/1、list_to_atom/1 - Elixir:
String.to_atom/1、List.to_atom/1 - JSON 解析:
Jason.decode(json, keys: :atoms)——這一個尤其致命,因為它會把 JSON object 裡每一個 key 都轉成 atom。攻擊者送一份{"k1":1,"k2":1,...}的 payload,你就替他新增了一整批 atom。 - 動態 interpolation:Elixir 的
:"field_#{user_input}",或 Erlang 的list_to_atom("field_" ++ UserInput)——這種把使用者輸入拼進 atom 字面量的寫法,每一個不同的user_input都是一個新 atom。
下面這個 widget 把一個典型的請求路徑攤開:從攻擊者送出的 HTTP 請求,經過 router、controller,到那一行致命的 atom 創建。點任一個環節看它知道什麼、不知道什麼——關鍵在於:路徑上越前面的環節,越沒有能力判斷這個字串「應不應該」被永久化。
HTTP 請求 · 攻擊面
攻擊者完全控制這裡的字串:query string、JSON body、header 值、URI scheme,都是任意 bytes。每一個請求可以帶一個全新的、從沒出現過的值。
它不知道:這個值之後會不會被當成 atom。對攻擊者來說這是免費的——成本只有一個 HTTP 請求。
Router / params 解析 · 邊界
framework 把請求拆成 params map,值還是 binary。到這裡都還安全——binary 會被 GC,灌再多也只是短命的 heap 垃圾。這是「最後一道還能擋」的防線。
它不知道:下游打算拿 params["type"] 做什麼。它沒有「這個值合不合法」的知識,只能原封不動往下傳。
Controller dispatch · 引爆點上游
常見的「方便」寫法:想用 atom 當 routing key 或 map key,於是把 binary 轉成 atom 再 pattern match。寫的人腦中假設「type 只會是那幾個值」——但這個假設沒有被程式碼強制。
它不知道:真實流量裡 type 是不是只有那幾個值。假設活在工程師腦裡,不在型別系統裡。
String.to_atom/1 · 引爆
這一行把 binary 無條件轉成 atom。binary "a1" 從沒出現過 → atom table 新增一筆,永不回收。重複 N 個不同值 → N 個永久 atom。撞到上限 → system_limit → 整台 VM crash。
修法:換成 String.to_existing_atom/1,或在環節 ③ 用 explicit lookup table 把值收束成已知集合。
互動圖表
字串在 router 層還是可被 GC 的 binary;不可逆的永久化發生在 String.to_atom/1 的那一行,之前都還有救。
這張圖最該記住的一件事是環節 ②:在 framework 把請求拆成 params map 的時候,那個 "a1" 還只是一個 binary。binary 是會被 GC 的——攻擊者灌一百萬個 binary 進來,它們只會在 heap 上短暫存在然後被回收,撐死讓你 GC 壓力大一點,絕不會撞 atom 上限。真正不可逆的轉換發生在環節 ④。換句話說,整條路徑上有一條清晰的「不歸點」:在轉成 atom 之前你還有救,轉完就再也回不來。所有的防禦,本質上都是在這條線上做文章——要嘛根本不過這條線,要嘛只放已知的值過線。
為什麼 JSON 解析特別致命——一份 payload 灌進一整批 key
前面那條路徑一次只新增一個 atom(一個 type 值)。攻擊者要灌滿一百萬個 atom,得發一百萬個請求——雖然可行,但有 rate limit 之類的東西能擋。Jason.decode(json, keys: :atoms) 把這個成本一口氣壓垮了。
當你用 keys: :atoms 選項解析 JSON,Jason 會把 JSON object 裡每一個 key 都轉成 atom。問題是 JSON object 的 key 是攻擊者完全控制的——他可以送一份這樣的 body:
{
"a0000001": 1, "a0000002": 1, "a0000003": 1,
"a0000004": 1, "a0000005": 1, ... (重複到幾十萬個 key)
}
一個 HTTP 請求、一份 payload,就替攻擊者新增了幾十萬個 atom。一兩個請求就能把表填滿。這就是為什麼原文把「JSON 解析成 atom keys」單獨拎出來警告——它把 atom exhaustion 從「需要持續灌請求」降級成「一兩發就 KO」。
這裡有一個容易被忽略的細節值得澄清:keys: :atoms 之所以危險,正是因為它無條件新增。Jason 同時提供一個 keys: :atoms! 選項(帶驚嘆號),它走的是 existing-atom 路徑——只認那些程式裡已經存在的 atom,遇到沒見過的 key 直接拋例外而不是新增 atom。差一個驚嘆號,安全性質天差地遠。把使用者輸入餵給 :atoms 是把整張 atom 表的鑰匙交給攻擊者;餵給 :atoms! 則是「我只接受我預先定義好的那組 key」。下面我們把「無條件創建」與「只認既有」這組對立,整理成一張可排序的對照表。
點欄位標題排序 · 4 欄 × 7 列
| 語言 | 無條件創建(危險) | 性質 | 既有查找(安全替代) |
|---|---|---|---|
| Erlang | binary_to_atom/1 | unsafe | binary_to_existing_atom/1 |
| Erlang | list_to_atom/1 | unsafe | list_to_existing_atom/1 |
| Elixir | String.to_atom/1 | unsafe | String.to_existing_atom/1 |
| Elixir | List.to_atom/1 | unsafe | List.to_existing_atom/1 |
| Elixir | Module.concat/1,2 | unsafe | 白名單後再 concat |
| JSON | Jason.decode(_, keys: :atoms) | unsafe | keys: :atoms! 或 keys: :strings |
| Elixir | :"field_#{input}" interpolation | unsafe | explicit lookup table |
existing 這個字眼是關鍵——它把語意從「給我一個對應這字串的 atom」改成「給我一個已經存在的、對應這字串的 atom」。後者把新增 atom 的權力從攻擊者手上收回。規律很乾淨:危險函式都是「拿到字串就無條件查表+新增」,安全替代都是「只查、查不到就拋錯,絕不新增」
危險函式無條件查表並新增,安全替代的 existing 系列只查不新增,查不到拋例外而非建立新 atom。
把這條「無條件創建 vs 只認既有」的差別放進同一個 input 規模下看,差異會更刺眼。下面這個 widget 讓你拖動「攻擊者送進來的不同值個數 N」,同時對照兩種處理方式的 atom 表增量:unsafe(to_atom)每個新值都新增一筆、隨 N 線性成長;safe(lookup table 或 to_existing_atom)不管 N 多大都把增量壓在零。重點不是某個絕對數字,而是兩條線的斜率——一條跟著攻擊者走,一條紋風不動。
同樣是 N 個攻擊者控制的不同值:unsafe 那條的長度正比於 N,N 撞上你的 atom 餘裕那一刻就是 VM c…
to_atom 的 atom 新增量隨不同輸入線性成長,existing 路徑無論 N 多大增量恆為零。
三種防線——不創建、只查既有、讓 linter 守門
機制清楚了,剩下的就是工程上怎麼擋。原文給的防禦不是單一招數,而是一條由淺入深的防線。下面這組 tab 把三層分開講,重點是看清楚每一層各自把「atom 創建」收束在哪裡。
最強的防線:根本不在 runtime 創建 atom。如果你要做的是「把外部字串映射到一組已知的內部值」,用一個 explicit lookup table——一個 case 或 map——把映射寫死。沒命中就回 error,永遠不新增 atom。可能值的集合就顯式寫在程式碼裡,「finite, known, enforced」三個條件一次滿足。
% Erlang:URI scheme 白名單,攻擊者塞什麼都不會新增 atom
scheme_to_atom(Scheme) ->
case Scheme of
<<"http">> -> http;
<<"https">> -> https;
_ -> {error, unknown_scheme}
end.
當你真的需要動態查 atom(比方 routing 到一個已註冊的 module)、但可能值在啟動時就已經全部存在於 atom table,用 existing 系列。它只查、不新增:字串對應的 atom 已存在就回傳,不存在就拋例外(不是新增)。攻擊者的任意輸入頂多換來一個可被 rescue 的 ArgumentError,換不來一筆新 atom。
# Elixir:只接受程式裡已存在的 atom,否則 ArgumentError
def to_known_atom(input) when is_binary(input) do
try do
{:ok, String.to_existing_atom(input)}
rescue
ArgumentError -> {:error, :unknown}
end
end
注意一個前提:to_existing_atom 只在「目標 atom 已經被定義過」時才有用。如果那個 atom 只在這條路徑上才會第一次出現,existing 版本會永遠拋錯。實務上你通常會在 module 裡先用一組 module attribute 或 pattern 把合法 atom「種」進表裡。
前兩層靠人記得寫對;第三層靠工具強制。Credo(Elixir 的靜態分析器)有一條 Credo.Check.Warning.UnsafeToAtom——它會掃出所有 String.to_atom/1、List.to_atom/1、以及 Module.concat/1,2 這類無條件創建 atom 的呼叫並報警。它預設是關閉的,所以你得在 .credo.exs 裡顯式打開。打開之後,任何人想偷懶寫 String.to_atom 都會在 CI 被擋下來。
# .credo.exs — 把這條從預設關閉改成啟用
{Credo.Check.Warning.UnsafeToAtom, []}
這一層的價值在於它把「不要從輸入創建 atom」這條口頭約定,變成 build 會強制的機械規則。口頭約定會被新進同事、被趕 deadline 的自己破壞;CI 規則不會。
這三層的關係不是三選一,是縱深防禦。理想狀態是:① 設計上盡量用 lookup table 避免動態 atom;② 真要動態時一律走 existing 版本;③ 用 Credo 在 CI 把所有不安全呼叫攔下來當最後一道網。三層各自獨立失效時,後面還有一層接住。
還有一個常被當成「解法」但其實只是緩兵之計的東西要點名:調大 +t。把 atom 上限從一百萬調到一千萬,看起來像是給了你十倍的緩衝——但攻擊者只要多灌十倍的請求就好,而你付出的是十倍的常駐記憶體。+t 改的是「撞牆的位置」,不是「會不會撞牆」。它能在你還沒修好程式碼時買一點時間,但它不是修復。真正的修復永遠是切斷「不可信輸入 → atom 創建」這條線。
什麼時候從輸入創建 atom 是安全的——回到那條判準
把整篇收束成一個你可以隨身帶走的判準。原文那句話其實已經給了完整的決策樹:
「Creating atoms from input is unsafe unless the set of possible values is finite, known, and enforced.」
三個條件,缺一不可:
- finite(有限):可能的值是有限集合。
"http" | "https"有限;任意 query string 無限。 - known(已知):這個有限集合在你寫程式時就知道,不是 runtime 才浮現。module 名稱、固定的 status 字串是已知;使用者自訂欄位名不是。
- enforced(強制):這是最常被漏掉的一條。「我知道 type 只會是那五個值」不算 enforced——那是假設。enforced 是指程式碼主動把輸入收束進那個集合:用
case白名單、用to_existing_atom配 rescue、用 schema validation。沒有強制,前兩個條件只是工程師腦裡的一廂情願。
反過來看那 35.8% 的 CVE 也就不意外了。atom exhaustion 之所以是 BEAM 生態系最大宗的漏洞類別之一,不是因為 Erlang/Elixir 工程師粗心,而是因為 atom 用起來實在太順手——它是 map key、是 module 名、是 pattern match 的常數,到處都是。「把字串轉成 atom」這個動作在 90% 的情境下都是對的(因為那些字串確實 finite、known、enforced),於是剩下 10% 沒守住 enforced 條件的地方,就成了乾淨的 DoS 入口。危險不在於這個動作罕見,而在於它太常見,以致於那少數不安全的用法藏在大量安全用法之中。
所以下週你 review code 看到任何一個 to_atom,問題只有一個:餵進去的值,是有限、已知、且被程式碼強制收束的嗎?三個都 yes,放行。任何一個 no——尤其是 enforced 那條——就把它換成 to_existing_atom 或 lookup table,並順手在 .credo.exs 打開那條 check,讓下一個你不必再靠記性。
Take-away:atom 是一張全域、不 GC、有上限的表裡的一個編號;從不可信輸入創建 atom,等於把這張表的鑰匙交給攻擊者——除非那組值有限、已知、且被你主動強制收束。