SN-2026-004:对象级策略授权越界命中桶级变更操作
SN-2026-004 修复了 silo-pkg v3.11.0(提交 3c24ad1、1f97549、d8b1fa7)与 Server 20260804(97b7d2804;上游参考 minio/minio#20449)中的一处 IAM 资源匹配缺陷:在评估桶级 S3 请求(args.ObjectName == "")时,Statement.IsAllowed 会固定在桶名末尾拼接 '/' 生成 "bucket/" 作为待匹配资源字符串,导致原本仅面向桶内对象的通配授权(如 Allow s3:* on arn:aws:s3:::bucket/*)能够越界放行 s3:PutBucketPolicy、s3:DeleteBucket 等高危桶级变更操作。
结论先行
- 根本原因:当
args.ObjectName为空时,Statement.IsAllowed无条件向桶名追加了'/'(得到"bucket/"),从而被"arn:aws:s3:::bucket/*"的末尾通配符*匹配命中。 - 多租户提权风险:在多租户场景下,被授予
Allow s3:* on arn:aws:s3:::tenant/*(常见的“允许操作该桶内所有对象”简写写法)的租户凭据,可以调用PutBucketPolicy为该桶写入Principal: "*"公开策略,或调用DeleteBucket、PutReplicationConfiguration、PutBucketLifecycle、PutBucketVersioning与PutBucketObjectLockConfiguration。 - 为何不能一刀切去掉末尾斜杠:直接对所有空对象名请求去掉
'/'会立刻破坏 11 个现有的 STSListBucket兼容性测试,并且会导致Deny s3:* on arn:aws:s3:::bucket/*拒绝策略失效。 - 精准收敛边界:针对
Allow语句上的 12 项高危桶级变更操作(!legacyBucketResourceMatch && statement.Effect == Allow && isSensitiveBucketMutation(args.Action)),资源模式必须同时匹配"bucket"与"bucket/"。若存量环境短期内依赖旧行为,可通过MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on临时恢复20260804之前的匹配逻辑。
为什么 arn:aws:s3:::bucket/* 会命中桶级请求
在 silo-pkg/bucket/policy/statement.go(以及 iam/policy/statement.go)中,Statement.IsAllowed(args Args) 原本按如下逻辑构造待与 Statement.Resources 比对的资源路径:
由于 else 分支无条件追加了 "/",每一个不带对象名的桶级请求(args.ObjectName == "")都会被构造为 resource = "mybucket/"。当它与资源模式 "arn:aws:s3:::mybucket/*" 进行通配比对时,* 会匹配 "/" 之后的空后缀并返回 true。
多租户环境下的越权影响
在 AWS IAM 与 S3 桶策略语义中,arn:aws:s3:::mybucket 代表桶本身,而 arn:aws:s3:::mybucket/* 代表桶内的对象。管理员在为租户或业务团队分配“桶内对象全权读写”权限时,经常将动作简写为 s3:* 并将资源限定在对象 ARN 上:
由于 s3:* 同时涵盖了对象级动作与桶级动作,而 "arn:aws:s3:::tenant-a/*" 又命中了 resource = "tenant-a/",持有该对象级策略的调用方便可在 tenant-a 桶上执行高危桶控制面变更:
s3:PutBucketPolicy/s3:DeleteBucketPolicy:将桶策略篡改为"Principal": "*"以公开暴露整个桶,或直接为自己写入显式的桶级权限;s3:DeleteBucket/s3:ForceDeleteBucket:直接删除整个桶;s3:PutReplicationConfiguration:将桶复制目标指向攻击者控制的外部端点以窃取数据;s3:PutBucketLifecycle:配置生命周期规则使桶内所有对象与历史版本自动过期销毁;s3:PutBucketVersioning/s3:PutBucketObjectLockConfiguration:挂起版本控制或篡改默认合规保留配置。
为什么不直接全局移除末尾斜杠
我们的第一版原型曾尝试直接删掉 else { resource += "/" } 分支,让所有 args.ObjectName == "" 的请求一律拿裸桶名 "mybucket" 去匹配。但运行测试集与代码审查后,我们发现这种一刀切改法会引发两个严重回归:
- 破坏存量
ListBucket兼容性:cmd/sts-handlers_test.go中有 11 个继承自上游的 STS 与 IAM 测试用例专门在arn:aws:s3:::mybucket/*上授予s3:ListBucket(或s3:*),而并未同时列出裸桶 ARNarn:aws:s3:::mybucket。如果对读取/列举操作也去掉"/",许多仅配置了arn:aws:s3:::mybucket/*的存量业务将无法列举桶。 - 削弱
Deny拒绝策略:如果管理员编写了Deny s3:* on arn:aws:s3:::mybucket/*期望封禁该桶上的所有操作,把args.ObjectName == ""的匹配目标从"mybucket/"改为"mybucket"会导致该Deny语句不再命中桶级调用——原本用于收紧安全的补丁反而会变成Deny绕过漏洞!
修复方案:按动作收敛的双重资源匹配
为了在不破坏 ListBucket 兼容性且不削弱 Deny 语句的前提下阻断提权路径,Statement.IsAllowed 将严格检查精准限定在命中高危桶级变更动作的 Allow 语句上(silo-pkg v3.11.0,并在服务端提交 97b7d2804 中接入):
对于 Allow 语句上的这些高危桶级变更动作,资源集合必须同时匹配裸桶名 "mybucket"(从而拒绝仅包含 arn:aws:s3:::mybucket/* 的策略,同时放行包含 arn:aws:s3:::mybucket 或 arn:aws:s3:::* 的策略)以及 "mybucket/"(原因见下文为什么单调性要求同时匹配两者)。
第二轮动作审计:哪些桶级写操作被纳入保护
初版草稿仅拦截了 PutBucketPolicy 与 DeleteBucketPolicy,遗漏了 DeleteBucket 以及生命周期、复制等变更操作。在第二轮审查中,我们逐一盘点了所有桶级写动作(args.ObjectName == ""),并将它们明确划分为两组。
纳入 isSensitiveBucketMutation 保护的 12 项高危桶级变更动作包括:
s3:PutBucketPolicys3:DeleteBucketPolicys3:DeleteBuckets3:ForceDeleteBuckets3:PutBucketLifecycles3:PutReplicationConfigurations3:PutBucketVersionings3:PutBucketObjectLockConfigurations3:PutBucketQuotaConfigs3:PutBucketRetentionConfigs3:PutBucketCorss3:DeleteBucketCors
(注:Server 20260804 发布时桶级 CORS 接口尚未启用;当 Server 20260903 在 #71 / #80 中正式实现桶级 CORS 时,s3:PutBucketCors 与 s3:DeleteBucketCors 已提前在 isSensitiveBucketMutation 中受控。)
另有 4 类低风险或自动化建桶常用的桶级操作被有意保留为历史匹配行为,以免破坏将自动化凭据限定在 arn:aws:s3:::app-bucket/* 的存量部署流水线:
s3:CreateBucket(使用前缀受限策略自动创建桶本身);s3:PutBucketTagging/s3:DeleteBucketTagging;s3:PutBucketEncryption;s3:PutBucketNotification。
为什么单调性要求同时匹配 "bucket" 与 "bucket/"
我们在设计修复时要求满足严格的安全单调性(Monotonicity)不变量:当 MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=off(默认值)时,任何在旧逻辑下被拒绝的请求,绝不能在新逻辑下反而被 Allow 语句放行。
在代码审查中,这一单调性证明经历了两次迭代才最终严密:
- 第一次尝试(在
NotResource上翻车):最初我们只检查了statement.Resources.Match(args.BucketName, ...)而未同时检查"mybucket/"。此时如果策略使用了反向排除NotResource: ["arn:aws:s3:::mybucket/*"],在旧逻辑下"mybucket/"命中排除集因此被拒绝;而只检查"mybucket"时它不再命中排除集,结果反而从拒绝变成了放行! - 第二次尝试(在单字符通配符
?上翻车):即便不使用NotResource,仅检查args.BucketName也会在定长通配符?上破坏单调性!考虑策略Allow s3:PutBucketPolicy on Resource: ["arn:aws:s3:::mybucke?"]:在旧逻辑下,待匹配串"mybucket/"长度为 9 个字符,因此 8 个字符的mybucke?不匹配,请求被拒绝;如果新逻辑只拿 8 个字符的"mybucket"去匹配,mybucke?就会突然匹配成功,从而放行了一个旧模式下原本拒绝的请求(1f97549)。
因此,要求 statement.Resources.Match(args.BucketName, ...) 与 statement.Resources.Match(resource, ...)(即同时匹配 "mybucket" 和 "mybucket/")从数学上保证了新规则下通过的 Allow 集合必然是旧规则的真子集(NewAllow = LegacyAllow ∧ MatchesBareBucket,提交 d8b1fa7),彻底消除了在任何 Resource 或 NotResource 组合下出现反向放行的可能。这也意味着像 arn:aws:s3:::mybucke? 这样只匹配 8 字符 "mybucket" 却不匹配 9 字符 "mybucket/" 的人工边界模式将无法授权高危桶变更;管理员应规范编写 arn:aws:s3:::mybucket 或 arn:aws:s3:::mybucket*。
运维影响与兼容逃生开关
- 规范编写的策略无需任何改动:凡是在
arn:aws:s3:::mybucket、arn:aws:s3:::mybucket*或arn:aws:s3:::*上授予桶管理权限的策略,升级后行为完全不变。 - 仅含
arn:aws:s3:::mybucket/*的策略:依然可以正常读写对象并调用ListBucket、CreateBucket、PutBucketEncryption、PutBucketTagging和PutBucketNotification,但在调用上述 12 项高危桶级变更动作时会收到AccessDenied,除非在Resource中显式补上arn:aws:s3:::mybucket。 - 兼容逃生开关:如果存量自动化流程在迁移过渡期内确实依赖
arn:aws:s3:::mybucket/*执行上述 12 项高危桶变更,可临时设置环境变量MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on,待策略修正后再关闭。
留白与后续演进
- 不做启动期存量策略自动改写:SILO 在升级时不会自动修改已存储的 IAM 策略或桶策略;运维人员可通过
mcli admin policy info检查是否有管理角色依赖arn:aws:s3:::bucket/*执行桶级变更。 - 保持标准
AccessDenied响应:遵循 S3 安全惯例,鉴权拒绝响应不会向调用方披露具体是哪条语句或哪个 ARN 模式未命中。
总结
随 Server 20260804(5ff00073a 及 silo-pkg d8b1fa7 / v3.12.0)发布后,12 项高危桶级变更操作默认要求策略 Resource 同时匹配桶级 ARN(除非显式开启 MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on)。