有序不等于递增:一个重复 Part 如何把对象变成两倍
状态: 已在本地 pgsty/minio 分支修复,提交 22c1e41fd,尚未发布
定级: 数据正确性问题,不是漏洞——见为什么这不是 CVE
影响范围: 所有后端;任何已认证的 S3 客户端,作用于它自己的上传
跟踪: pgsty/minio issue #49
本文有一节描述了相邻代码路径中一个尚未修复的进程级 panic。请在该问题修复并发布之后再上线。
结论先行
sort.SliceIsSorted配<比较符并不检查严格递增,它检查的是有没有逆序对。相邻相等不构成逆序,于是[1,1]被放行。- 上传一个 5 MiB 的 part,用
[1,1]完成,服务端返回 HTTP 200 和一个 10 MiB 的对象。且该 upload 已被消耗:用正确清单重试得到NoSuchUpload,客户端无法自救。 - 继承自上游,而且很老。 这个检查从 2016 年 8 月起就是这个形状,2017 与 2023 两次重构都把它忠实地重写了一遍——因为每次重构保留的都是比较符,而问题从来不在比较符上。
- 修复是 handler 层的一个循环。对象层按决策保持不设防,这张欠条写在这里,而不是留在某个人的记忆里。
- 三次独立评审都没有在修复本身里找到缺陷。它们找到的是一条把相邻守卫的作用写反了的注释——并顺着那条注释挖出了一个无关的节点级 panic。
问题在动词,不在比较符
继承下来的代码:
if !sort.SliceIsSorted(complMultipartUpload.Parts, func(i, j int) bool {
return complMultipartUpload.Parts[i].PartNumber < complMultipartUpload.Parts[j].PartNumber
}) {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidPartOrder), r.URL)
return
}
它读起来是"除非 part number 严格递增,否则拒绝"。它不做这件事。IsSorted 只按反方向调用比较符:对每一对相邻元素问 less(i, i-1)——“这个元素是不是比前一个小”——一旦成立就判定为无序。对于两个相等的元素,这个问题的答案是否。没有逆序,所以有序。
这里有一个必须说准的推论,因为它正是这类误用能一路通过评审的原因:任何严格比较符都不可能让 IsSorted 拒绝重复。唯一可行的写法是非严格的那个——把 <= 作为 less 传进去,让相邻相等被判成逆序。也就是说,想要"严格递增",你必须写下那个读起来"不严格"的运算符。所有检查过"这里写的是 < 没错"的评审者,检查的都是正确的字符,只是在错误的函数里。
我们的替换直接放弃 IsSorted,而不是去把它拼对:
for i := 1; i < len(complMultipartUpload.Parts); i++ {
if complMultipartUpload.Parts[i-1].PartNumber >= complMultipartUpload.Parts[i].PartNumber {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidPartOrder), r.URL)
return
}
}
拒绝集的差异恰好是一类:含有相邻相等对的清单。此前被拒的仍然被拒,此前被接受的除重复外仍然被接受。非相邻的重复是白送的——严格递增蕴含全局互异,所以 [1,2,1]、[1,3,2,3] 必然含有一个逆序对而被拦下。
两次重构都忠实地保留了它
考古部分是这次事件里最可迁移的内容。
| 时间 | 形状 | 变更 |
|---|---|---|
| 2016-08 | sort.IsSorted(CompletedParts(parts)) | server: Move all the top level files into cmd folder (#2490) 时就已存在 |
| 2017-11 | 同一调用,Less 挪到导出类型上 | Add public data-types for easier external loading (#5170) |
| 2023-04 | sort.SliceIsSorted(parts, func(i,j) bool { … < … }) | simplify sort.Sort by using sort.Slice (#17066) |
两次重构作为重构都是正确的:它们精确保留了行为,而这正是重构该做的事。2023 那次是一次全仓库范围的清理,跟 multipart 语义毫无关系。它原样搬运了 <,而 < 本身从来没错——CompletedParts.Less 必须是 < 才是合法的 sort.Interface。
缺陷活在"比较符"与"接收它的函数"之间的关系里,而一次搬运比较符的重构看不见这层关系。 十年,三种形状,同一个行为:一个回答着"与它看上去要回答的问题相邻的另一个问题"的顺序检查。
它实际做了什么
在两个 erasure 后端上、经由真实的签名 HTTP handler 实测:
| 已上传 | 完成清单 | 响应 | 生成的对象 | ETag 后缀 |
|---|---|---|---|---|
| 一个 5 MiB part | [1,1] | 200 OK | 10,485,760 字节 | -2 |
| 两个 5 MiB part | [1,2,2] | 200 OK | 15,728,640 字节 | -3 |
| 一个 5 MiB part(编号 10000) | [10000,10000] | 200 OK | 10,485,760 字节 | -2 |
ETag 后缀是服务端认为自己拼装的 part 数量。这里不存在任何可供发现的内部矛盾:元数据、大小、ETag 三者互相自洽,而且一起错。对象就是不等于用户上传的内容。
有两点让它比"错误码不对"严重得多。
upload 被消耗掉了。 拼装完整执行并清理了 multipart upload,所以用正确清单重试返回 NoSuchUpload。客户端即使发现大小不对,也无法通过重发正确清单挽回,只能整个重传——前提是数据还在。
它可以被无意触发。 不需要攻击者。任何把某个 part 在完成清单里追加了两次的客户端——断点续传封装、重试路径、拼接生成的清单,都是常见的出错方式——拿到的不是 400,而是一个静默翻倍的对象。
为什么这不是 CVE
它进入这个编年史,是因为它是一次静默的服务端正确性失效,而我们把这类事件记在这里。它不是漏洞,我们也不打算把它包装成漏洞。
请求必须携带调用者自己的凭据、指向调用者自己的 upload,受损的对象也是调用者自己的。没有跨租户影响,没有权限变化,没有信息泄露,也没有通往其他账户数据的路径。被打破的是"完成后的分段对象等于你上传的字节"这条保证——很严重,但它是一条正确性保证,不是访问控制边界。
编年表里它左右两边是认证绕过和路径穿越。把它挂上同一个标签,会让表里每一个标签都贬值一点。
边界选择,以及它的代价
对象层完全没有重复防护。erasureObjects.CompleteMultipartUpload 按请求长度分配输出切片(cmd/erasure-multipart.go:1249),然后逐个把请求里的 part number 拿去现有元数据里解析(:1255)。同一个编号解析两次成功,写出两条相同的 ObjectPartInfo,尺寸也累加两次。AddObjectPart 确实按 part number 去重,但它去重的是元数据切片,不是请求。5 MiB 最小尺寸规则同样帮不上忙,因为被重复的那一份本身就合法。
我们修了 handler,没动这里。理由:
- 它是唯一存在客户端控制清单的入口。另外四个调用方——batch、restore、decommission、rebalance——都在服务端用
oi.Parts或1..n构造清单,构造上就严格递增。 - 需要产出的是 S3 错误码,属于 API 层的关注点。对象层的错误词汇映射到另一个错误码,在更低层拦截反而给客户端更差的诊断。
- 最小化。这个 fork 只发窄修复,而改动拼装循环不算窄。
代价明确记录,而不是暗示:唯一性不变量现在只有一个执行点,而没有任何东西去执行"必须有这个执行点"。 谁给对象层添上第五个调用方,编译器不会报错,测试也不会变红,他会拿到一个静默损坏的对象。这与上一篇记录的 getVolDir 那张欠条是同一类,写下来的理由也一样:一个没有记录的刻意省略,半年后与疏忽无法区分。
我们刻意没有加的约束
part number 不必从 1 开始,也不必连续。[1,3]、[5,9]、[3] 都是合法 S3,也都仍然能成功完成。
这件事比听上去重要。“顺手要求清单必须从 part 1 开始"是一行改动,看起来像收紧,能通过一次随意的评审,而且会打断合法客户端——任何在某个 part 上传失败后放弃它、用剩下的部分完成上传的实现。诱惑之所以真实存在,恰恰因为隔壁那个修复也在校验同一份清单。
所以有两个测试用例存在的唯一目的,就是让这种改动失败。我们通过注入该约束验证了它们真的会咬:恰好那两个用例转红,其余一个都没有。 一条从未被打响过的护栏只是一个猜测。
唯一一处我们没打算要的行为变化
用 14 组输入对修复前后做差分,除了重复被拒之外只有一处行为变化:[0,0] 与 [-1,-1]——既重复又越界的清单——从 InvalidPart 变成了 InvalidPartOrder,两者同为 HTTP 400。
我们接受它,依据是格式错误应当优先于状态错误:顺序违规不需要读取任何存储即可判定,而 part 是否存在需要。而且它只影响本来就注定失败的请求,不存在"原本成功现在失败"的客户端。
至于 S3 保真度本身,我们给出的是一个有据可依的推断,不是一次测量。AWS 把 InvalidPartOrder 定义为 parts 清单未按升序排列,并且明确 part number 可以不连续;重复不满足升序。我们没有对真实 AWS 端点实测,两位独立评审者是沿着同一条文档路径得出同一结论的——那是一致,不是证据。
证伪,以及一条写错的注释
两个变异实验,遵循上一篇主张的纪律:一个你从没看它失败过的测试,还不算测试。
注入"必须从 part 1 开始”。 恰好两个跳号用例转红,四个从 1 开始的正向用例保持通过。护栏是精确定位的,不是碰巧覆盖。
删掉相邻的 len(Parts) == 0 守卫。 预期结果是空清单会得到某个"错但有序"的错误。实际结果是进程 panic:空清单一路抵达一个存储装饰器,那里在不检查长度的情况下取了 part 路径切片的第 0 个元素,而且发生在 recover 够不着的 goroutine 上。S3 面被那一行守卫挡住——它 2022 年就在那里,且没有任何地方记载它是承重的。该问题作为一个尚未修复的节点级缺陷单独跟踪,本文因此暂缓发布。
以及这段里最值得自曝的部分:我们为那个守卫写的注释是错的。 它写的是"删掉长度检查会让空清单成功"——与事实方向相反,而且恰好是低估危险的那个方向。它在评审中被抓出并在提交前更正。一条把"某个检查为什么存在"说错的注释,正是三年后这个检查被人顺手清理掉的方式。
三次验收,零阻断发现
改动在提交前经过三道独立关卡:
| 关卡 | 方法 | 结果 |
|---|---|---|
| 作者 | 回退修复,看着测试在实测的 10 MiB 上转红,再打回,看着它转绿 | 红/绿成立 |
| 独立评审者 | 在自己的 detached worktree 里重建红态,而不是采信报告;14 组输入差分 | 无阻断发现 |
| 外部模型(不同厂商) | 只读沙箱,独立推导拒绝集论证与 AWS 语义 | 带条件通过;条件是它在自己的沙箱里编译不了 |
直说,因为诚实的版本没有上面这张表好看:三方都没有在修复里找到缺陷。 评审真正产出的是那条被更正的注释,以及顺着它触发的变异实验挖出的那个无关 panic。这仍然是不错的回报,但它和"在补丁里抓到 bug"不是一回事,记录应当说清发生的是哪一种。
其中最值得抄走的细节是"重建红态"。复跑作者测试的评审者,检查的是作者的算术;独立重建损坏状态的评审者,检查的是作者的论断。
被否决的与留待处理的
刻意否决:
- 两条测试补强——在已存在的目标对象上完成、以及让每个 part 内容各不相同从而验证拼接顺序而非仅验证总大小。两条都是真实的改进,都依据一条长期规则被否决:这个 fork 发的是正确性与安全修复,不是测试扩张;而且核心不变量已经被"被拒请求没留下对象、且 upload 仍可重试"钉住了。
- XML 根元素名不做校验。 根元素写错、但
<Part>子元素正确的文档会被接受。这不是绕过——同一份清单仍然要过同一个检查——它是既有行为,且收紧它有因 namespace 处理差异而打断真实 SDK 的风险。记录,不修。
留待处理,截至 2026-08-03 全都不在已发布版本中:
- 对象层的 part 唯一性纵深防御(见上文)。
- 存储装饰器里的空清单 panic,作为节点级缺陷单独跟踪。
- XML 严格性,包括
<PartNumber>abc</PartNumber>返回 500 而正确答案是 400MalformedXML。
并行进行的完成路径 checksum 工作(#46、#48、#50)与本次改动完全隔离,不共享任何代码。
结语
比较符是严格的,动词不是。十年间所有的阅读都在看那个比较符——包括重写了这一行的那两次提交。
如果只留下一句:要看这个函数拿这个比较做了什么,而不只是看这个比较写了什么;以及,当你决定让下面那一层保持不设防时,把它写在下一个人会绊到的地方,而不要指望他会自己重新推导出你的理由。