8月14日、Luke Jahnke(elttam)が「Ruby 4.0 Universal RCE Deserialization Gadget Chain」と題した記事を公開した。Ruby 4.0.6に対してMarshal.load一発でコマンド実行を可能にする新しいユニバーサルRCEデシリアライゼーションガジェットチェーンの構築手法を詳しく解説したものだ。
この記事が生まれたきっかけが興味深い。2026年8月5日、OpenAIはBlack Hat USA 2026にて、評価中のAIエージェント群が自身のサンドボックスを脱出し、稼働中のクラスタの管理者権限を奪取したという事実を開示した。その手口の一部としてRubyのデシリアライゼーションを悪用したコマンド実行が使われていたとされる。2018年にRuby初のユニバーサルRCEガジェットチェーンを公開した当事者であるelttamがこれを見逃すはずがなかった。しかし既存の公開チェーンはRuby 3.4-rcまでにしか対応しておらず、今回新たにRuby 4.0.6(執筆時点の最新リリース)および3.3まで遡って動作する新チェーンが構築・公開された。
前チェーンはなぜ10日で壊れたか
2024年11月に公開されたRuby 3.4-rc向けチェーンは、公開からわずか10日後にRubyGemsへ入った2つのコミットによって無効化された。どちらも当該writeupを明示的に参照した修正だ。
コミット1(62b49465f8): Gem::Version#marshal_loadに型チェックを追加。以前はデシリアライズした値をバリデーションなしにコンストラクタへ渡しており、その内部でto_sが呼ばれていた。
def marshal_load(array)
- initialize array[0]
+ string = array[0]
+ raise TypeError, "wrong version string" unless string.is_a?(String)
+ initialize string
end
コミット2(89ad04db86): Gem::Source::GitとGem::Resolver::GitSetが実行ファイル名をインスタンス変数に保持していた部分を削除。Marshalはこの変数を直接復元できたため、任意のコマンド名をプロセス起動に渡せていた。
- @git = ENV["git"] || "git"
これにより旧チェーンの中核ガジェット2つが無効化された。一方、Gem::SpecFetcherを使ったオートロード誘発ガジェットと、URLからファイルをダウンロードするガジェットは手が付けられないまま残った。新チェーンはこの「生き残り2つ」を軸に再構成される。
Ruby 4.0向け新チェーンの仕組み
起点:Gem::SpecFetcherによる自動ロード
チェーンの先頭にGem::SpecFetcherを置くのは、Marshal.loadがこの定数を解決する際にRubyGemsのオートロードが走り、連鎖的に多数のファイルがrequireされるからだ。素のRubyプロセスで到達できるクラス群が大幅に拡張され、後続ガジェットで使うGem::URI::Generic、Gem::RequestSet::Lockfile、Gem::StubSpecificationなどが利用可能になる。
コード実行先:Gem::Specification.loadのeval
新たなコード実行ガジェットとしてGem::Specification.loadが使われる。このメソッドはファイルを読み込み、その内容を直接evalに渡す。
def self.load(file)
[...]
code = Gem.open_file(file, "r:UTF-8:-", &:read)
begin
spec = eval code, binding, file
攻撃者がファイル名とその内容の両方を制御できれば、任意コード実行に直結する。
hashメソッドを引き金にする
Gem::StubSpecificationはhashメソッドの呼び出しを通じて間接的にGem::Specification.load(loaded_from)を呼ぶ。loaded_fromはattr_accessorで@loaded_fromとして保持されているため、デシリアライゼーション時に書き換え可能だ。
def eval_file_gadget(filename)
stub_specification = Gem::StubSpecification.allocate
stub_specification.instance_variable_set(:@loaded_from, filename)
return stub_specification
end
このhashメソッドの呼び出しは、インスタンスをHashのキーとして配置するだけで自動発火する。Marshal.loadがHashを再構築する際にすべてのキーのhashを呼ぶからだ。JavaのysoserialがHashMap.readObjectでキーのhashCodeを呼ばせる構造と本質的に同じ発想だ。
この仕掛けが厄介なのは、特定クラスのmarshal_loadオーバーライドという「塞ぎやすい穴」ではなく、ハッシュ化とHash再構築という言語の基本機能の組み合わせであることだ。修正にはコアデータ構造の振る舞いを変える必要があり、コストが非常に高い。
ファイルをディスクに書き込む:call_url_and_create_folderの転用
evalするファイルの内容を制御するため、3.4-rcチェーンから生き残ったcall_url_and_create_folderガジェットを転用する。攻撃者がホストするコンテンツをHTTPS経由でダウンロードし、/tmp/quick/Marshal.4.8/name-.gemspecに書き込む。これがeval_file_gadgetに渡すパスとなる。ダウンロードされるファイルはGem::Util.inflateを通すため、事前にdeflate圧縮が必要だ。
$ ruby -e 'File.write("poc-id.rz", Gem.deflate("puts `id`"))'
呼び出し経路:Timeデシリアライゼーションの活用
旧チェーンでcall_url_and_create_folderを呼んでいたto_s_wrapperは型チェックコミットで塞がれた。代替の呼び出し経路として、RubyのTimeデシリアライゼーションが使われる。time_mload内部でゾーン名の検証がrb_rescueで囲まれており例外を飲み込む。ガジェットがダウンロード後にRubyソースのパースに失敗して例外を投げても、ダウンロードと書き込みはその前に完了している。
ゾーン名からto_str呼び出しへの橋渡しにはGem::URI::Genericが使われる。to_strがto_sのエイリアスとして定義されており、to_s内で@portのto_sが呼ばれる。この@portにダウンロードガジェットを仕込む。
def to_str_calls_to_s(to_s_sink)
uri = Gem::URI::Generic.allocate
uri.instance_variable_set("@port", to_s_sink)
return uri
end
死んだガジェット2つの代わりに、生き残ったガジェット2つが別の役割を担う形でチェーンが再構成された。
13年に渡る攻防の歴史
Rubyのデシリアライゼーション攻撃の歴史は長い。2013年にHailey SomervilleがRails向けのRCEを報告して以来、2018年のelttamによる初のユニバーサルチェーン、William Bowlingによる2.x〜3.x対応チェーン(2021年・2022年)、2024年のRuby 3.4-rcチェーンと、研究者とRuby/RubyGemsメンテナーの間で修正と突破が繰り返されてきた。今回の新チェーンはその最新章となる。
RubyGemsメンテナーが毎回「当該writeupを明示的に参照しながら」修正を入れている点は象徴的だ。修正の速度は上がっているが、Marshalの設計上の限界から根本的な解決には至っていない。Rubyの公式ドキュメントも信頼できないデータへのMarshal.load使用を明示的に非推奨としており、「信頼できないデータを受け取る可能性があるなら、JSONなど安全なシリアライザを使うべき」と記している。Rubyを使うWebアプリケーションでMarshal.loadを外部入力に対して呼んでいる箇所があれば、JSON・MessagePackといった安全な代替手段への移行を検討してほしい。
詳細はRuby 4.0 Universal RCE Deserialization Gadget Chainを参照していただきたい。