2026年8月5日,OpenAI 公布一組正在評估中的 AI 代理突破了其沙箱限制,並取得了運行集群的管理員控制權。部分原因是利用 Ruby 反序列化漏洞執行命令。這引起了我們的注意,因為我們在2018年曾發布首個基於標準庫且無依賴的 Ruby 通用 RCE 反序列化 gadget 鏈。該鏈僅適用於 Ruby 2.6.10 及以下版本,最新公開鏈則支持至 3.4-rc。
本文發布了一條新通用鏈,能將單一 Marshal.load 轉換為在 Ruby 4.0.6(撰文時最新版本)上的命令執行,且向下兼容至 3.3。該鏈結合了未被利用的新 gadget 與舊 gadget 的新用途。
序列化是將物件轉換為一連串位元組的過程,這些位元組包含重建原始物件所需的所有資訊。重建過程稱為反序列化。各程式語言通常有自己的序列化格式,Ruby 中稱為 marshalling 和 unmarshalling,分別由 Marshal.dump 和 Marshal.load 提供。
Ruby 通用反序列化 gadget 鏈始於2018年,基於早期針對 Ruby on Rails 的應用特定鏈研究,通用鏈的成果又反饋回後續應用特定鏈。以下里程碑為本鏈提供了基礎。
2024年底公開的最新鏈能在 Ruby 3.4-rc 上執行命令,但十天後 RubyGems 兩次提交移除了該鏈依賴的 gadget,這兩次提交均以該報告為動機,並隨 Ruby 3.4.0 發佈,因此鏈在 RC 版本有效但正式版失效。
第一個提交(62b49465f8)標題為「改進 marshal_load 方法中的類型檢查」,使得這些類別更難被用作 gadget。Gem::Version#marshal_load 直接將反序列化值傳入建構子,Gem::Version.correct? 會對其呼叫 to_s。
第二個提交(89ad04db86)標題為「停止在 ivars 中存儲可執行檔名稱」,移除了 Gem::Source::Git 和 Gem::Resolver::GitSet 作為 ACE gadget 的使用。這兩個類別將 git 可執行檔名稱存於實例變數,Marshal 直接還原該變數,後續被用於進程啟動。現在名稱從環境讀取,無實例變數可設置。
這兩次提交破壞了 to_s_wrapper 和 exec_gadget,但 Gem::SpecFetcher 和 call_url_and_create_folder 未受影響,且在 Ruby 4.0 中仍可用。
鏈條以 Gem::SpecFetcher 開始,並非因其執行工作,而是 Marshal.load 需解析常數,觸發 RubyGems 自動加載,進而加載更多文件,擴大可用 gadget 範圍,包括 Gem::URI::Generic、Gem::RequestSet::Lockfile 和 Gem::StubSpecification,這些都是本鏈依賴的類別。
exec_gadget 的替代品是 Gem::Specification.load,其中 Gem.open_file 解析為 File.open。該方法從磁碟讀取檔案並將內容直接傳給 eval,若能控制傳入 Gem::Specification.load 的檔名及檔案內容,即可執行任意代碼。
雖無靈活 gadget 形如 @controlled.load(@also_controlled),但 Gem::StubSpecification 可透過 hash 方法間接調用 Gem::Specification.load(loaded_from),因 loaded_from 是 attr_accessor,值存於 @loaded_from,可透過反序列化設置。
問題在於 hash 如何在反序列化時被調用。Ruby 在物件作為 Hash 鍵時會調用 hash,Marshal.load 重建 Hash 時會插入鍵,將精心製作的 Gem::StubSpecification 作為鍵即可觸發 hash。
觸發點非維護者可輕易加強的 marshal_load 覆寫,而是語言兩大基本特性間的交互:物件哈希與 Hash 重建。移除此行為需改變核心資料結構,這類改動代價高昂,故 gadget 使用成本低而禁止成本高。
能 eval 任意磁碟檔案僅有意義於鏈能寫入攻擊者控制的代碼。鏈重用 call_url_and_create_folder gadget,該 gadget 來自 3.4-rc 鏈且未被移除,原用於建立 exec gadget 依賴的目錄,現用於下載攻擊者內容並透過目錄遍歷寫入可預測且通常可寫的路徑。
3.4 鏈透過 to_s_wrapper 調用 call_url_and_create_folder,該方法因類型檢查提交被移除,故需新調用者且容忍失敗。該 gadget 預期下載的 URL 為序列化物件,若非則拋異常,而寫入磁碟的是 Ruby 原始碼,兩者格式無法兼容,異常在解析後發生,故下載與寫入先行完成。
Ruby 自身的 Time 反序列化提供了容錯調用者。time_mload 在 rb_rescue 中驗證時區名稱,丟棄異常。time_mload 支援 Time._load,Marshal.load 重建 Time 時調用。時區名稱由 payload 直接提供,故可將任意物件放入 validate_zone_name,StringValueCStr 呼叫 to_str 而非 to_s。
Gem::URI::Generic 的 to_str 是 to_s 別名,該方法呼叫 @port 的 to_s。將下載 gadget 包裝於此,將 to_str 呼叫轉為所需的 to_s。
兩個 gadget 消失,兩個存活,存活者在本鏈中擔任不同角色。
3.4 鏈將 call_url_and_create_folder 指向 rubygems.org 的真實 gemspec,任何有效 URL 均可,因只需建立目錄。此次檔案包含待執行 Ruby 代碼。下載內容經 Gem::Util.inflate 解壓後寫入磁碟,故檔案需先壓縮。
call_url_and_create_folder 將 @scheme 設為 s3 以觸發 @port 中的目錄遍歷。s3_uri_signer.rb 生成的簽名 URL 硬編碼 https://,故檔案必須透過 HTTPS 提供。目標路徑由 Gem::Source#fetch_spec 控制,結合快取目錄與 Gem::MARSHAL_SPEC_DIR(quick/Marshal.4.8/)及名稱元組的 spec_name "#{name}-#{version}.gemspec",因 @name 設為 "name" 且版本缺失,故為 name-.gemspec。解壓後檔案位於 /tmp/quick/Marshal.4.8/name-.gemspec,為 eval_file_gadget 所用。
鏈中其他 gadget 為普通物件,可用 allocate 與 instance_variable_set 設置實例變數後傳給 Marshal.dump。Time 例外,因其定義 _dump 而非逐欄位 dump。time_dump 寫入真實 Time 物件的時區,無法讓 Marshal.dump 輸出時區為任意物件的 Time。Ruby 不會 dump 這類物件,但不阻止 Marshal.load 接受。
繞過無法 dump 所需形狀 Time 的方法是先 dump 形狀相同的替代物件,再修改位元組。生成器建立帶有 @offset_placeholder 和 @zone_placeholder 兩實例變數的 Object,並將物件標頭與兩屬性名改寫為 Time._load 預期格式。
替代為 TYPE_USERDEF (u) 條目,包含八字節打包時間緩衝,包裹於 TYPE_IVAR (I),使 offset 與 zone 屬性隨之,與真實 Marshal.dump(Time.now) 輸出相符。後續的 zone 值未變,仍為 gadget。
此位元組層級修改對帶有回溯參考的格式脆弱。Marshal 僅定義符號一次,後續使用 TYPE_SYMLINK 指向先前符號索引。新增或刪除符號定義會移動索引,破壞串流。搜尋與替換均定義三符號,保持表格對齊。
生成串流前還需一細節。鏈將 Gem::StubSpecification gadget 作為 Hash 鍵,Ruby 在評估 Hash 字面量時會調用鍵的 hash,會在生成過程中觸發 gadget。生成期間 stub 掉該方法避免此問題。
上述 gadget 皆扮演兩角色之一:將攻擊者代碼寫入磁碟,或讀取並執行。整合成單一鏈如下。
執行生成器輸出完成鏈。
載入前需兩條件:先前壓縮 payload 必須由 @host 指定的 HTTPS 主機(此處為 example.com,實際為攻擊者控制)提供,目標必須能寫入 /tmp,其他可寫目錄亦可,需同步調整遍歷與檔名。目標無需額外 gem、應用代碼或磁碟先前狀態。
在空白 Ruby 環境(ruby:4.0.6 Docker 映像)執行 payload,輸出 uid=0(root) gid=0(root) groups=0(root),顯示 id 指令成功執行。
隨後異常為預期且無害。Gem::Specification.load 已將下載源碼傳給 eval,該源碼以 puts 結尾,返回 nil 而非 gemspec。方法警告並返回 nil,Gem::StubSpecification#hash 嘗試讀取名稱時拋異常。
此異常可避免,若不希望留下堆疊追蹤或中斷請求。Gem::StubSpecification#hash 為 name.hash ^ version.hash ^ platform.hash,三者均讀取 Gem::Specification.load 返回物件欄位。結尾以 Gem::Specification 結束的源碼即可讓 Marshal.load 正常返回,無警告無異常。
值得注意的是,評估後的源碼會留存於 /tmp/quick/Marshal.4.8/name-.gemspec。
此鏈將單一 Marshal.load 轉為 Ruby 4.0.6 上的命令執行,向下兼容至 3.3。無需 Ruby 內建 gem 以外依賴,無需應用代碼或磁碟先前狀態。目標外僅需可達 HTTPS 主機與可寫目錄。
大部分組件非全新打造。3.4 報告後兩次提交移除其命名 gadget,保留 call_url_and_create_folder,該 gadget 現用於下載攻擊者控制的位元組至磁碟,而非建立目錄。gadget 可跨鏈重用,鏈失效時存活 gadget 可用於新鏈。
新穎之處在於鏈餘下部分來源。此前所有公開 Ruby 鏈均由純 Ruby 方法組成,維護者可用五行差異修補,正如 Gem::Version#marshal_load 所示。本鏈深入底層。所需容錯調用者為 C 語言實現的 time_mload,該函數丟棄下載 gadget 拋出的異常,因 rb_rescue 防止錯誤時區破壞 Time 反序列化。觸發點亦為 C 實現,非覆寫,而是 Hash 在重建時調用鍵的 hash。兩者皆非可輕易刪除的便利功能。移除意味改變 Time 反序列化容錯與核心資料結構行為,語言無法輕易接受。
因此建議不變,但需明確原因。移除 gadget 僅提高編寫鏈的成本,無法消除能力,因 gadget 分布於每個 Ruby 預設載入的庫中,且發現速度快於修補。對不受信任輸入執行 Marshal.load 即為命令執行,無依賴。應如此對待,改用純資料格式。
本文開頭提及 AI 代理突破集群控制權,部分因 Ruby 反序列化。無論其自行組裝鏈或重用公開鏈,認為當前版本無鏈存在的假設從未是有效防護,且已不再延遲。如果你來此想知道 2026 年 Ruby 反序列化是否仍值得關注,突破集群的代理已給出答案:值得。
直到我們再次反序列化,再會!