☰
Ruby Worker反序列化漏洞详解:从任务队列到远程代码执行
2026/10/20 2:07:54 网站建设 项目流程

前段时间在做某套基于 Ruby 的后台任务系统巡检时,发现一个很容易被忽略的隐患:Worker 在接收任务时,把外部传入的序列化数据直接还原成了 Ruby 对象。这个问题如果被利用,攻击者不需要登录后台,只要能“喂”给任务队列一段精心构造的数据,就可以在服务器上执行任意命令。放到整个业务架构里看,就是标题说的“系统完全沦陷”——数据库被拖、配置和密钥被读、内网被横向渗透,一条龙。

这篇文章不只是讲漏洞原理,还会把 Worker 为什么会出现这类问题、攻击者是怎么一步步触发反序列化的、我们应该从哪些角度排查和修复,以及我踩过的几个坑,一起讲透。适合正在维护 Ruby 业务后台、使用任务队列做异步处理的开发者阅读,也同样适合安全工程师拿来做代码审计参考。

1. 先搞清楚:Ruby Worker 为什么会对序列化数据如此敏感

1.1 Worker 的基本工作方式

一个典型的异步任务系统里,Worker 做的事非常简单:从队列中间件里取任务、解析参数、执行业务逻辑。比如一个“发送欢迎邮件”的任务,通常会被描述成一个任务名加几个参数,推入 Redis 或数据库里的队列,然后后台进程把它捞出来执行。

为了让任务在网络间、进程间传递,必须把参数变成一段可传输的字节流。最常见的是 JSON 字符串,因为 JSON 天然只包含字符串、数字、数组、字典这些基础类型,解析之后也只是个普通 Hash。但有些任务队列为了支持更复杂的对象参数,或者为了性能,会直接使用 Ruby 自己的序列化机制 Marshal.dump 和 Marshal.load。还有一些组件在早期版本里默认使用不安全的 YAML 加载方式。问题往往就是在这一步埋下的。

很多开发者对“反序列化漏洞”的认知还停留在 Java 里,觉得 Ruby 有自己的一套对象模型,应该没问题。实际上 Ruby 的 Marshal 格式非常“开放”,它会在序列化流里写入对象所属的类名和实例变量的名字。当 Marshal.load 读到这些信息时,会向 Ruby 运行时申请创建对应的类实例,并恢复实例变量。换句话说,只要攻击者能控制序列化流里的类名和属性,就有机会诱导应用创建出攻击者指定的对象。

更麻烦的是,Ruby 反序列化不只是一个“赋值”过程。某些类在创建实例或者恢复数据时,会触发自定义方法,例如 marshal_load、method_missing、initialize 等。这些方法如果内部包含危险操作,比如执行系统命令、读取文件、发起网络请求,那么一次看似普通的 Marshal.load 就可能被改写成一次远程代码执行。

1.2 漏洞本质是把网络边界当成了本地信任边界

我自己在排查这类问题时,最喜欢先问一句:当前这行反序列化代码处理的输入,到底是从哪里来的?很多系统在最初设计时,任务队列是内部组件,假设队列里的数据都是自己人推入的,于是开发者在入队或出队时直接 Marshal.load,不加任何校验。

但当系统慢慢长大,队列中间件可能会暴露到不可信网络,或者 Web 应用存在其他漏洞导致攻击者能往队列里写数据,这个信任假设就崩塌了。攻击者只要摸到了队列入口,就不再需要突破 Web 层权限,而是直接拿到一个“反序列化后门”。

所以这个漏洞的本质,是开发者把“网络边界”误当成了“本地信任边界”。在安全设计里,所有从网络、文件、队列、缓存中读到的数据,都默认不可信。一旦反序列化函数读取的是不可信的字节流,又没有对类做白名单限制,就等于允许外部输入直接决定服务器上要创建什么对象、执行什么逻辑。

2. 一个可被利用的反序列化漏洞,通常需要三个条件

2.1 条件一:不可信数据能够流入反序列化函数

这是最基础的门槛。反序列化函数本身是合法工具,关键是输入源。在 Worker 场景里,入口可能包括:任务队列的 Redis 端口未做访问控制、Web 接口存在状态变更类的业务漏洞、或者某个 Worker 通过 HTTP 回调接收外部数据后直接处理。

实测中,我发现有些团队会把任务参数做成一个“通用 data 字段”,前段传什么,后台就序列化什么。比如一个延迟任务接口,接收 JSON 后内部转成对象存进队列,等到 Worker 消费时再还原。这里面如果有一步用了 YAML.load 或者 Marshal.load,攻击者就能按自己的意愿构造整个数据结构。

最隐蔽的是那种“看起来只传字符串”的接口。攻击者可以先把恶意数据 Base64 编码或十六进制编码,然后塞进字符串参数里,Worker 端如果再把它解码后喂给反序列化函数,依然会触发。审计时一定不要只看参数类型,要看完整的数据流。

2.2 条件二:攻击者能够构造出符合目标类结构的序列化流

这里分两种情况。第一种是攻击者完全了解 Ruby Marshal 格式,可以手工构造字节流。由于 Marshal 格式公开,生成的字节流可以只包含类名和基本数据,不依赖目标系统环境。第二种情况是攻击者利用了原有系统的序列化入口,例如通过 Web 接口传入一个对象,系统把它 dump 后放入队列,攻击者再修改其中的部分字段。

很多开发者觉得“只要我的队列数据加密/签名过就安全”,但加密和签名只能防外部直接篡改,防不了“内部合法入口被滥用”。比如某个接口本身允许用户提交一个对象类型标记,攻击者把类型标记改成另外一个危险类,签名照样通过,因为签名只保证数据没有被改,没法保证类型是安全的。

所以在设计上,真正要卡的是“类的种类”,而不是“是否被篡改”。这就引出了白名单校验的必要性。Ruby 社区在主流的 YAML 安全加载方案里,最终也落在了 permitted_classes 这种白名单机制上,就是因为它防的是“类替换”攻击。

2.3 条件三:应用环境中存在可被利用的“魔术方法链”

即使反序列化函数能够创建任意类对象,也不代表就能直接执行命令。攻击者需要找到目标 Ruby 环境里已经加载的某些类,这些类在反序列化过程中会调用一些“有趣”的方法,比如 system、exec、eval、` 等。单独某一个类可能只是读取对象属性,但几个类的方法组合起来,就能形成一条从“创建对象”到“命令执行”的调用链。

这类能够组合利用的类,业内称为“gadget”。Ruby 应用几乎都会加载一堆 gem,这些 gem 内部的公共方法有时会意外地成为 gadget。关键是,开发者自己写的代码里可能永远都不会出现 system(),但某个依赖库的某个方法内部会调用 command,或者是通过 Open3、IO.popen 执行外部命令,于是链就通了。

这里要特别提醒:不要以为“我的代码里没有危险函数”就安全。Ruby 的反射能力很强,Kernel、Object、ERB、Gem、Net 相关的类都可能在特定条件下执行代码。审计时如果只是搜 system、exec,会漏掉大量潜在风险。要真正清除反序列化漏洞,必须回到入口,而不是在链路上扫雷。

3. 攻击推演:从一段任务数据到服务器权限

3.1 攻击者是如何锁定入口的

先说结论:在真实的攻击场景里,入口往往不是那个看起来很专业的“反序列化接口”,而是一个边缘业务功能。我见过一个例子,某业务后台提供了一个导入功能,可以接收 Excel 或 CSV 文件,文件内容会被解析后暂存到任务队列里做异步处理。攻击者发现这个导入接口接受“自定义分隔符”参数,分隔符会被拼进内部对象后序列化进入队列。

虽然这个接口最终没有直接使用 Marshal.load,但数据在后续 Worker 中被某个组件用 YAML 读取,于是攻击者通过构造特殊分隔符,注入了一段 YAML 元数据。这个过程听起来很绕,但在攻击者眼里,只要摸到了“输入到队列再到高危函数”的链路,就足够了。

更常见的入口是 Redis 未授权访问。很多内部系统为了省事,Redis 监听在 0.0.0.0 上,没有密码,没有防火墙策略,攻击者扫描到端口后,直接连进去往队列列表里 rpush 一条任务。一次成功写入,就直接越过了 Web 层所有认证。

所以从防御视角看,不要把“入口”想成一个独立页面,而要把整条数据流当成攻击面。每一个能影响任务参数的接口、文件读写、HTTP 回调,都可能是入口。

3.2 构造“看起来很正常”的队列负载

不贴完整可复现的利用链,因为这类公开 gadget 链本质上就是现成的远程控制武器,我在这里只讲构造思路。序列化流里包含的关键信息是类名、实例变量、对象的嵌套结构。攻击者会选择一条已经存在的 gadget 链,按照链上每个方法期望的对象结构,逐层构造嵌套数据。

为了绕过简单的类型校验,攻击者还会利用 Ruby 的一些动态特性,例如让某个字段同时支持字符串和 Symbol,或者用 Struct 封装多个属性。如果系统在入队前只检查了“第一层数据类型”,那么内层对象完全由攻击者控制,第一层校验等于形同虚设。

在本地环境,攻击者可以搭一套相同版本的 Ruby 和依赖,用脚本生成序列化数据,再验证 payload 是否能触发预期动作。这个过程和普通开发调试类似,只是目标变成了“制造一个恶意任务”。一旦验证通过,payload 就被编码后放进真实队列。

3.3 触发链式调用:为什么不需要一个“攻击入口页面”

当 Worker 从队列中拿到这段序列化数据并调用反序列化时,负载会被解析成多层的 Ruby 对象。某些类在解析过程中会触发方法调用,方法内部又可能触发另一个对象的方法。攻击者利用这种连锁反应,最终让环境执行了一条系统命令。

因为整个过程是 Worker 内部正常的消费流程,所以从应用日志看,不会有任何“黑客操作”痕迹。你只会看到 Worker 处理了一个任务,或许还会打印出一行原本就有的任务日志。命令执行的结果如果被攻击者直接发往外部,也不会有任何文件写入或异常报错。这是反序列化漏洞最阴险的地方:从业务功能上看,一切正常。

3.4 为什么最终会变成“完全沦陷”

一旦命令执行成功,攻击者得到的不是数据库查询权限,而是运行 Worker 进程的操作系统账户权限。这个账户大概率能读取应用配置文件,里面包含数据库密码、Redis 密码、对象存储密钥,甚至云凭据。拿到这些后,攻击者就可以直接用客户端工具连接数据库,导出全量用户表,或者修改业务数据。

更麻烦的是横向移动。很多后台系统的生产服务器之间网络连通性很广,Worker 可能还能访问内部管理端口。攻击者通常会在第一时间创建计划任务或写启动脚本,保证持久化;随后再扫描内网,寻找更多可利用的机器。到了这一步,就不再是单个服务被入侵,而是整个生产环境被渗透。

我之前做应急响应时遇到过类似的案例:最初只是任务队列里多了一条异常任务,等排查完,发现攻击者已经利用 Worker 权限读取了云平台的临时密钥,拿到了整个账号的控制权。所以“完全沦陷”不是夸张描述,而是反序列化漏洞加上内网互信之后非常常见的结果。

4. 哪些业务场景最容易中招:影响范围不只是“加了危险函数”

4.1 高发场景清单

从实际踩坑和审计经验看,下面这几类场景最容易出现反序列化漏洞:

  • 任务参数直接透传用户输入,且队列组件开启了对象序列化。
  • 使用自定义任务序列化器,比如为了在队列里存 ActiveRecord 对象,直接把整个对象 Marshal.dump。
  • 老项目升级过程中,从 YAML 格式的队列数据迁移到新组件,迁移脚本里用 YAML.load 读取旧数据。
  • 内部组件之间通过消息队列传递结构化对象,消息队列本身没有鉴权。
  • 为了调试方便,在后台管理界面提供一个“查看任务详细对象”的功能,直接 Marshal.load 了任务详情。

这些场景的共同点是:开发者没有意识到“序列化格式”本身就是一种代码。JSON 反序列化通常只生成 Hash/Array,安全性较好;但 Marshal 和没有白名单的 YAML 可以让任意类被实例化,这不是小问题。

4.2 不只是 Ruby Worker 本身,上下游都可能被牵连

我经常跟团队强调,反序列化漏洞的影响范围不能只看 Worker 这一个进程。即使当前 Worker 没有高危系统命令,攻击者也可以通过读取环境变量和配置,拿到下游服务凭证。比如 Worker 连接的消息队列、缓存、对象存储、邮件服务,这些凭证一旦泄露,下游全部遭殃。

另外,任务系统往往和异步通知、报表生成、数据同步等核心业务绑定。攻击者破坏任务队列中的数据,不只是执行命令,还可以篡改业务数据。比如让一个“结算任务”把金额字段改成负数,或者让“清空缓存任务”变成一个死循环,造成业务不可用。这些攻击成本更低,但破坏力同样很大。

所以在评估这个漏洞的影响范围时,不要只盯着“是否能弹 shell”,还要看 Worker 能触达哪些数据和系统。最好的办法是画一张“Worker 可信边界图”,标注它读取的配置、连接的中间件、可写文件路径。做完这张图,你就能直观地看到,为什么要把反序列化漏洞的严重性定到最高级别。

5. 排查与检测:如何确认自己的 Worker 有没有这类问题

5.1 代码审计:先搜这三个函数

排查第一步,是在整个项目代码库里搜反序列化函数。最常见的几个高危函数是:

  • Marshal.load / Marshal.restore
  • YAML.load / Psych.load
  • YAML.unsafe_load
  • Oj.load(使用默认模式时)

如果搜索结果里出现了这些函数,不要急着判定“有漏洞”,先确认它们的输入源。这里有个实用技巧:写一个简单的数据流脚本,把“数据来源”和“反序列化位置”连起来看。如果一个反序列化函数的上游是用户参数、外部接口返回值、数据库字段、Redis 队列,基本就是高风险。

我自己习惯在审计时用 grep 找出调用点后,再检查是否传了 permitted_classes 或白名单。如果看到 YAML.safe_load 并且 permitted_classes 里包含 Symbol、Time、Date 这些常见类,同时没有放开 Object、Kernel 等危险类,风险可控;如果只有一个裸的 YAML.safe_load 但没有 permitted_classes,也还算安全;但如果是 YAML.load,那基本就是在裸奔。

5.2 运行态观察:看 Worker 有没有“异常动作”

代码审计有滞后性,运行时指标能帮助我们发现已发生的攻击。重点看三处:Worker 进程是否曾启动过外部子进程、是否尝试连接外部不常见地址、是否在短时间内出现大量未预期日志。

Linux 上可以用 auditd 监控 execve 系统调用,或者用 eBPF 工具观察 exec 事件。如果发现 Worker 进程调用了 curl、bash、nc 等命令,且不是业务自身逻辑,就是强烈信号。还有文件系统变更:攻击者常用 crontab、/tmp 下的启动脚本、systemd unit 做持久化,这些都是排查重点。

另外,观察任务队列本身也会发现异常。比如一个本应只有几 KB 的任务,突然出现几十 KB 的序列化数据;同一个任务 ID 被反复消费;队列里出现大量同一个 key 的任务。攻击者测试 payload 时通常会留下这类痕迹,只是大多数团队没有给任务队列做日志审计,所以一直没发现。

5.3 日志与链路追踪中的危险信号

在反序列化攻击中,日志不一定会有报错,但会有特征。比如日志里出现了 Base64 超长字段、十六进制编码的参数、未预期的类名,或者某个任务处理耗时突然增长。这些信号单独看都不致命,组合起来就很可疑。

更有效的办法是给任务队列加上“入队来源”标记。在入队时写入来源模块、用户 ID、请求 ID,然后在 Worker 消费时做关联。一旦发现某个任务的来源与任务内容不匹配,例如一个来自公开接口的任务却包含了内部类引用,立刻就能定位到攻击入口。如果还没有这套链路,建议尽快补上,排查难度会大幅下降。

6. 修复与防御:从根上堵住这个洞

6.1 最省事的方案:不要用 Marshal / 裸 YAML 处理外部数据

修复反序列化漏洞的最佳方案永远是“不反序列化不可信数据”。对于 Worker 任务,参数应当只允许 JSON 兼容的数据类型,比如字符串、数字、数组、Hash、布尔值。要是业务需要传递 ActiveRecord 对象,不要直接存对象,而是存对象 ID 和类名,让 Worker 在消费时再从数据库加载对应记录。

这一步看似简单,但改动量不小。很多代码为了省事,一开始就直接把整个对象塞进队列,等出了问题要改,还得重构入队逻辑。我给团队的建议是,趁早做数据格式迁移:把现有队列中的对象格式统一转换成 JSON,并增加一个消费兼容层,先处理旧数据,再逐步切换。

如果你的系统确实需要支持复杂对象,那就必须把反序列化类白名单做起来。Ruby 的 YAML.safe_load 本身就支持 permitted_classes,不要嫌麻烦,把所有合法类全部列出来。Marshal 没有现成的白名单参数,需要在外部包一层“先解析类名列表再校验”的逻辑,或者用自定义序列化把类型信息放在安全区域。

6.2 如果业务必须序列化对象:白名单校验的正确姿势

允许对象反序列化时,要做三层检查:

第一层,入口检查。在入队前,使用递归遍历对象结构,提取所有对象的类名,跟白名单比对。只要出现白名单外的类,直接拒绝任务。这一层能挡住大部分攻击。

第二层,反序列化前校验。序列化数据到达 Worker 后,不能直接加载。先做一次“数据格式解析”,把序列化流中的类型标签提取出来,再和同一个白名单比对。这一步很关键,因为入队时的代码和消费时的代码可能不一致,攻击者也可能绕过入队逻辑直接写队列。

第三层,对象生命周期监控。即使前两层都做了,也不能完全保证后续依赖升级会引入新的 gadget。建议在 Worker 启动时记录基线类加载情况,运行时若发现异常类被加载,触发告警。

我见过一个非常稳固的实现:先对序列化数据做“只读解析”,提取所有类名后进行白名单校验,通过后再放入一个受限子进程执行反序列化。这样一来,即使校验逻辑被绕过,攻击者也很难从沙箱里逃逸。当然,这个方案成本高,一般只在多租户系统里才值得做。

6.3 Worker 队列的访问控制必须收紧

反序列化入口往往不在 Web 层,而在队列中间件。所以访问控制要按“不信任内网”的原则来配置。Redis、RabbitMQ、Kafka 这类组件,应只监听在业务网卡的固定端口上,禁止监听 0.0.0.0,并且开启密码认证或 TLS 单向认证。

同时要避免把生产环境队列和测试环境共用。我之前处理过一个案例:测试环境的 Redis 没有密码,攻击者从公网扫描进来,顺着测试队列找到了生产环境共用的任务数据格式,然后反手往生产队列里投毒。所以环境和网络隔离不是小事。

除了网络层,应用层也要做队列 ACL。消费 Worker 需要保持“最小权限”原则,比如只能用一组固定的 Key、只允许订阅特定 topic,不能随意读写所有队列。这部分能力取决于组件本身,但至少可以做到:任务消费者与服务提供者分开,消息队列与业务数据库的访问凭据不存在同一份配置文件里。

6.4 纵深防御:即使代码有洞,也要让攻击者寸步难行

修复漏洞只是第一步,纵深防御能显著降低“完全沦陷”的风险。我建议至少做这几件事:

  • Worker 进程使用独立低权限系统账户,禁止使用 root 运行。
  • 文件系统层面,Worker 运行目录和配置目录只读,临时目录单独挂载。
  • 系统层面,启用 SELinux 或 AppArmor,限制 Worker 进程访问非必要路径。
  • 容器场景下,给容器加 readOnlyRootFilesystem,禁用特权模式。
  • 网络层面,Worker 出向流量通过代理或防火墙白名单限制,不允许任意外联。
  • 密钥管理:数据库密码、云密钥不要落在环境变量或普通配置文件里,使用专门的密钥管理服务动态获取。

这些措施单独看都不能阻止反序列化 RCE,但组合起来会让攻击者即使拿到了执行权限,也无法轻易读取密钥、外传数据、持久化驻留。很多时候,攻击者发现环境太“硬”,就放弃继续渗透了。

6.5 修复后的回归测试与升级注意事项

修复完成后,不要只验证“任务还能正常消费”就收工。要针对攻击场景做回归测试:用恶意序列化数据尝试打给 Worker,确认被拒绝;用正常任务跑通全流程;确认白名单覆盖所有正常业务类;检查是否有老任务仍然以旧格式存在,导致兼容性问题。

如果项目依赖了老的 YAML 库或 Marshal 相关 gem,需要检查依赖升级后,原有序列化数据是否仍可解析。Ruby 官方对 Marshal 格式有兼容性保证,但某些第三方库可能调整过内部类结构,升级后读旧数据会失败。升级前在全量环境做数据抽样测试,别等上线了才发现历史任务全部消费失败。

7. 常见问题速查

问题表现可能原因处理建议
Worker 消费任务时报“class not found”反序列化时异常攻击者尝试构造环境里不存在的类检查任务来源,加入日志告警
队列里出现超长参数任务任务体积异常大可能是 Base64 后的恶意序列化数据限制单任务体积,增加来源审计
突然出现大量失败任务队列重复消费攻击者在扫描入口立即隔离队列,审计历史任务
Worker 进程执行了外部命令系统日志异常反序列化 RCE 已发生断开外联,保留现场,重置凭据
YAML.safe_load 报错业务异常白名单没包含合法类结合业务补全白名单,但不要放开 Object
老数据迁移失败新代码读旧队列数据失败序列化格式不兼容增加过渡兼容层,逐步切流

这个表格可以作为你们团队自查时的快速入口,但记住,表格只是索引,真正要解决的问题都在前六章里。排查这类漏洞最忌讳的就是“只改函数、不信任边界”,防住一个入口,其他入口照样会被打穿。

8. 一些个人经验:这类问题往往藏在“看不见的地方”

做了这些年安全巡检,我最深的体会是,反序列化漏洞往往不是一个新人写出来的,而是一个老系统在长期维护中一步步演化出来的。最初可能是为了传一个带状态的对象,后来为了方便,直接用了 Marshal.dump;再后来,任务队列从本地内网变成了跨机房,但消费端还是当年的老代码。每步看起来都合理,叠在一起就成了灾难。

排查时也不要只盯着应用层。建议你下一次发布前,抽半小时做一件小事:把所有 Worker 的上下线流程画出来,标出每一段数据从哪里来、用到什么序列化方式、谁能往队列里写数据。这张图会非常直观地暴露风险点。我每次给团队做评审,只要画完这张图,基本都能找出两三个隐藏入口。

如果你现在正接手一个老项目,我建议优先做三件事:第一,全局搜一下 Marshal.load 和 YAML.load;第二,确认队列中间件的监听地址和访问密码;第三,看 Worker 进程的运行用户是不是 root。改完这三个点,你的系统已经比大多数同类系统安全不少。后续再慢慢推进白名单、沙箱和密钥管理。安全没有一步到位的方案,但堵住最粗的那条路,至少能让攻击者绕很远的路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询