1. 为什么私有配置会成为一场拉锯战
做云上运维的人应该都有这种体验:S3 桶在创建时默认是私有的,但真正导致数据泄露的从来不是 AWS 的默认策略,而是后续的各种“顺手操作”。比如某个开发为了给前端临时加载图片,勾选了“公开读取”;比如某个分析任务需要跨账号访问,直接在桶策略里写了 "Principal": "*";再比如用 AWS 控制台创建静态网站时,跟着向导一路 Next,最后桶就变成了 public。
问题的本质在于:S3 的权限模型是“允许优先”的,只要有一条显式允许的语句,且没有显式拒绝,访问就可能成功。 这意味着私有状态不是一劳永逸的配置,而是需要在每一次策略变更、每一个新桶创建时都保持的状态。靠“提醒大家小心”是没用的,必须建立一套体系,让错误的配置要么做不了,要么做了立刻被纠正。
这套体系就是标题里说的两条腿:预防性控制(Preventive Controls),把公有访问的路提前堵死;强制执行(Enforcement),在配置偏离预期时自动发现、自动修正。预防性控制适合在开发阶段和基础设施创建环节去落实,强制执行则适合在运行时兜底,两者配合才是一个完整的治理方案。
这篇文章的服务对象很明确:正在为团队设计 AWS 账号体系的安全工程师、被 CIS Benchmark 或等保要求追着整改的运维同学、还有那些“一个人管几个账号”的云管理员。下面讲的内容全部基于我在真实环境里用过的方案和踩过的坑,不是从文档里抄出来的概念堆砌。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. S3 对象私有的权限底层逻辑
2.1 四个访问入口,一个都不能漏
很多人对 S3 权限的理解停留在“桶策略加 Deny 就完事了”,但实际上一个 S3 对象能否被匿名访问,是由四个独立的机制共同决定的,它们是“或”的关系:只要任一入口放行了,对象就公开了。
第一个是 Bucket Policy,这是挂在桶级别的 JSON 策略,可以授权给指定账号、指定用户,也可以授权给所有人。第二个是 IAM Policy,它挂在用户或角色上,决定了一个经过身份认证的主体能对哪些桶和对象做什么操作。第三个是 ACL(访问控制列表),这是 S3 的“老古董”机制,基于 XML 的权限描述,可以单独给某个对象设置 public-read。最后一个是 Block Public Access 设置,这是 AWS 后来推出的“总开关”,可以覆盖掉前三种机制产生的公有访问。四个入口里,最常出问题的是前三个,而第四个恰好是我们做预防性控制时的主力工具。
需要特别提醒的是,S3 对象级别的 ACL 是一个历史包袱很重的功能。 在 2023 年 4 月之后,AWS 已经默认对新创建的桶禁用 ACL,但如果你的账号里存在旧桶,或者因特殊需求重新启用了 ACL,那它就是一个随时可能被点着的火药桶。我曾经在一个客户的桶里发现数千个被单独设置成 public-read 的图片对象,排查下来是很多年前某个上传脚本干的,而且这些对象的上传者早就离职了,连问都问不到。
2.2 私有不等于安全,这是最容易被误解的地方
从 S3 的设计逻辑来看,对象私有只是“没有被显式公开授权”,但如果你的访问密钥泄露了,私有对象一样可以被读取。所以我们在做“确保私有”这个课题时,脑子里要绷紧一根弦:我们做的事情不是让对象变成“锁在保险柜里”,而是确保没有合法的匿名访问通道。
这里有一个很好的生活化类比:S3 桶就像一间办公室,私有策略相当于关了门,但钥匙(访问密钥)还在保安那里。如果保安被收买了,门关不关都没用。所以完整的私有化方案通常包含两层——外层关掉所有匿名通道,内层管好身份和密钥。这篇文章的重点在外层,但如果你在落地时发现对象是私有的、数据却仍然泄露了,那要立刻去查 IAM 身份和密钥的使用情况,而不是继续在桶策略上打转。
理解了这层逻辑,再去设计解决方案时思路就清晰很多:先明确风险在哪里(匿名访问),然后用预防性控制把所有可能产生匿名访问的通道全部关闭,最后用强制执行机制确保这些通道在未来不会被重新打开。
3. 预防性控制的四个层次
3.1 账号级开关:Block Public Access 的全局默认
在我接手过的所有账号里,第一步动作都是同一个:在 AWS 控制台打开 S3 的 Block Public Access 页面,点击“编辑”,把四个复选框全部勾上,然后在弹出确认框时,手动输入 "confirm"。 这一步做完,该账号下所有新建的桶都会默认继承四个开关的全部限制。
这四个开关分别是:BlockPublicAcls(阻止通过 ACL 设置公有访问)、IgnorePublicAcls(忽略已有的公有 ACL)、BlockPublicPolicy(阻止桶策略里出现公有授权)、RestrictPublicBuckets(限制对公有桶的访问方式)。在实际操作中,这四个选项通常应该全部勾选,但有一个例外需要单独辨析,我会在 3.3 节说清楚。
如果你是通过 AWS Organizations 管理多个账号,还可以在管理账号里使用 S3 Block Public Access 的账号级设置 API,通过 SDK 或 CLI 对组织内的所有账号批量设置。这里有一个极其常见的误操作:在子账号手动关闭了 Block Public Access,但 AWS 在关闭时也会要求输入确认文字,很多运维人员看到了“确认”就直接点了,根本没意识到自己是在给“允许公有访问”这一选项颁发许可证。
3.2 服务控制策略:在组织层面禁止“打开”动作
比起逐个账号去设置,在 Organizations 里用 SCP(Service Control Policy) 才是根治方案。SCP 的作用是在账号之上做权限边界,对组织内的所有账号生效。它不会授予任何权限,只会限制权限。你可以写一条策略,显式拒绝 s3:PutBucketPolicy 且策略效果为“如果策略内容包含 Principal: "*" 就拒绝”,也可以更粗暴地拒绝 s3:PutBucketPublicAccessBlock 入参中任何一项为 false。
不过 SCP 有它本身的限制——它不是实时强制的,而且不影响已有策略。如果某个桶已经有一条允许公读的旧策略,SCP 并不会自动把它删掉。所以 SCP 更像是一个“刹停装置”,用来防止将来有人再做坏事,清理存量问题还得靠后面的强制执行。
我给一个可以落地的 SCP 示例,贴在下面。这段策略的效果是:禁止任何人修改 Block Public Access 的设置为“不阻止”,也禁止对桶设置公有的桶策略。
json复制{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyS3BlockPublicAccessDisable",
"Effect": "Deny",
"Action": "s3:PutBucketPublicAccessBlock",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"s3:PublicAccessBlockConfiguration": "true"
}
}
},
{
"Sid": "DenyS3PublicBucketPolicy",
"Effect": "Deny",
"Action": [
"s3:PutBucketPolicy",
"s3:DeleteBucketPolicy"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/PublicAccess": "true"
}
}
}
]
}
需要说明,第二段的实现方式在实际生产环境里并不完全理想,因为 ExistingObjectTag 只能匹配对象标签,无法直接检查策略内容。这块在 AWS 原生能力范围内其实是一个痛点,当前常用做法是配合后文的强制检测机制去弥补。
3.3 桶级别加固:公有访问的总闸门
账号级的 Block Public Access 是默认值,但如果某个业务真的需要公开访问(比如托管静态资源、存放公开数据集),那就必须给这类“例外”开一个口子。最稳妥的做法是:不要让所有桶共享同一个全局配置,而是把公开访问的桶放进单独的 AWS 账号,再用 SCP + Bucket Policy 做精确管控。
回到单桶层面,实际操作时我们可以在桶上单独设置 PublicAccessBlock。在 Terraform 里我就是这样写的:
hcl复制resource "aws_s3_bucket" "private" {
bucket = "my-private-bucket"
}
resource "aws_s3_bucket_public_access_block" "private" {
bucket = aws_s3_bucket.private.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
这里要批评一下 Terraform AWS Provider 在 3.x 之前的一个“坑”:早期版本里如果只定义了 aws_s3_bucket 而没有显式定义 aws_s3_bucket_public_access_block,那么桶的 Block Public Access 会沿用 AWS 账号的默认设置,而不是自动开启。你可能是按“安全默认”的思路去写的代码,结果部署出来的桶却是可以公开访问的。现在升级到 4.0 之后,AWS Provider 默认会为桶创建时附加一个拒绝公网的配置,但如果你是存量代码,还是建议把上面这段资源定义显式写出来,不要依赖隐式行为。
3.4 IAM 策略的最小化,也是预防性控制的一环
很多时候对象被公开,不是因为桶策略写错了,而是因为一个拥有 s3:PutObject 和 s3:PutObjectAcl 权限的 IAM 用户在调用上传接口时,顺带把 ACL 设成了 public-read。这一类问题用桶策略和 Block Public Access 都发现不了,因为从 S3 的视角看,这个请求是“经过身份认证的合法操作”。
因此,对所有能够往 S3 写入数据的 IAM 策略,都应当检查:有没有 s3:PutObjectAcl 权限?有没有 s3:PutObject 但未限定 s3:x-amz-acl 请求头?规范做法是,把下面这段条件加进 IAM 策略,用 Deny 来阻止带有公有 ACL 的上传操作:
json复制{
"Effect": "Deny",
"Action": [
"s3:PutObject",
"s3:PutObjectAcl"
],
"Resource": "arn:aws:s3:::my-private-bucket/*",
"Condition": {
"StringEquals": {
"s3:x-amz-acl": [
"public-read",
"public-read-write"
]
}
}
}
这段策略放在 IAM 里,它的效力是:如果你的某个程序试图往桶里传一个面向公网的文件,S3 会直接拒绝,连 ACL 都不会被创建。这可以挡掉很多“上传脚本顺手加公读”的意外事故。
4. 强制执行的体系设计:不能只靠“相信大家会遵守”
预防性控制做得再好,也只能覆盖“从今天开始的新建操作”,对于存量桶和持续变化的环境,还需要强制执行体系的介入。这套体系的逻辑一句话概括:持续检测配置状态,发现偏离就告警,必要的时候自动修复。
4.1 AWS Config 规则:配置漂移的实时哨兵
AWS Config 提供了托管规则 s3-bucket-public-read-prohibited 和 s3-bucket-ssl-requests-only,前者直接检查桶是否允许公共读取或公共写入,后者检查是否强制 SSL。启用方式建议用 CloudFormation 或 Terraform 声明式部署,而不是在控制台上逐个点击,这样后续可以审计。
在实际使用中,AWS Config 的规则有一个特性需要特别注意:Config 只会在配置变更后或定期触发时运行规则,默认频率是 24 小时。 如果你的合规要求是“分钟级发现异常”,这个频率是不够的。通过配置 aws_config_config_rule 的 maximum_execution_frequency 参数,可以把评估周期改成 1 小时,但还是有将近一个小时的暴露窗口。这里就需要引入事件驱动的实时检测,见 4.2。
4.2 CloudWatch Events + Lambda:事件驱动的实时响应
AWS 的几乎所有 S3 配置变更动作都会在 CloudTrail 里留下记录,例如 PutBucketPolicy、PutBucketAcl、PutObjectAcl 等。这就给了我们一个绝佳的实时检测机会:用 CloudWatch Events(现在叫 EventBridge)捕捉这些 API 调用,然后触发一个 Lambda 函数来做判断和处理。
我的典型做法是这样的,供你参考:
- 在 EventBridge 里建立一条规则,事件模式匹配
S3数据面和控制面的写操作,至少包含PutBucketPolicy、DeleteBucketPolicy、PutBucketAcl、PutObjectAcl。 - 事件目标是某个 Python 或 Node.js 编写的 Lambda 函数。
- Lambda 函数解析事件里的
bucketName、requestParameters等信息,调用 S3 API 去检查该桶当前的PublicAccessBlock和真实 ACL 状态。 - 如果发现有问题,就执行预置的修复动作,比如重新打开 Block Public Access,或者移除桶策略里的公有授权语句。
Lambda 的模拟代码如下,这里裁剪了实际项目里的完整实现,保留了核心逻辑。角色记得要配上 s3:GetBucketPolicy、s3:GetBucketAcl、s3:PutBucketPublicAccessBlock 权限,以及可选的 s3:PutBucketPolicy 权限用于回滚策略。
python复制import boto3
import json
s3 = boto3.client('s3')
def lambda_handler(event, context):
print(json.dumps(event, default=str))
detail = event.get('detail', {})
bucket_name = detail.get('requestParameters', {}).get('bucketName')
if not bucket_name:
return {'status': 'no_bucket'}
response = s3.get_public_access_block(Bucket=bucket_name)
config = response.get('PublicAccessBlockConfiguration', {})
if not all([
config.get('BlockPublicAcls', False),
config.get('BlockPublicPolicy', False),
config.get('IgnorePublicAcls', False),
config.get('RestrictPublicBuckets', False)
]):
s3.put_public_access_block(
Bucket=bucket_name,
PublicAccessBlockConfiguration={
'BlockPublicAcls': True,
'IgnorePublicAcls': True,
'BlockPublicPolicy': True,
'RestrictPublicBuckets': True
}
)
print(f"Fixed PublicAccessBlock for {bucket_name}")
return {'status': 'fixed', 'bucket': bucket_name}
return {'status': 'ok', 'bucket': bucket_name}
4.3 Access Analyzer:属于 S3 的体检报告
AWS Access Analyzer 是一个经常被忽略但很有价值的工具,它的作用是持续分析你的 S3 桶策略,找出哪些桶实际上是可以被外部访问的。这里的“外部”不仅指公网,还可以指其他 AWS 账号。它生成的 Findings 会列出受影响的桶、外部访问主体、访问动作和访问条件,这比盲目地用命令行 aws s3api get-bucket-acl 去逐个排查要高效得多。
我在工作里习惯把 Access Analyzer 的 Findings 输出到 Security Hub,然后在 Security Hub 里配置自定义抑制规则,把已经确认“有意公开”的桶标注为预期行为,剩下的都是必须处置的问题。注意 Access Analyzer 不会主动修改你的桶,它只做分析,别指望它能帮你修复。
5. 综合实战:从 0 到 1 搭建一套防泄露体系
5.1 前提评估与账号梳理
动手之前先做一件事:盘点你的 AWS 账号里有哪几个 S3 桶是允许公开访问的。AWS 提供了一个便捷命令,可以一次性列出所有具有公有访问权限的桶:
bash复制aws s3api list-buckets --query 'Buckets[].Name' | while read bucket; do aws s3api get-bucket-acl --bucket $bucket; done
这条命令不是官方推荐的常规排查方式,在桶很多的时候会比较慢,而且需要每个桶都有读取权限。更专业的做法是用 AWS 官方的开放工具 CloudFront Security Workshop 里的脚本,或者直接在 AWS Console 上查看 Trusted Advisor 的 S3 Bucket Permission 检查项(需要商业或企业支持计划)。我建议从 Trusted Advisor 开始,先建立一份“需要公开的桶”的清单,然后针对每个桶询问业务方“为什么公开”,答案合理的保留,答案含糊不清的一律走关闭流程。
5.2 开启组织级预防性控制
在确认需要公开的桶不超过三五个之后,就可以果断地启用组织级预防性控制。在 Organization 管理账号上创建 SCP,并将策略附加到所有成员账号所在的 OU。注意是附加到 OU 而不是单个账号,这样新加入的账号会自动继承,不需要人工处理。
同时把 aws_s3_account_public_access_block 在 Terraform 里定义好,对每一个成员账号单独开启:
hcl复制resource "aws_s3_account_public_access_block" "all_accounts" {
account_id = var.account_id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
关于这一步,请允许我多说一个踩过的坑。Terraform 里如果先创建桶,后创建 PublicAccessBlock 资源,中间是有一个时间窗口的。 在这个窗口内,桶的 ACL 是默认的 private,所以正常没问题。但如果桶策略在创建时就被设置成了公读,PublicAccessBlock 尚未生效,这期间对象就是可访问的。为了避免这种竞态,建议把新桶的创建流程纳入 CI/CD,确保 PublicAccessBlock 和桶在同一批资源里创建,而不是分两次执行。
5.3 实时检测与自动化修复的部署
我在 4.2 节给出的 Lambda 方案是一个最小的可运行版本,但在生产环境里你还需要补上以下三块能力。
第一块是 通知能力:Lambda 修复动作完成后,要把结果发到 SNS,再由 SNS 触发邮件或 Slack 通知,让安全团队知晓发生了什么事。修复不是终点,事后复盘才是关键。第二块是 审计能力:在 Lambda 里把每次触发的完整事件和修复结果写入另一个专门用于审计的 S3 桶,这个桶本身要设置成任何人都无法修改、只能由 Lambda 角色写入的防腐桶。第三块是 多方条件判定:Lambda 里不要只判断 PublicAccessBlock 是否开启,还应当判断桶策略里是否真正存在允许公共访问的语句。因为有些时候 PublicAccessBlock 是开着的,但桶策略里留了一个巨大的 Principal: "*",一旦开关被误关了,数据瞬间就裸奔了。更好的做法是同时检查策略内容和实际访问效果,两者都合规才是真的合规。
5.4 存量桶的专项清扫
即使有了上述体系,你仍然需要对存量桶做一次彻底排查。我总结了一个执行方案,比单纯看控制台更有效率:
- 用 Access Analyzer 导出所有外部访问 Findings。
- 对每一条 Finding,按“是否业务必要”归类;
- 非必要的,直接修改桶策略或 ACL 删除公有访问;
- 必要的,记录到预期清单,在 Access Analyzer 中设为 Archive,方便后续只关注新增问题。
这里最怕的是“找到了问题,但不知道怎么改”。一个比较典型的案例是:某桶策略里有一条 Allow GetObject 给到了 Principal: "*",但同时配了一个 Condition: IpAddress,要求来源 IP 必须在公司出口网段内。这类策略在技术上是有限定条件的,不算严格意义上的公网开放。但 CISO 做审计时看到 Principal: "*" 就会紧张。我的建议是:这类策略把限定条件写清楚,同时将桶名列入“预期公开清单”,在 Access Analyzer 里标记为预期后,就不会反复告警了。
6. 常见问题与排查技巧实录
6.1 我已经开启了 Block Public Access,但对象仍然能匿名打开
这是最让人崩溃的一种情况。通常原因是:对象存在于一个启用了 ACL 的旧桶里,而 Block Public Access 的设置作用域是“桶”和“账号”,不是“对象”。 早期版本的 S3 允许在对象上单独设 ACL,如果某个对象在 Block Public Access 启用前就已经被设置为 public-read,那这个对象的 ACL 记录会一直存在,但它不会影响后续访问——因为 Block Public Access 已经阻止了通过 ACL 授予的匿名访问。所以如果出现“开了 Block Public Access 还能匿名访问”,请先检查:
- 你是否真的把四个开关全开了(尤其是 IgnorePublicAcls 和 RestrictPublicBuckets);
- 桶策略里是否有显式的
Allow给到Principal: "*"的语句; - 你是否启用了 S3 网站托管端点(
bucket-name.s3-website-region.amazonaws.com),这个端点有时绕过部分 ACL 限制。
6.2 IAM 权限和桶策略之间的优先级,谁是老大?
判断规则很简单:显式 Deny 覆盖一切 Allow;在没有 Deny 的情况下,任何一项 Allow 都足以放行。 这个逻辑导致了一个安全从业人员经常抱怨的设计问题——你没办法通过“少配 Allow”来让一个桶变为私有,因为 AWS 的默认行为是隐式拒绝,但一旦有人配了一条 Allow,私有就变成了公开。所以在排查“为什么私有策略没生效”时,不要只盯防泄露的 Deny 语句,更要去全局搜 Allow。
6.3 Terraform 与 CloudFormation 的对比选择
Terraform 的强项在于跨云厂商时的统一编排,以及计划(plan)和审批(apply)两阶段的工作流,配合 Atlantis 或 GitLab CI 可以做到基础设施即代码的完整闭环。CloudFormation 的强项是和 AWS 原生的集成度,StackSets 可以在多账号部署同一套合规基线。如果团队没有历史包袱,我更推荐 Terraform,因为它对资源的 import 支持更好,用来接管存量桶很方便。
这里给一个提示:用 Terraform 接管存量桶时,先执行 terraform import,再执行 terraform plan,直接 apply 可能会把桶炸掉。 别问我怎么知道的。
6.4 自动化修复会不会误伤正常业务?
会。如果你不加条件地对所有触发修复的桶强制执行 Block Public Access,那么那些需要用 S3 做静态网站托管的桶会瞬间 403。线上事故就是这么来的。所以 Lambda 修复之前,必须先查一个白名单列表(可以存放在 DynamoDB 或直接写在环境变量里),白名单内的桶不做修复,只发告警。
个人经验是:白名单应该是“必要公开桶”的唯一合法入口。 新业务想要公开 S3 对象,必须走审批流程把桶名加进白名单和后端配置,而不是临时在 AWS 控制台上改一下 ACL。这样既保证了灵活性,也保证了绝大多数资产处于默认私有状态。
7. 我踩过的最贵的坑,写在这里
最后一次提醒,有几个看似不起眼的细节,恰恰是数据泄露高发的原因。
第一个是 CloudTrail 的日志桶必须单独治理。CloudTrail 的日志通常是以压缩包的形式写入指定的 S3 桶,很多人给这个桶设置了“仅允许 CloudTrail 写入”策略后就不管了。但实际上,如果这个日志桶的 ACL 被误设成 public-read,所有 API 调用记录就全部暴露了,等于把钥匙放在门口脚垫下面。这不是危言耸听,在企业安全评估中是高危项。
第二个是 S3 访问日志和 CloudTrail 的数据面日志要分清。默认的 CloudTrail 只记录管理事件,也就是谁在什么时候创建了桶、修改了策略。而真正的对象读写(比如谁读了你的私有对象)属于数据事件,默认不记录。如果你要做对象访问层面的安全审计,必须单独开启数据事件记录,且要付出额外的费用。这个成本不该省。
第三个是 不要把“测试桶”和“生产桶”混在一个账号里。很多泄露事故的起点是“这个桶只是临时测试一下”。测试完忘了删,生产环境的脚本又在往里写数据,最后测试桶成了一个装满生产数据的“混合体”。配合前面几套机制,这种桶可能不会被检查出来(因为它的配置从管理侧看是私有的),但一旦某个认证身份被泄露,混合桶里的数据连带风险是最高的。
在实际操作中我最想强调的还是那个判断——S3 对象私有的治理没有“完成时”,只有“进行时”。配置一次通过不代表下一分钟仍然私有,你今天加的 Deny 语句挡得住控制台操作,挡不住自动化的脚本调用 API。所以请务必把预防性控制和强制执行当成两条平行的轨道同时推进:一侧用 SCP、Block Public Access、IAM 条件把路堵死,另一侧用 Config、EventBridge、Lambda 盯住每一个可能的开放动作。两条轨道都在正常运转时,S3 里面的数据才算真正睡得踏实。
