你還在用 Marshal.load 處理未驗證資料?
1. 簡介
Ruby 的
Marshal
模組是一種原生二進位序列化格式,用於持久化儲存和傳輸任意 object graph(包含其 instance 變數)。由於
Marshal.load
重建物件時不會呼叫其建構子,餵入未受信任的位元組已反覆導致遠端程式碼執行,且此過程無需依賴任何特定應用程式邏輯——直譯器本身及其隨附的 RubyGems 函式庫已提供攻擊所需的一切。一篇針對寫作當時最新版本 Ruby 4.0.6 所發表的 gadget chain,延伸了此模式:它僅需單次
Marshal.load
呼叫,無需第三方 gem,且無需修改即可回溯至 Ruby 3.3。
[1]
2. 背景:gadget 鏈及其持續存在的原因
gadget 鏈會將不相關方法(屬性讀取器、自動載入器、比較運算子)的 side effect 串聯起來,直到其中一個執行危險操作(例如產生程序或執行程式碼)。萬用鏈(Universal chain)完全由標準函式庫與 RubyGems 建構而成,因此能作用於任何 Ruby 程序,而非特定網站框架。關於未經檢查的物件重建為何危險,以及此類鏈如何從資料竄改升級為完整遠端執行, 更廣泛的論述可參閱不安全的反序列化風險之綜合探討 ,其中將 RCE 與身份驗證繞過和資訊洩漏並列為程式反序列化未經驗證封包時可能造成的後果。
3. 先前鏈失效的原因
一個早前針對 Ruby 3.4 發行候選版本有效的萬用鏈,在其發表十天後因 RubyGems 的兩個提交移除了它所依賴的特定 gadgets 而失效。
[2]
第一個提交收緊了
Gem::Version#marshal_load
,該方法先前會將反序列化的值直接傳入
initialize
而未檢查其型別:
- # Program 1 — Gem::Version#marshal_load, before and after the RubyGems fix
- # BEFORE (Ruby < 3.4): the array element from the Marshal stream is
- # attacker-controlled and is handed to initialize with no validation,
- # so any object — not just a String — can reach the constructor.
- def marshal_load(array)
- initialize array[0] # array[0] is fully attacker controlled
- end
- # AFTER (Ruby >= 3.4): a type check closes the gadget by rejecting
- # any non-String value before initialize is ever reached.
- def marshal_load(array)
- string = array[0]
- raise TypeError, "wrong version string" unless string.is_a?(String) # <-- gadget disabled here
- initialize string
- end
第二個提交移除了儲存在 instance 變數中的 git 執行檔名稱(Marshal 可直接還原該變數),改為在呼叫時從環境變數讀取。 [2] 這兩個修補都直接針對先前文章中點名的 gadgets,顯示出一個重複的模式:公開發表鏈會讓維護者能精確修補被點名的方法,且這些提交小到幾分鐘內就能審閱完畢。
4. 擴大可利用的 gadget 範圍
新的鏈一開始會引用
Gem::SpecFetcher
。這個類別本身不會執行任何有幫助的動作;關鍵在於
Marshal.load
為了重建物件必須解析該常數,而解析它會觸發 RubyGems 的自動載入,進而引入定義該類別的檔案——而該檔案又會需要載入更多檔案。因此,單一常數引用就能將一個精簡直譯器原本可存取的少數類別,擴展成一個更大的集合,包含
Gem::URI::Generic
、
Gem::RequestSet::Lockfile
和
Gem::StubSpecification
,而鏈的其餘部分皆依賴於這些類別。
5. 從接收端到觸發器
程式碼執行的目的地由
Gem::Specification.load
提供,它會從磁碟讀取檔案並直接求值其內容:
- # Program 2 — Gem::Specification.load: the arbitrary-code-execution sink.
- class Gem::Specification < Gem::BasicSpecification
- def self.load(file)
- # [...]
- code = Gem.open_file(file, "r:UTF-8:-", &:read) # resolves to File.open;
- # "file" must be attacker controlled
- begin
- spec = eval code, binding, file # file contents run as Ruby source
要到達這個接收端,需要一個形式為
@controlled.load(@also_controlled)
的物件,但沒有任何 gadget 能直接提供。
Gem::StubSpecification
提供了一條間接路徑:其
hash
方法會呼叫
Gem::Specification.load(loaded_from)
,而
loaded_from
是一個
attr_accessor
,其背後的 instance 變數可以透過反序列化來設定,完全無需呼叫建構子:
- # Program 3 — building the caller and triggering its hash method.
- def eval_file_gadget(filename)
- stub_specification = Gem::StubSpecification.allocate # bypass new/initialize entirely
- stub_specification.instance_variable_set(:@loaded_from, filename) # read later by #hash -> Specification.load
- return stub_specification
- end
- # Placing the returned object as a Hash KEY is sufficient: Marshal.load
- # reconstructs a Hash by inserting each key, and Ruby calls #hash on a
- # key whenever it is inserted -- so no override of marshal_load is needed.
觸發器——也就是
Hash
在還原時對其鍵呼叫
hash
——並非一個可被覆寫的輔助方法,而是兩個核心語言特性(hashing 與 hash reconstruction)之間的交互作用,這使得要移除它遠比修補單一方法(讓先前鏈失效的那種修補)來得昂貴許多。
6. 具備容錯能力的下載路徑
在將攻擊者內容寫入磁碟(再進行求值)之前,會重複使用先前鏈中的 URL 下載 gadget,但該 gadget 預期回應中包含的是序列化物件,而在找到純原始碼時會引發例外——但此時寫入動作已經完成。Ruby 自身的
Time
反序列化提供了一個 caller,會恰好忽略該例外:
- /* Program 4 — time_mload (C): validate_zone_name runs inside rb_rescue, */
- /* so any exception it raises while calling to_str on the zone value */
- /* is discarded, letting the side effect of that call survive. */
- static VALUE
- validate_zone_name(VALUE zone_name)
- {
- StringValueCStr(zone_name); /* invokes to_str on zone_name */
- return zone_name;
- }
- static VALUE
- time_mload(VALUE time, VALUE str)
- {
- /* [...] */
- get_attr(zone, (zone = rb_rescue(validate_zone_name, zone, 0, Qnil)));
- /* rb_rescue swallows the raised exception here */
- /* [...] */
StringValueCStr
會呼叫
to_str
,而非
to_s
,因此下載 gadget(只定義了
to_s
)會被包裝在
Gem::URI::Generic
中,該類別的
to_str
是
to_s
的別名,並會轉而呼叫被包裝物件自身的
to_s
:
- # Program 5 — aliasing to_str to to_s to reach the download gadget.
- module Gem::URI
- class Generic
- def to_s
- # [...]
- str << @port.to_s # @port is attacker controlled
- # [...]
- end
- alias to_str to_s # StringValueCStr's to_str call lands here
- end
- end
- def to_str_calls_to_s(to_s_sink)
- uri = Gem::URI::Generic.allocate
- uri.instance_variable_set("@port", to_s_sink) # wraps the download gadget
- return uri
- end
7. Chain flow
以下序列追蹤單次
Marshal.load
呼叫,從常數解析、具容錯能力的下載,到最終的
eval
,其依據為上述五個程式片段。
圖 1 — 觸發器與 Payload 傳遞路徑匯聚於
eval
,衍生自程式 1–5。
8. 討論
先前停用前一個鏈的四個提交中,有兩個並未更動
call_url_and_create_folder
;新的鏈將其從一個目錄建立輔助工具,重新利用為檔案傳遞機制,這說明了 gadgets 的壽命會超過它們初次被發現時所屬的特定鏈。此鏈與所有先前公開鏈的不同之處在於,其觸發器及其具容錯能力的呼叫者都存在於 C 語言中,而非可修補的 Ruby 方法:從
time_mload
中移除
rb_rescue
,或更改
Hash
在重建期間對其鍵呼叫
hash
的方式,都將改變核心語言語意,而非單一有漏洞的輔助方法。關於將未驗證輸入的反序列化視為通往遠端執行的直接路徑,以及隨後可能造成的影響類別(如權限提升和阻斷服務)之通用準則,仍與此結果一致。
[3][4]
9. 結論
一個由常數解析自動載入、具容錯能力的 C 層級呼叫者,以及 Ruby 的 hash-reconstruction 語意所建構的萬用鏈,能在目前釋出的 Ruby 版本上,經由單次
Marshal.load
達成命令執行,且無需任何應用程式碼,也不需要除了預設 RubyGems 安裝以外的任何 gem。由於觸發器(trigger ) 與 exception-swallowing caller 是結構性的,而非偶然存在,因此修補個別方法只會提高建構新鏈的成本,但不會移除底層能力。將對未受信任輸入使用
Marshal.load
視同於執行命令,並在必須反序列化外部輸入時改用純資料格式,仍是唯一持久的緩解措施。