对象存储"合规删除"失效现场:一条 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 还是得自己一条条列出来,平台不会替你想。