这是本节的多页打印视图。
点击此处打印.
返回本页常规视图.
SILO 安全编年史
SILO 分支处理过的应用层 CVE 编年史:按照时间顺序,每个 CVE 独立成篇。
这里记录 SILO 社区分支自分叉以来处理过的安全事件。文章严格按照发现与修复时间排列,每个 CVE 独立成篇:最初的威胁模型、复核中的转折、被否决的方案、最终恢复的不变量、验证证据与兼容性代价,都留在它自己的故事里。
编年表
下方文章按时间正序排列;同一天的事件按 CVE 编号排列。只涉及 Go 或依赖升级的 CVE 继续留在对应的发布注记中,不把它们包装成应用层漏洞故事。
CVE-2026-32285:最终无需补丁的 jsonparser 通告
一次没有代码改动的安全研判:当前依赖已经包含补丁,可达性检查也没有发现漏洞路径。
撰文: 冯若航 |
状态: 无需代码改动,问题已关闭
GitHub Issue: pgsty/minio#26
安全维护不只有“发现漏洞,然后提交补丁”。CVE-2026-32285 的初始判断是:仓库可能仍携带未修复的 jsonparser,甚至一度讨论过替换依赖或维护自己的分支。真正检查 module version 与可达性后,结论却是当前代码已经使用包含修复的 v1.1.2,govulncheck 也没有发现可达的 vulnerable symbol。
最终正确的动作不是制造一个依赖升级,而是把证据记录下来,然后关闭问题。
初始判断为什么有问题
问题最初被理解成“该依赖没有可用的 fixed version”。如果直接按照这个前提行动,很容易出现几种看似积极、实际有害的结果:
- 无意义地改变 dependency graph;
- 为一次并不存在的修复引入新的兼容性回归;
- 增加本地 fork 的长期维护负担;
- 让用户误以为过去的 SILO Release 确实暴露于该漏洞。
安全工作不能用“有没有产生 diff”衡量。没有漏洞时不改代码,本身就是一个需要证据支持的安全决定。
研判过程
这次调查按四层证据逐步收敛:
- 核对当前
go.mod 与 go.sum 中实际解析到的版本; - 检查上游 release,确认
v1.1.2 已经包含对应补丁; - 运行并核对
govulncheck,没有发现可达 symbol; - 将 issue 中的差异归因于漏洞数据库或问题信息滞后,而不是当前源码仍然存在漏洞。
这里必须区分四件事:一个版本曾被标记为受影响、一个 package 被导入、漏洞 symbol 在程序中可达、以及远程输入能真正触发利用。这四个结论不能互相替代。
为什么不做“保险升级”
如果当前版本已经包含修复,再随便 bump 到另一个版本并不会让系统“更安全”。它只会扩大变化面,让后续回归更难归因。对一个庞大的 Go module graph 来说,这种无目标滚动尤其危险。
最终决定是:
- 不提交假修复;
- 在 issue 中保留版本与可达性证据;
- 继续让版本 gate 与
govulncheck 防止未来依赖回退; - 不把“当前无需改动”写成永久豁免。
验证边界
本事件证明的是:2026-04-15 当时的 checkout 不需要为 CVE-2026-32285 修改代码。 它不代表所有未来分支、依赖图或发布版本永远不受影响。只要依赖发生降级或 module selection 改变,就必须重新做版本与可达性检查。
这篇文章记录的是当时的研判与关闭依据,不声称本次博客整理重新运行了原始 govulncheck。
这次事件留下的原则
安全维护的目标是让风险结论准确,而不是让每个 issue 都产生代码。面对依赖型 CVE,最重要的是依次回答:当前到底解析到哪个版本、漏洞代码是否进入程序、symbol 是否可达、部署入口是否可利用。只有这些问题的答案要求改变源码时,才应该制造 diff。
CVE-2026-33322:OIDC JWT 算法混淆
OIDC verifier 混用 client secret 与 JWKS key,最终通过恢复 JWKS-only 非对称验证关闭算法混淆。
撰文: 冯若航 |
状态: 已发布
首个包含版本: RELEASE.2026-04-17T00-00-00Z
影响入口: AssumeRoleWithWebIdentity、AssumeRoleWithClientGrants
GitHub Issue: pgsty/minio#22
旧实现把 OIDC client secret 放进 JWT verifier keyring,同时允许 HMAC signing method。知道 client secret 的攻击者因此可以自行签发 HS token,再通过 STS 换取临时权限。最终修复恢复了 JWKS-only 非对称验证:宁可明确打破 HS256/384/512 兼容,也不保留一个会重新混淆信任语义的开关。
漏洞不在“有没有验签”
表面上看,旧代码确实执行了 JWT signature verification。真正失效的边界是:verifier 接受了哪一种 key,以及 token header 是否能选择与该 key 不匹配的算法语义。
攻击链需要满足几个条件:
- 攻击者获得 OIDC client secret;
- 攻击者构造 HMAC-signed ID token;
- verifier 把 client secret 当作 HMAC signing key;
- token 进入 WebIdentity 或 ClientGrants STS flow,换取临时凭据。
client secret 泄露本身当然严重,但它原本不应该自动获得“签发任意用户 ID Token”的权限。把两种能力混在同一个 keyring 中,才是算法混淆的核心。
兼容路径写出来了,又被主动删除
修复过程中曾经实现过 allow_hmac 一类兼容路径。它看起来很合理:默认安全,确有需要的用户可以显式打开。但继续把 shared secret 放进通用 verifier keyring,意味着管理员需要理解这个配置实际上扩大了整个 STS 信任边界;未来 method allowlist 只要发生漂移,漏洞就可能重现。
几种方案的取舍最终非常清楚:
| 方案 | 收益 | 风险 | 结论 |
|---|
| 保留 secret keyring,只限制部分算法 | 改动小,兼容 HMAC IdP | keyring 仍混合两种信任语义 | 否决 |
增加 allow_hmac 配置 | 兼容性显式 | 配置本身难以正确理解,测试面扩大 | 实现后回滚 |
| JWKS-only | 边界清晰,刷新与重试共用同一 parser | HS 用户必须迁移 | 接受 |
这次最重要的决策不是“新增了哪些代码”,而是主动删除了已经完成的兼容实现。
最终不变量
修复集中在 OIDC JWT 验证路径,并固定了四条规则:
- verifier key 只来自 IdP JWKS;
- OIDC client secret 不进入 JWT verification keyring;
- HS256、HS384、HS512 一律拒绝;
- 正常 RS256 流程以及 JWKS refresh/retry 使用同一 method allowlist。
修复没有借 CVE 顺手扩展 JOSE 功能。PS256 与 EdDSA 不在这次事件的支持范围内。
验证与发布
开发记录包含 HS256 rejection、RS256 acceptance、JWKS refresh/retry regression tests,以及 focused go test ./internal/config/identity/openid。临时兼容 helper、配置与测试在最终 diff 中全部删除。
公开发布 lineage 中的修复提交为 f1f2239,并随 SILO 2026-04-17 发布。这篇文章记录历史验证,不代表本次博客整理重新执行了测试。
兼容性代价
这是明确的 breaking change。仍签发 HS256/384/512 token 的 IdP 必须先迁移到 JWKS-backed RSA/ECDSA,再升级 SILO。这里选择的是更窄、更容易解释的信任模型,而不是让旧配置继续工作。
CVE-2026-33419:LDAP STS 用户枚举与限流链
从统一认证失败,到修正成功退款、代理来源与账户锁定:一次经历两轮反向修复的 LDAP STS 加固。
撰文: 冯若航 |
状态: 已发布,经历两轮后续修正
首个包含版本: RELEASE.2026-04-17T00-00-00Z
完整修正版本: RELEASE.2026-06-18T00-00-00Z
GitHub Issue: pgsty/minio#23
核心漏洞很直接:LDAP STS 对“用户不存在”和“密码错误”返回不同结果,形成 username oracle。第一版修复统一外部错误,并增加 source IP 与 username 双重限流;连续复核却发现,成功退款、可伪造来源 header、reservation accounting 和共享 username bucket 都可能让安全控制本身成为新的攻击面。
六月的最终方案删除了会造成精确账户锁定的 username bucket,只保留 source IP bucket,并把代理来源识别写成明确的部署契约。
初始威胁模型
入口是 AssumeRoleWithLDAPIdentity。攻击者不需要已有 MinIO 账号,只要能够访问 LDAP STS endpoint,就可以比较 unknown user 与 wrong password 的 code、status 或 message,逐步枚举有效用户名,再结合 password spraying、组织结构猜测或社工攻击。
修复也不能简单把所有错误都伪装成“密码错”。LDAP connection、lookup bind 或目录服务故障必须继续表现为基础设施错误,否则运维会失去诊断能力。
第一轮:统一响应并增加 limiter
2026-04-15 的初始修复做了三件事:
- unknown user 与 bad password 对外返回同一 STS auth error;
- LDAP infrastructure error 仍返回 500,并在 server log 保留真实原因;
- 新增 in-memory limiter,最初同时按 source IP 与 normalized username 分桶。
这一版关闭了内容侧信道,也给暴力尝试增加了成本,但 limiter 的状态机与来源识别随后暴露出更多问题。
第二轮:成功、来源与会计
4 月 16 日的连续修正处理了三类缺陷:
- 成功认证不应消耗失败额度,reserve/commit/cancel/refund 生命周期必须明确;
- 默认只能使用 socket peer,不能直接信任
X-Forwarded-For、X-Real-IP 或 Forwarded; - refund 与 capacity 必须有边界,避免 cancel 逻辑凭空增发 token。
trusted proxy 需要显式 allowlist,而不是因为请求带着“真实 IP” header 就自动获得信任。
第三轮:删掉 username bucket
六月的对抗性复核推翻了“source + username 一定比 source-only 更强”的直觉。共享 username bucket 可以被任意来源持续耗尽,攻击者只需要低频请求就能在合法用户真正执行 LDAP bind 之前,精确锁死一个目标账户。
最终修复因此:
- 删除 per-username bucket;
- XFF 从右向左剥离 trusted hops,取第一个非可信地址;
- 拒绝
0.0.0.0/0 与 ::/0 这类 trusted-proxy footgun; Forwarded 不再用于安全敏感分桶;X-Real-IP 只在代理覆盖而非透传客户端输入的契约下使用。
这次转折说明,安全控制必须拥有自己的威胁模型。限制更多维度,不等于更安全。
被否决的方案
| 方案 | 否决原因 |
|---|
| 为未知用户执行 dummy bind | 放大 LDAP 压力并引入易错的第二条认证路径;内容侧信道已经关闭 |
IPv6 统一按 /64 分桶 | 会让同一站点或运营商前缀下的合法用户互相误伤 |
| XFF 直接取最左值 | 客户端可伪造 |
XFF 与 X-Real-IP 不一致就回退 peer | 攻击者可故意制造不一致,把代理后的所有用户压入同一 bucket |
完整支持 RFC 7239 Forwarded | 安全解析复杂度高,现实收益不足 |
验证与发布
历史记录覆盖 limiter reserve/commit/cancel/refund、并发、success、infra failure、unknown user/bad password 外部等价,以及 RemoteAddr、spoofed header、trusted proxy、多 hop 与 catch-all CIDR。focused package test 与 build 均有记录。
LDAP security e2e 在缺少 _MINIO_LDAP_TEST_SERVER 时会 skip,所以外层 ok 不能冒充真实 LDAP 全场景证明。
初始公开修复提交为 6619d0c,后续修正包括 c55b52c、817a457、084a154 与 5e40665。
最终代价与残余风险
- limiter 最终只按 source IP,放弃跨来源的单账号 hard throttle;
- limiter 是 per-node、in-memory,不是集群全局密码防护;
- botnet、分布式来源、IPv6 地址轮换与 LDAP bind timing 仍然存在;
- trusted proxy 配置错误仍会破坏来源归属;
Forwarded-only 部署会退化为 peer bucket,粒度更粗。
限流只能降低单一来源的尝试速率。真正隐藏 username existence 的,是统一的外部认证响应。
CVE-2026-34204:复制元数据注入
普通 PUT/COPY 可以伪造内部复制状态;修复让 replication-only metadata 只在授权复制路径中恢复。
撰文: 冯若航 |
状态: 已发布
首个包含版本: RELEASE.2026-04-17T00-00-00Z
GitHub Issue: pgsty/minio#24
普通 PUT 与 COPY 请求可以把 X-Minio-Replication-* header 伪装进内部 X-Minio-Internal-* SSE metadata,写出 replication state 与真实授权路径不一致、甚至无法读取的对象。最终修复不再默认接受 replication-only metadata,只在通过 ReplicateObjectAction 的可信复制流程中恢复,并在 CopyObject 的所有 header 消费者之前统一清洗。
威胁模型
攻击者只需要普通对象写权限,不需要 internode credential。输入完全来自客户端可控的 X-Minio-Replication-* header;metadata extraction 却会把它们转换成内部复制或 SSE 状态。
后续读路径按照错误的内部状态解释对象,可能导致对象不可读,形成完整性与可用性破坏。几乎所有接受不受信写请求的生产 server 都应该视为受影响。
问题的根本不是 header 名字本身,而是不可信来源的数据在没有经过复制授权的情况下获得了内部语义。
全请求拒绝,还是精确清洗
看到 replication header 就拒绝普通请求,是最直观的修复。但这会把客户端过去可以携带的多余 header,从“被忽略”改变成 hard failure。最终选择了更精确的模型:
- 默认 extraction path 不接受 replication-only metadata;
- ordinary
PUT 与 COPY 先剥离这些字段; - 只有通过
ReplicateObjectAction 授权后才恢复; - replica status 写入使用同一可信条件;
- multipart 与 Snowball 的合法 replication flow 显式恢复所需的 SSE metadata。
这让兼容性变化停留在内部语义,而不是扩大到所有携带多余 header 的客户端。
为什么 CopyObject 必须提前清洗
CopyObject 的 header 不只用于最终 metadata map,还会提前参与 precondition 与 SSE-C source 处理。如果只在写入对象前删除,早期消费者已经被污染。
最终清洗发生在这些消费者之前,把“不可信 replication header 不进入内部语义”变成单一不变量,而不是依赖每个后续函数记得再检查一次。
实现与验证
改动覆盖 handler-utils、object handler 与 multipart handler,并加入了几层测试:
- helper 层 trusted/untrusted metadata extraction;
- handler 层 malicious
PUT 与 COPY; CopyObject header sanitization;- vulnerable parent 与 patched tree 的红绿对照;
- live server before/after,确认恶意 header 不再让对象不可读;
- legitimate replication、multipart 与 Snowball flow 保持可用。
公开发布 lineage 的修复提交为 fcb8f24。这篇文章保留历史验证边界,本次博客整理没有重新启动 live server。
代价与残余风险
- 普通客户端夹带的内部复制 header 现在会被忽略或清洗;
- replication-only metadata 必须在授权分支显式恢复;
- 未来新增复制入口如果忘记恢复,会表现为功能回归,而不是重新放开不受信写入;
- 本次审计聚焦 replication header,不代表所有
X-Minio-Internal-* 字段都完成了同样的 trust audit。
这个事件留下的审查问题很简单:一个字段看起来像“内部字段”并不能证明它可信,必须继续追问它来自哪里,以及哪一个授权决定允许它获得内部含义。
CVE-2026-39414:S3 Select 超大记录与 SIMD 绕过
第一轮给 CSV 与 JSON Lines 加上 1 MiB 上限,第二轮又发现 SIMD fast path 完全绕过了它。
撰文: 冯若航 |
状态: 已发布,六月完成二次闭环
初始修复版本: RELEASE.2026-04-17T00-00-00Z
完整修复版本: RELEASE.2026-06-18T00-00-00Z
GitHub Issue: pgsty/minio#25
四月的第一轮修复使用既有的 1 MiB maxCharsPerRecord 同时限制 CSV 与普通 JSON Lines,避免在遇到分隔符前持续无界 buffering,并让客户端得到明确的 OverMaxRecordSize。六月复核却发现,支持 SIMD 的 CPU 会走另一条 simdjson fast path,完全绕过这个限制。
最终方案让 JSON Lines 统一走 bounded reader,同时修正错误码、parser error 与 terminal error 前的 completed-record flush。代价是暂时放弃 SIMD 快路径,以换取所有 CPU 上一致的安全语义。
威胁模型
攻击者可以提交或查询包含超长单条记录的对象。reader 在遇到 record delimiter 前持续缓存,造成 memory/CPU DoS。更麻烦的是,同一个输入会因为机器 CPU 能力不同而进入不同实现:测试机上的安全行为,并不一定等于生产机。
错误语义也属于修复的一部分。如果超大记录最后只表现为 generic InternalError,客户端与告警系统无法区分安全上限和服务端故障。
第一轮:复用已有的 1 MiB 不变量
第一版补丁没有发明新的配置项,而是沿用已经存在的 maxCharsPerRecord = 1 MiB:
- CSV splitter 与 line-delimited JSON 在 buffer/parse 前拒绝超长记录;
- 保留最早发生的 splitter error,不让 partial decode 覆盖;
- 将错误透传为
OverMaxRecordSize,不再折叠成 InternalError。
这是一项有意的兼容性收缩。过去包含超过 1 MiB 单行或单记录的客户端,升级后必须切分输入。
第二轮:硬件相关的绕过
六月沿调用链继续检查时发现:
JSON Lines -> simdj.NewReader -> simdjson.ParseNDStream
当 simdjson.SupportedCPU() 为真时,JSON Lines 绕过 bounded json.PReader。第三方 parser 会在 chunk 结束后继续读取直到换行,普通 reader wrapper 无法同时做到“不丢失前面完整记录”和“下一条记录一定有界”。
最终选择不是继续包裹,而是让 JSON Lines 暂时全部走 bounded PReader。未来如果恢复 SIMD,它必须自己执行同样的 record bound,并通过同一组不依赖 CPU 的回归测试。
同一轮修正的流语义
复核还修正了几个相邻问题:
- 用
errors.As 透传实现 SelectError 的错误,而不是只识别一个 concrete type; - JSON worker 把 parser error 包装成
JSONParsingError; - terminal error event 之前先 flush 已完成但不足 batch size 的 output queue;
- 保留输入顺序中的错误优先级,不用更晚的 oversized record 覆盖更早的 parse error。
这些细节决定了客户端看到的是正确的失败,而不是“修复了资源上限,却破坏了流式协议”。
刻意没有塞进本 CVE 的问题
- CSV
AllowQuotedRecordDelimiter 与外层物理换行 splitter 的历史语义缺陷; - CRLF 中
\r 是否计入长度; - 在没有相同边界的情况下恢复 SIMD 性能。
这些问题有的真实存在,但需要独立的 AWS compatibility evidence 或更复杂的 quote-aware splitter,不适合借安全修复顺手猜答案。
验证与发布
历史记录包含 oversized JSON Lines、错误码保留和不依赖本机 SIMD 能力的行为测试;go test ./internal/s3select/... -count=1 与 git diff --check 均有通过记录。
公开初始修复提交为 c5765dc,六月完整修复为 fd69c89。本次博客整理没有重新执行这些测试。
最终代价
- JSON Lines 性能可能下降,本事件没有 benchmark 给出量化结果;
- 1 MiB 单记录上限会拒绝过去可接受的超大输入;
- quoted CSV multiline 语义仍需独立处理;
- 任何 CPU-specific fast path 以后都必须与 slow path 共用安全测试。
这次二次修复留下的教训是:安全不变量必须跨硬件路径成立。只在当前 CPU 上跑绿的测试,不能证明另一个执行引擎也受保护。
CVE-2026-40344:Snowball 自动解包认证绕过
Snowball unsigned-trailer 请求可以在完成认证前进入解包器;修复把 SigV4 验证前移到任何 tar 字节之前。
撰文: 冯若航 |
状态: 已发布
首个包含版本: RELEASE.2026-04-17T00-00-00Z
GitHub Advisory: GHSA-9c4q-hq6p-c237
Snowball PutObjectExtractHandler 漏掉了 streaming unsigned-trailer auth case。伪造 signature 的 tar stream 可以在认证完成前进入 untar(),而一次请求又能扇出为多个对象写入。最终修复在任何 tar 字节进入解包器之前,初始化正确 reader、处理 decoded length 并完成 SigV4 验证。
编号为什么变过
修复时正式 CVE 尚未分配,commit subject 使用了临时的 fake CVE-2026-40028。正式编号后来确定为 CVE-2026-40344。历史 commit 没有重写;公开 advisory 与本文一律使用正式编号。
从一次认证遗漏到批量对象写入
入口是 Snowball / PutObjectExtract 自动解包。请求采用 unsigned-trailer streaming,而旧 handler 没有像普通 PUT 一样覆盖该 auth type。
危险不只在于一个请求被错误授权。tar stream 一旦进入 untar(),单个请求可以创建多个攻击者指定的对象。认证错误因此被放大成批量写入问题。
最终不变量:失败时解包器必须看到零字节
修复过程中最关键的一句话是:
如果认证最终失败,untar() 必须看到零字节。
这直接排除了“先解包,认证失败后再回滚”的方案。对象写入会经过多条路径,要证明回滚完整远比证明输入从未越过边界困难。正确收口点只能在数据流进入解包器之前。
实现
最终改动包括:
- 识别
authTypeStreamingUnsignedTrailer; - 读取
X-Amz-Decoded-Content-Length; - 使用
newUnsignedV4ChunkedReader(); - 在进入
untar() 前执行完整 SigV4 request verification; - 保留合法 signed Snowball 与 CRC32 trailer flow。
验证
历史 commit 与会话记录覆盖:
- forged-signature Snowball unsigned-trailer 被拒绝;
- non-public bucket 的 anonymous Snowball 被拒绝;
- 合法签名与 trailing CRC32 可以正常解包;
- vulnerable parent 与 patched tree 的红绿对照;
- containerized before/after smoke。
公开修复提交为 b50ab58。本次博客整理没有重新运行容器测试。
兼容性与残余风险
- 过去依赖实际未验证的 unsigned-trailer Snowball 组合的客户端,升级后会失败;
- 认证已经前移,但 tar 内容路径、归档大小与对象数量限制仍是独立安全面;
- Snowball 与普通 unsigned-trailer 现在共享 reader,未来修改必须同时回归两条路径。
这个事件的重点不是多加了一次 if,而是把认证决定移动到真正的放大边界之前。
CVE-2026-41145:Unsigned-Trailer 查询认证绕过
query-string SigV4 credential 进入 unsigned-trailer 流后没有被验签,最终在共享 reader 边界统一关闭。
撰文: 冯若航 |
状态: 已发布
首个包含版本: RELEASE.2026-04-17T00-00-00Z
GitHub Advisory: GHSA-hv4r-mvr4-25vw
query-string SigV4 credential 可以进入 STREAMING-UNSIGNED-PAYLOAD-TRAILER 数据流,但旧代码只在存在 Authorization header 时验证签名。结果是:请求只要提供有效 access key 标识,即使没有正确 signature,也可能完成写入。
最终修复把 presigned rejection 与 SigV4 verification 放进 newUnsignedV4ChunkedReader(),让所有消费该数据流的 caller 共用同一认证边界。
编号说明
修复时正式编号尚未分配,commit subject 临时写为 fake CVE-2026-40027。正式编号后来确定为 CVE-2026-41145。历史 commit 保留原样,公开材料统一使用正式编号。
根因:把认证绑定到传输形式
受影响入口包括 PutObject 与 PutObjectPart。请求使用 STREAMING-UNSIGNED-PAYLOAD-TRAILER,credential 与 signature 放在 query string,而不是 Authorization header。
旧 handler 用“header 是否存在”决定要不要验签。body reader 却仍然正常读取数据,于是 query auth 被静默降级成近似匿名写入。攻击者只需要知道一个有效 access key 标识,并不需要构造正确 signature。
问题不是 query 参数没有解析,而是认证决定依赖凭据的传输形态,而不是真正消费数据流的信任边界。
为什么不逐 handler 打补丁
| 方案 | 风险 | 结论 |
|---|
在 PutObject 与 PutObjectPart 各补一段 header/query 判断 | 当前入口能闭合,但新 caller 很容易再次遗漏 | 否决 |
| 发明 presigned unsigned-trailer 兼容协议 | 协议与测试面显著扩大,又没有既有支持契约 | 否决 |
在 newUnsignedV4ChunkedReader() 统一拒绝并验签 | 所有消费者强制经过同一边界 | 接受 |
匿名 unsigned-trailer 并没有被一刀切禁用。如果 bucket policy 明确允许 anonymous write,它仍然可以按匿名授权路径工作。真正被禁止的是“带 query credential,却没有验证 credential”的混合状态。
实现与验证
修复在 cmd/streaming-v4-unsigned.go 的 reader 入口完成 presigned rejection 与 SigV4 verification,同时移除 PutObject / multipart handler 中依赖 header presence 的 gate。
新增测试覆盖 forged query PUT、multipart、mixed auth 与 anonymous policy。历史记录还包括 vulnerable parent 上写入成功、patched tree 上失败,以及 header-authenticated 与合法 anonymous flow 继续工作的 live server before/after smoke。
公开修复提交为 fa7c579。本次博客整理没有重新执行 live exploit。
兼容性与残余风险
- presigned/query unsigned-trailer 现在明确不受支持,这是有意的 breaking change;
- 修复下沉到 reader,显著降低 sibling handler 再次漏检的风险;
- 其他 streaming auth mode 仍然需要独立审计,不能因为这一条 reader 修复就宣称所有 SigV4 streaming 组合安全。
这次修复的形状比 payload 本身更重要:当多个 handler 共享同一种认证数据流时,认证应该属于 reader,而不是每个 caller 的可选判断。
CVE-2026-42600:ReadMultiple Storage-REST 路径穿越
从完整 preflight 校验到删除整条 API:一个没有生产调用者的内部文件读取接口为什么不值得保留。
撰文: 冯若航 |
状态: 已发布
首个包含版本: RELEASE.2026-06-18T00-00-00Z
GitHub Advisory: GHSA-xh8f-g2qw-gcm7
影响范围: 仅 distributed erasure;需要 cluster-root / internode JWT
/rmpl 的 msgpack body 中包含 Bucket、Prefix 与 Files,旧代码直接把它们拼成文件系统路径,没有 containment。最初的修复实现了完整 preflight validation;继续审计调用链后却发现,这条 API 从 2024 年起已经没有 production caller。最终方案因此从“保留并加固”转为删除 route、handler、client、interface 与生成代码。
删除约一千行代码看似比局部校验更大,长期攻击面却更小。
威胁模型
这个漏洞只在 distributed erasure 模式下注册,single-node 部署不受影响。攻击者需要 root secret 派生的 internode JWT、被控节点,或者能够截获未加密的节点间流量。
危险字段位于 msgpack body,不在 URL 或 form 中,因此上层 HTTP path middleware 看不到。xlStorage.ReadMultiple 会直接 join 并读取,允许路径逃出 drive root。
这不是匿名 S3 漏洞,而是从“集群 root / peer”到“节点进程可读文件系统”的边界跨越。
第一版:保留 API,完整校验
最初补丁在 xlStorage.ReadMultiple 中:
- 拒绝 absolute path、
. / .. segment、反斜杠、Windows drive prefix 与 NUL; - 校验 drive → volume → prefix → file 的最终 containment;
- 尽量保留空 Bucket 与
.minio.sys/multipart 历史契约; - 在任何 read 或 streaming 发生前返回错误。
这套设计本身可以关闭已知穿越,但审查很快发现了一个 early-return 缺口。
MaxResults 暴露了“边用边校验”的问题
第一版在读取循环里逐项验证 Files。如果请求是 Files=[good, bad] 且 MaxResults=1,函数读取第一项后提前返回,第二项永远不会被校验。
它没有直接读取第二个恶意文件,却破坏了“整个 msgpack request 必须先合法”的修复目标。于是校验被前移成全量 preflight,path length 也在 streaming 前统一检查。
这次转折留下了一条通用规则:存在 early return 或 streaming 的请求,逐项使用前检查不等于整请求安全验证。
最终决定:删除 API
进一步调用链审计确认:
- 上游在 2024 年 9 月移除了最后一个 production caller;
- multipart 已改用
ReadParts; - 当前树没有 in-tree production consumer;
- 上游正式处置也选择删除
ReadMultiple。
最终删除 route、handler、client wrapper、StorageAPI / xlStorage method、metric、datatype 与生成代码,storageRESTVersion 保持原有兼容策略。
| 方案 | 短期变化 | 长期维护面 | 结论 |
|---|
| 原地 validation | diff 较小,保留接口 | 永久保留无人使用的高权限文件读取 API | 放弃 |
| 删除 API | 删除较多接口与生成代码 | 攻击面与维护面最小 | 接受 |
验证与发布
原地校验阶段运行过 xlStorage、storage-REST client、msgpack encode/decode 与 path edge case focused tests;对抗性 review 补出了 MaxResults 问题。删除阶段检查了 route、client、interface、generated surface 与 caller absence。
公开修复提交为 73ac524,并随 SILO 2026-06-18 发布。本次博客整理没有重新运行删除后的 full suite。
兼容性与结论边界
- 外部 S3 API 没有变化;
- 私自调用内部
/rmpl 的第三方实现会失效; - mixed-version rolling upgrade 可能出现 protocol mismatch,因此升级时应保持节点版本一致;
- 删除 endpoint 只证明
ReadMultiple 不再存在,不能外推成所有内部节点 body path 都已经完成 containment 审计。
这个 CVE 的最终修复是正确的,但它也提醒我们:关闭一个 endpoint,与关闭一个缺陷类别,是两种不同结论。