对象存储"合规删除"失效现场:一条 s3:Get* 通配把历史版本全放了出来,ListBucket 只挡住了三分之一的入口
上个月接了个合规审计的活儿。客户是做消费金融的,监管要求"客户注销后 30 个工作日内物理删除全部影像件与身份证明扫描件"。他们跑完注销脚本的第 41 天,审计方在对象存储上执行了一条命令,拉回了一份 2021 年的身份证正反面扫描件,字节级完整,SSE 解密后照样能看。
客户运维第一反应是"不可能,我们桶策略 Deny 了匿名读"。第二反应是"审计日志里没有人调过 ListObjects"。两个判断都是真的,但结论全错。这篇就把这条链子拆开。
第一现场:删除标记(Delete Marker)不是删除
这个桶开了版本控制,客户的注销脚本走的是 SDK 的 DeleteObject。开了版本控制之后,这个调用干的事是往对象上盖一个删除标记,让当前版本视图不显示这个 key,底层数据一个字节没动。
# 运维以为的"删除了"
DELETE /contract-archive/2021/kyc/3101xxxxxx_scan.pdf
# 实际存储里长这样
VersionId: 9V7fP... IsLatest=false Size=1102883 <-- 数据本体还在
VersionId: cT2kQ... IsLatest=true DeleteMarker=true
所以"物理删除"这四个字,在版本控制开启的桶上根本轮不到 DeleteObject 来实现。真正的实现只有两种:删除指定 versionId 的 DeleteObjectVersion,或者生命周期里的 NoncurrentVersionExpiration。客户两样都没配,只配了 Expiration(那是给当前版本用的),于是数据静默留存了 41 天,理论上会留存到桶被删掉那天。
第二现场:ListBucket 这个 action 只覆盖了两个 API
客户的桶策略里确实有一条全量 Deny,但 ARN 写错了:
{
"Sid": "DenyExternal",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:ListBucket", "s3:GetObject"],
"Resource": ["arn:aws:s3:::contract-archive/*"],
"Condition": {"StringNotEquals": {"aws:PrincipalArn": "arn:aws:iam::123456789012:role/ops"}}
}
两个问题叠在一起:
- ARN 漏了 bucket 本体。
s3:ListBucket作用在arn:aws:s3:::contract-archive这个资源上,不带/*。你只写/*,这条 Deny 对 ListBucket 根本不生效,只对 GetObject 生效。看策略的时候觉得"我全拒了",实际上 bucket 级操作一条都没拦住。 - 即便 ARN 写对了,Action 列表也只覆盖了三分之一的列举入口。S3 兼容协议里,能从桶里"列东西"的动作至少有四个,各自是独立 action:
s3:ListBucket -> GET /bucket?list-type=2 (ListObjectsV2)
s3:ListBucket -> GET /bucket (ListObjects v1)
s3:ListBucketVersions -> GET /bucket?versions (含删除标记, 能看全部历史 key)
s3:ListBucketMultipartUploads -> GET /bucket?uploads (正在进行的分片上传, 泄露 key 名)
s3:ListMultipartUploadParts -> GET /bucket/key?uploadId=xxx (泄露分片大小与 ETag)
客户只 Deny 了第一个。审计方扫到的是 ?versions,桶策略对 s3:ListBucketVersions 没有任何声明,IAM 侧那个 BI 角色又开着 s3:List* 通配,于是直接放行。
第三现场:s3:Get* 这条通配是整条链子的核心
BI 团队当年为了省事,角色策略写的是:
{
"Effect": "Allow",
"Action": ["s3:List*", "s3:Get*"],
"Resource": ["arn:aws:s3:::contract-archive", "arn:aws:s3:::contract-archive/*"]
}
在没有版本控制的年代,s3:Get* 约等于只读,最多多给一个 GetObjectAcl,无伤大雅。开了版本控制之后,这条通配的权限面被静默扩大了——它把 s3:GetObjectVersion 一起放进来了。
而 s3:GetObject 和 s3:GetObjectVersion 是两个不同的 action。请求里只要带上 versionId 参数,鉴权就走后者。BI 角色的意图是"只读当前版本",实际拿到的是"读全部历史版本,包括被 DeleteMarker 盖住的"。审计方的操作就三步:
# 1. 列出所有版本, 拿到 key 和 versionId
aws s3api list-object-versions \
--bucket contract-archive --prefix 2021/kyc/ --max-items 5
# 2. 直接按 versionId 取回被"删除"的对象
aws s3api get-object \
--bucket contract-archive \
--key 2021/kyc/3101xxxxxx_scan.pdf \
--version-id 9V7fPqR2xK... out.pdf
# 3. 匿名路径同理, 一行 curl 就够
curl -s "https://contract-archive.oss.example.com/2021/kyc/3101xxxxxx_scan.pdf?versionId=9V7fPqR2xK..." -o out.pdf
注意第 3 步:如果桶上还有任何一条给匿名或 CDN 回源角色的 s3:Get*,这条路是通的。而 CDN 的缓存 key 当时配的是"忽略全部 query string",导致这个带 versionId 的响应在边缘节点上还留了一份副本,删除后至少又多存活了一个 TTL。
为什么审计日志里"没人列过"
这条最坑。客户的巡检 SOP 里 grep 的是这些事件名:
grep -E "ListObjects|GetObject " access.log
而真实的调用事件名是 ListObjectVersions 和 GetObjectVersion,一个都不匹配。GetObject 后面还带个空格,连前缀模糊都没匹配上。日志全都在,是查询语句把人骗了。改完之后:
grep -E "ListObjects?(V2)?|ListObjectVersions|ListBucketVersions|GetObject(Version)?|ListMultipartUploads" access.log \
| awk '{print $8, $9, $10}' | sort | uniq -c | sort -rn
加固:把六个动作和前缀条件一次钉死
分三层做,缺一层都能被绕。
第一层:收窄 action,禁止通配
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BiReadCurrentOnly",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::contract-archive",
"arn:aws:s3:::contract-archive/*"
],
"Condition": {
"Null": {"s3:versionid": "true"}
}
},
{
"Sid": "DenyAllVersionSurface",
"Effect": "Deny",
"Principal": "*",
"Action": [
"s3:GetObjectVersion",
"s3:DeleteObjectVersion",
"s3:ListBucketVersions",
"s3:ListBucketMultipartUploads",
"s3:ListMultipartUploadParts",
"s3:AbortMultipartUpload"
],
"Resource": [
"arn:aws:s3:::contract-archive",
"arn:aws:s3:::contract-archive/*"
]
}
]
}
"Null": {"s3:versionid": "true"} 的意思是"请求里不带 versionId 时才放行",这条能兜住 SDK 层偷偷带参数的场景。资源两行都要写:bucket 本体给 ListBucket,/* 给对象级操作,少一行就漏一层。应急只读角色单独开一条 Allow,别混在业务角色里。
第二层:让"删"真的删
{
"Rules": [{
"ID": "purge-noncurrent-fast",
"Status": "Enabled",
"Filter": {"Prefix": "20"},
"NoncurrentVersionExpiration": {"NoncurrentDays": 1},
"Expiration": {
"ExpiredObjectDeleteMarker": true
}
}]
}
ExpiredObjectDeleteMarker: true 用来清理"所有旧版本都过期了、只剩一个删除标记"的 key,否则桶里会积累一堆空壳删除标记,ListObjectVersions 照样能列出来(只是没有可读内容)。如果监管要求彻底不可恢复,生命周期不够——它能被有 PutBucketLifecycle 权限的人改掉。真要做到位得上 Object Lock 的 COMPLIANCE 模式,GOVERNANCE 模式只要有 s3:BypassGovernanceRetention 就能删,别拿它当合规证据。
第三层:验收脚本,别靠肉眼
#!/bin/bash
B=contract-archive
K=2021/kyc/3101xxxxxx_scan.pdf
E=https://oss.example.com
# 必须 403
curl -s -o /dev/null -w "anon-list %{http_code}\n" "$E/$B?list-type=2"
curl -s -o /dev/null -w "anon-versions %{http_code}\n" "$E/$B?versions"
curl -s -o /dev/null -w "anon-uploads %{http_code}\n" "$E/$B?uploads"
curl -s -o /dev/null -w "anon-get-ver %{http_code}\n" "$E/$B/$K?versionId=9V7fPqR2xK..."
# 必须 200
curl -s -o /dev/null -w "anon-get-cur %{http_code}\n" "$E/$B/$K"
这五条跑完全绿才算加固完成。客户收尾时补了一轮历史扫描,用 list-object-versions 把全桶 2.1TB 的非当前版本翻出来,其中有 780GB 是"以为删了"的影像件,全部转成显式 DeleteObjectVersion 清掉,同时把 CDN 的缓存 key 从"忽略全部 query"改成"保留白名单参数"。
最后说句选型上的经验:版本控制、对象锁、审计日志这三件事,配置界面上都是勾选框,但生效语义差别极大——版本控制一开,s3:Get* 这种通配的权限面立刻膨胀,审计日志的事件名也全变了。轻云互联那边的对象存储在上版本控制时会把 ListBucketVersions / GetObjectVersion 的事件单独打标,日志里能跟普通读写区分开,排查这类问题省不少事;但无论平台上什么,策略里那六个 action 还是得自己一条条列出来,平台不会替你想。