内部节点路径 containment 审计:补完 CVE-2026-42600 欠下的那笔账
状态: 已在本地 pgsty/minio 分支修复,尚未发布、尚未披露(未申请 CVE/GHSA,上游仓库已归档)
影响范围: 仅 distributed erasure;需要 cluster-root / internode JWT
前置阅读: CVE-2026-42600 · ReadMultiple
本文包含完整利用向量与实测数据。发布即构成披露,请在修复版本发出后再上线。
上一篇的结尾写着一句话:
删除 endpoint 只证明
ReadMultiple不再存在,不能外推成所有内部节点 body path 都已经完成 containment 审计。
那是一张写明了的欠条。本文是还账记录——以及一份不太好看的施工日志。
结论先行
- 缺陷共 12 项,全部继承自上游。逐函数 md5 比对确认,fork 对相关文件的 diff 是纯删除、新增零行。
- 这不是新漏洞,是 CVE-2026-42600 的剩余部分:同一根因下还剩三个协议面。
- 我们的过失不在代码,在记录——把一次点修复写成了一次闭合。
- 修复过程中我们自己引入了 4 次回归,全部集中在"自己发明的规则"上,复用既有语义的部分零回归。
「N 个端点」是错的框架
此前的审计反复在数端点,先后得出 19、21、22 三个数字,而且每次都漏掉整个协议面。真实结构是四个面:
| 协议面 | 入口数 | 受全局 HTTP 中间件保护 |
|---|---|---|
| storage-REST HTTP query 参数 | 9 | 是——此前被误报为无保护 |
| storage-REST HTTP msgpack body | 4 | 否,r.Form 从不来自 body |
| storage Grid RPC | 18 | 否,一次 upgrade 后不再进 HTTP 链路 |
| peer-S3 Grid RPC | 5 | 否,且绕过 getStorage() 直达 drive |
第一行同样重要:它推翻了"21 个端点全部可逃逸"的旧结论。之前的审计在 handler 函数体里 grep 校验函数,没找到就判定无保护,忽略了保护发生在中间件层。
第四行则是任何"加固 storage-REST handler"的方案都碰不到的地方。
根因是三层叠加,不是一个 bug
三个设计事实,单独看都不算错:
- 校验只在 HTTP 表层——
r.Form由url.ParseQuery(RawQuery)填充,永不来自 body(2017 引入) - Grid RPC 绕过中间件——
/minio/grid/v1一次 websocket upgrade 后,msgpack 帧不再进入 HTTP 链路(2023 引入) - 存储层零 containment——
getVolDir只在 volume 恰好等于""/./..时拒绝,pathJoin会Clean(2018 定型)
一句话:上层以为下层会校验,下层以为上层已校验,中间那条通道两边都看不见。
ReadMultiple 只是踩中这个结构的其中一个端点。移掉它,结构原封不动。
时间线里有一行特别值得看:ShardFileSize 的除零风险 2020 年就在了,但直到 2024-10 它被挪进 xioutil.WithDeadline(本意是修大对象超时)才从"一次请求失败"升级为"整个进程退出"——因为 WithDeadline 在裸 goroutine 里执行工作函数,Go 无法跨 goroutine recover。这个升级在当时不可能被看出来。
实测向量
全部在真实 xlStorage 上、经真实 REST/grid 客户端、配合植入哨兵文件复现,不是静态推断。
| 向量 | 协议面 | 观测结果 |
|---|---|---|
WriteAll("vol","../../x") | storage grid | drive root 外任意文件写 |
RenameFile(".minio.sys","","bucket","x") | storage grid | 整个系统卷(IAM、config)搬进可读 bucket,全程无 .. |
DeleteBucket("../victim", force) | peer-S3 grid | drive root 外整棵目录树递归删除 |
DeleteBulk("vol","") | HTTP body | 整卷进 trash |
ReadAll(volume:"../") | 任意 | getVolDir 的检查加个尾斜杠就被绕过 |
CheckParts + 零值 Erasure | storage grid | 进程终止 |
AppendFile 声明 Content-Length: 64 GiB | HTTP | 空 body 分配 68,719,574,840 字节 |
DeleteVersions 声明 1 亿条 | HTTP | 10 字节参数分配 10.4 GB |
part Size = -2 | storage grid | 截断的分片被报告健康,修复静默跳过 |
两条此前无人发现的值得单独说:
空源路径的 RenameFile 命中卷根别名并把整卷搬走。用在 .minio.sys 上,攻击者随后一次普通 S3 GET 就能读到集群 IAM 与配置。它不需要任何穿越序列——所以任何以 .. 为线索的审计都必然漏掉它。
负数 part size 让 ShardFileSize 的两个分量都向下取整到 0。而 checkPart 的唯一判据是 st.Size() < expectedSize,于是任何存在的文件都判定完好,包括被截断的分片。更糟的是这与纠删参数是否合法无关——一个能通过 FileInfo.IsValid()(修复流程自己信任的检查)的元数据同样中招。这不是输入校验问题,是数据完整性问题:合法的修复流程读到毒化元数据后,会得出"分片没问题"从而静默跳过修复。
修复:两处收口,不是二十处补丁
要恢复的不变量只有一句:来自内部节点载荷的路径必须解析在它所指定的 volume 之内,volume 必须解析在 drive root 之内。
前半句只可能被 .. 打破(绝对路径与反斜杠前缀会被 pathJoin 的 Clean 收拢),后半句还有一条独立破法:别名卷根的路径。所以是两条规则,不是一张策略矩阵。
| 收口点 | 位置 | 覆盖 |
|---|---|---|
| volume 轴 | getVolDir(4 行) | 全部调用方,含 peer-S3 |
| path 轴 | getStorage() 装饰器 | 31 个远程入口 + 嵌套字段 |
核心逻辑不到 40 行。不改任何 handler,不改 xl-storage.go 的调用点,不碰本地 erasure 路径。
两个细节值得记录:
- 校验必须在 join 之前、针对原始参数。
pathJoin对绝对drivePath做Clean,会把前导..彻底抹掉——/drive/../../etc变成/etc,join 之后的检查看起来干干净净,实则放行一切。这是未来重构最可能悄悄撤销修复的方式。 NSScanner是唯一不经getVolDir就触达文件系统的方法,它的守卫行是承重的,不是顺手加的。
被否决的方案里最值得一提的是"在 33 个 pathJoin(volumeDir, …) 处加 containment"——那是真正的纵深防御,但要在全树最性能敏感的文件里改 33 处、每处单独判断卷根是否为合法目标。护栏测试用极小代价买到了大部分同等的抗漂移能力。这是一笔明确记账的欠条:若日后新增不经 getVolDir 就触达文件系统的路径,该决定必须重新评估。
施工日志:我们自己制造的四次回归
这部分不好看,但比修复本身更有信息量。
第一次:空白字符。 第一版把空白当分隔符,于是拒绝了 " "、" " 这类合法 S3 对象键。而 PutObject 正是经 RenameData 提交对象——这类键会在每块远程磁盘上同时失败,写入 quorum 崩溃。讽刺的是:漏洞需要 root 凭据,这个 bug 只需要用户传一个空格。
第二次:纯反斜杠键。 同一函数、同一根因。path.Clean 从不把 \ 当分隔符,所以 Unix 上 "\\" 是普通文件名。拒绝它导致分布式集群拒绝单机服务器接受的写入——同一套 S3 API,行为随部署拓扑而变。
第三次:Windows 的空格与句点。 Win32 规范化层从组件末尾剥离空格和句点,因此只由这些字符构成的组件会消失、路径解析到父目录。这意味着 Windows 上 " " 与 "..." 都是卷根别名。而 "..." 当时正躺在我们自己的"合法对象名"正向清单里——不仅漏了向量,还主动断言过它合法。
第四次:负数 part size。 新加的守卫只拒绝"正数尺寸 + 参数不可用",把"非零"等同于了"正数"。而负数走的是另一条通往 0 的路。
两次假绿
写 AppendFile 的验收测试时连续两次拿到无意义的绿灯:
- 用 REST 客户端发请求——客户端对
*bytes.Reader特判并据其推导 Content-Length,伪造值被静默覆盖。 - 换不透明 reader——Go 的 http 客户端自己拒绝发送 body 短于声明长度的请求。
最终改用 httptest 直打 handler 才成立。教训:客户端的自我保护不是服务端的防御,而攻击者用裸 socket 没有这些顾虑。
还有一次是设计层面的:我们实现过一版"逐字段反射投毒"测试,然后否掉了它——它无法区分"该拒绝却放行"和"本就该放行的非路径字段"(ETag、Algorithm…),会把正确行为报成失败。
最隐蔽的一次在 fuzz 里:第一版属性测试把"只由分隔符组成"的字符串写成例外并提前 return。那不是例外,是盲区——fuzz 被亲手挡在这个类别之外,跑一百万次也不可能找到纯反斜杠键。例外写错,比没有 fuzz 更危险,因为它给人"已经搜过"的错觉。
一个高度集中的模式
| 组件 | 语义来源 | 回归数 |
|---|---|---|
guardPaths | 复用既有 hasBadPathComponent | 0 |
getVolDir 守卫 | 复用既有 hasBadPathComponent | 0 |
isVolumeRootAlias | 自己发明的 | 3 |
guardErasureParams | 自己发明的 | 1 |
复用既有语义的部分零回归,自己发明规则的部分贡献全部回归。
这不是巧合:hasBadPathComponent 已经作为对象层自己的规则(经 IsValidObjectPrefix)被真实 S3 流量验证多年,结构上不可能拒绝任何能通过 S3 API 创建的东西;而新造的规则只有作者的想象在背书。
可操作的结论:能复用就别造;非造不可时,属性测试必须用排除法而非枚举法,例外要用独立于实现的谓词表述,且越少越好。
护栏比补丁重要
最终测试里有两个方向、缺一不可:
- 可证伪性——逐个临时移除守卫,确认测试真的变红(穿越守卫移除后 191 个子测试失败;分配守卫移除后 64 GiB / 10.4 GB 现形)。这一环正是被否决的社区 PR 所缺失的:它的测试断言
err != nil,而目标本就不存在,漏洞完好无损时也会通过。 - 合法流量 fuzz——断言凡
IsValidObjectName接受的键守卫必须接受(196 万次执行无违例),以及合法 bucket 名必须通过getVolDir(81 万次)。这一环是我们自己前两版缺失的。
再加一个方法级反射护栏:给 StorageAPI 新增未守卫的带路径方法时,按方法名报错。
这些护栏存在的理由,历史给得很直白:CVE-2026-39414 也是 2026-04-15 点修复、两个月后才来一个 fix: complete ...。加上本次,“点修复 → 记录成闭合 → 数月后补完"在这个 fork 里已经出现两次。问题不是谁不小心,而是树里没有任何东西能告诉你一个类别仍然敞着。 护栏就是把"必须有人记得"变成"CI 会失败”。
关于对抗审查
本次修复经历了五轮独立对抗审查,五轮各命中一条被漏掉的问题,五次全部成立:空白键 → 反斜杠键 → AppendFile 分配 → Windows 空格/句点 → 负数 part size。
同期我们的自查也确实找出两条(WithDeadline 的日志放大、ReadParts 用错规则),但那是在被逼到那个严谨度之后。
这个命中率说明的事情很直白:合并前的最后一道关应当是独立验收,而不是作者的自我结论。 在这次修复中,作者四次判断"可以发版",三次被推翻。
后续状态
初稿列出的两个实现缺口现已在本地分支关闭;但截至 2026-08-03,下面这些后续提交都尚未进入公开服务端版本:
ReadFileHandler已有上界。 提交b6f70ab08会拒绝超过 5 GiB 的声明读取长度;这是该旧式整文件 bitrot 路径所代表 S3 part 的最大尺寸。合法的 GiB 级读取仍可能按相同量级分配内存;这里消除的是超过格式真实上限、由调用方任意指定的分配,并没有假装大读取毫无成本。- 负数 part size 既不能写入,也不能被信任。 提交
80e8eaa42在AddVersion写入收口点拒绝该值,并在CheckParts与VerifyFile再次校验,因此既覆盖新写入的毒化元数据,也覆盖已经落盘的历史元数据。内部节点边界使用同一个谓词。 - 非正数 erasure block size 在构造时即被拒绝。 提交
80e8eaa42在NewErasure校验blockSize,覆盖单独给ShardFileSize加守卫无法覆盖的其他 offset 与 decode 除法;rebalance 中独立的除法在自身边界另行校验。
仍有两项限制,不能被打包进更强的结论:
- Windows 无 CI——发布 Windows 构建,测试只跑 Ubuntu。Windows 规则是从文档化的 Win32 行为推理而来,未经实机验证。
- 符号链接——containment 校验是词法防御,与上游一致。
尾声
上一篇说"关闭一个 endpoint,与关闭一个缺陷类别,是两种不同结论"。这次已知 sink 已在本地分支关闭,代价是四次自制回归和三次被推翻的"可以发版"。发布仍是独立的一道门:以上修复尚未进入公开服务端构建。
如果只留一句:漏洞是上游的,我们的错在于把点修复当成了闭合。 而防止它第三次发生的,不是更仔细的人,是会失败的测试。