Vercel 被黑全过程拆解——一个 AI 工具怎么变成供应链攻击入口

5 阅读6分钟

Vercel 被黑全过程拆解——一个 AI 工具怎么变成供应链攻击入口

4月19号,Vercel 发了一份安全公告,承认内部系统被未授权访问。CEO Guillermo Rauch 同一天在 X 上发了长贴,点名说攻击起点是一个叫 Context.ai 的第三方 AI 工具。

这事的攻击链值得认真拆一遍。不是因为手法多新——OAuth 滥用和供应链攻击都是老套路了。值得拆是因为从入口到最终拿到客户环境变量,中间每一步都利用了我们日常开发中"默认信任"的环节。

攻击发生了什么

先理清时间线。这不是一次突击行动,而是跨了差不多 22 个月的持久渗透:

  • 2024年6月左右:Context.ai 的 Google Workspace OAuth 应用被攻破
  • 2024下半年到2025年初:攻击者通过 OAuth token 维持持久访问
  • 2025年初:从 Context.ai 的 OAuth 权限横向移动到一名 Vercel 员工的 Google Workspace 账户
  • 2025年中:进入 Vercel 内部系统,开始遍历客户环境变量
  • 2026年4月10日:有客户收到 OpenAI 的 API key 泄露通知,key 只存在于 Vercel 的环境变量里
  • 2026年4月19日:Vercel 正式披露

22 个月的驻留时间。Google Workspace 的 OAuth 审计日志默认只保留 6 个月,意味着最早期的入侵活动日志在调查开始前就已经过期了。

攻击链五个阶段

第一步:拿下第三方 OAuth 应用

Context.ai 是一个 AI 分析工具,它有一个 Google Workspace OAuth 应用,Vercel 的员工授权过这个应用。攻击者拿下了这个 OAuth 应用——具体怎么拿下的 Context.ai 那边没有公开披露。

Vercel 在公告里给出了被攻破的 OAuth 应用 ID:

110671459871-30f1spbu0hptbs60cb4vsmv79i7bbvqj.apps.googleusercontent.com

OAuth 应用一旦被授权,产生的 token 有几个特性让它成了攻击者的理想切入点:

  • 不需要用户密码就能用
  • 用户改密码对它没影响,token 照常有效
  • scope 通常很宽(邮件、云盘、日历都能访问)
  • 授权之后几乎没人会再审计它

我写了一个脚本,可以查你的 Google Workspace 里有哪些第三方 OAuth 应用还活着:

from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

def list_third_party_apps(admin_creds_path: str, customer_id: str = "my_customer"):
    """列出 Workspace 中所有第三方 OAuth 应用授权"""
    creds = Credentials.from_authorized_user_file(admin_creds_path)
    service = build("admin", "reports_v1", credentials=creds)

    # 拉 OAuth token 授权事件
    results = service.activities().list(
        userKey="all",
        applicationName="token",
        eventName="authorize",
        maxResults=500,
        customerId=customer_id,
    ).execute()

    apps = {}
    for activity in results.get("items", []):
        for event in activity.get("events", []):
            params = {p["name"]: p.get("value", p.get("multiValue", ""))
                      for p in event.get("parameters", [])}
            client_id = params.get("client_id", "unknown")
            app_name = params.get("app_name", "unknown")
            scopes = params.get("scope", "")
            user = activity.get("actor", {}).get("email", "unknown")

            if client_id not in apps:
                apps[client_id] = {
                    "name": app_name,
                    "users": set(),
                    "scopes": set(),
                }
            apps[client_id]["users"].add(user)
            if isinstance(scopes, list):
                apps[client_id]["scopes"].update(scopes)
            else:
                apps[client_id]["scopes"].add(scopes)

    # 重点关注的高危 scope
    dangerous_scopes = {
        "https://mail.google.com/",
        "https://www.googleapis.com/auth/drive",
        "https://www.googleapis.com/auth/admin.directory.user",
    }

    print(f"\n共发现 {len(apps)} 个第三方 OAuth 应用\n")
    for cid, info in apps.items():
        risky = info["scopes"] & dangerous_scopes
        flag = " ⚠️ 高危" if risky else ""
        print(f"[{info['name']}]{flag}")
        print(f"  Client ID: {cid}")
        print(f"  授权用户数: {len(info['users'])}")
        print(f"  Scopes: {', '.join(info['scopes'])}")
        if risky:
            print(f"  高危 scope: {', '.join(risky)}")
        print()

if __name__ == "__main__":
    list_third_party_apps("admin_creds.json")

跑一遍你大概率会发现几个早就不用了但 token 还活着的应用。

第二步:从 OAuth 横移到员工 Workspace 账户

拿到了 Context.ai 的 OAuth 权限后,攻击者用这个权限访问了 Vercel 一名员工的 Google Workspace 账户。这意味着攻击者能看这个员工的邮件、Google Drive 文档、日历里的会议和链接。

Rauch 在声明里用的原话是"a series of maneuvers that escalated from our colleague's compromised Vercel Google Workspace account"。具体是通过 SSO 联合认证跳过去的,还是从邮件/文档里收割到了更多凭证,Vercel 没有公开说。

这里有一个设计层面的问题:Google Workspace OAuth 应用授权后产生的 token,在用户改密码、开 2FA 之后仍然有效。想撤销得去 Workspace Admin Console 手动操作。

检查你 Workspace 账户当前活跃的 OAuth token:

# Google Workspace Admin SDK - 列出指定用户的 OAuth token
curl -s "https://admin.googleapis.com/admin/directory/v1/users/{user_email}/tokens" \
  -H "Authorization: Bearer $(gcloud auth print-access-token)" | \
  python3 -c "
import json, sys
data = json.load(sys.stdin)
for token in data.get('items', []):
    print(f\"App: {token.get('displayText', 'unknown')}\")
    print(f\"  Client ID: {token['clientId']}\")
    print(f\"  Scopes: {', '.join(token.get('scopes', []))}\")
    print(f\"  User: {token.get('userKey', 'unknown')}\")
    print()
"

第三步:进入 Vercel 内部系统

有了员工的 Workspace 账户权限,攻击者进入了 Vercel 的内部系统。Rauch 公开说攻击者的行动速度极快,对 Vercel 内部架构的了解程度很深,推测使用了 AI 辅助加速。

这也是 2026 年攻击趋势的一个缩影——攻击者用 AI 来加速漏洞利用和内部侦查,不是什么赛博朋克概念了,已经在真实事件中出现。

第四步:遍历环境变量

这一步是整个事件伤害最大的环节。

Vercel 的环境变量有一个"sensitive"标记机制。标记为 sensitive 的变量加密存储,内部系统也没法直接读取明文。但标记为"non-sensitive"的变量——虽然也是加密存储的——在内部系统有权限的情况下可以解密读取。

问题在于,在这次事件之前,Vercel 创建环境变量时默认不是 sensitive 的。很多团队在设置 DATABASE_URLAPI_KEY、各种 token 的时候,根本没注意到这个选项。

事后 Vercel 的修复措施之一就是把默认值改成了 sensitive: on。

这个设计给我们的教训很直接:安全敏感的配置项,默认值必须是最严格的那个。让用户主动选择降低安全性(opt-out),而不是让用户记住要手动提高安全性(opt-in)。

第五步:下游影响

泄露的环境变量里包含各种下游服务的凭证——数据库连接串、OpenAI API key、第三方 SaaS 的 token。

有一个用户 Andrey Zagoruiko 在 4月10日——Vercel 正式披露前 9 天——就收到了 OpenAI 的通知,说他的 API key 被泄露了。而这个 key 只存在 Vercel 的环境变量里。

这说明攻击者不只是读取了这些凭证,还在实际使用它们。

你的项目该查什么

如果你在用 Vercel,下面这些事现在就该做:

1. 检查环境变量的 sensitive 标记

# 用 Vercel CLI 列出项目环境变量
vercel env ls --json | python3 -c "
import json, sys
envs = json.load(sys.stdin)
non_sensitive = [e for e in envs if e.get('type') != 'sensitive']
if non_sensitive:
    print(f'⚠️  发现 {len(non_sensitive)} 个非 sensitive 环境变量:')
    for e in non_sensitive:
        print(f\"  {e['key']} (target: {e.get('target', 'unknown')})\")
else:
    print('✅ 所有环境变量都标记为 sensitive')
"

2. 审计 OAuth 应用授权

重点看有没有这个 Client ID:

110671459871-30f1spbu0hptbs60cb4vsmv79i7bbvqj.apps.googleusercontent.com

即使没有中招,也建议清理一遍不再使用的 OAuth 授权。

3. 检查 CI/CD 中的 Action 是否 pin 了 SHA

# 有风险——tag 可以被覆盖指向恶意代码
- uses: some-ai-vendor/vercel-deploy-action@v2

# 安全——SHA 是不可变的
- uses: some-ai-vendor/vercel-deploy-action@a3f8c1d2e4b5f6a7b8c9d0e1f2a3b4c5d6e7f8a9

4. Webhook 接收端要验签

如果你有 Vercel Webhook 触发的自动化流程,确认处理请求之前验证了 x-vercel-signature

import crypto from "crypto";

function verifyWebhook(rawBody: string, signature: string, secret: string): boolean {
  const expected = crypto
    .createHmac("sha1", secret)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// 在处理请求的入口
export async function POST(req: Request) {
  const rawBody = await req.text();
  const sig = req.headers.get("x-vercel-signature") || "";

  if (!verifyWebhook(rawBody, sig, process.env.WEBHOOK_SECRET!)) {
    return new Response("Unauthorized", { status: 401 });
  }

  const event = JSON.parse(rawBody);
  // 签名验证通过,继续处理
}

5. MCP/Agent 工具配置不要转发高权限 token

如果你在用 MCP 或者其他 Agent 工具框架,检查配置里有没有把 VERCEL_TOKEN 之类的高权限 token 传给第三方 server:

// ❌ 高风险——token 传给了第三方
{
  "servers": {
    "vercel-deployer": {
      "url": "https://mcp.some-vendor.com/vercel",
      "env": {
        "VERCEL_TOKEN": "${VERCEL_TOKEN}"
      }
    }
  }
}

// ✅ 用 scope 最小的专用 token + 本地 server
{
  "servers": {
    "vercel-deployer": {
      "command": "npx",
      "args": ["@vercel/mcp-server"],
      "env": {
        "VERCEL_TOKEN": "${VERCEL_DEPLOY_ONLY_TOKEN}"
      }
    }
  }
}

2026 年供应链攻击的规律

这不是孤例。2026 年已经出了好几起同类事件:

  • LiteLLM:PyPI 包被植入恶意代码,窃取 API key
  • Axios:npm 包被投毒
  • Codecov:CI 工具被攻破,窃取环境变量中的凭证
  • CircleCI:内部系统被攻破,客户密钥泄露

看出规律了吗?攻击者不直接攻击目标公司。攻击目标公司信任的第三方工具、依赖库、CI/CD 服务。信任关系就是攻击面。

Vercel 的 CEO 在声明里说了一句值得记住的话:攻击者对 Vercel 系统的了解程度和行动速度,说明他们"很可能得到了 AI 的大幅加速"。

安全防御这边也得跟上。我在项目里加了一个定时脚本,每周扫一遍 OAuth 授权和环境变量状态:

#!/usr/bin/env python3
"""每周安全扫描:OAuth 应用 + 环境变量审计"""
import subprocess
import json
from datetime import datetime

def audit_vercel_env():
    """检查 Vercel 环境变量的 sensitive 状态"""
    result = subprocess.run(
        ["vercel", "env", "ls", "--json"],
        capture_output=True, text=True
    )
    if result.returncode != 0:
        print("❌ Vercel CLI 执行失败")
        return

    envs = json.loads(result.stdout)
    issues = []
    for env in envs:
        if env.get("type") != "sensitive":
            issues.append(env["key"])

    if issues:
        print(f"⚠️  {len(issues)} 个环境变量未标记 sensitive:")
        for key in issues:
            print(f"  - {key}")
    else:
        print("✅ 环境变量检查通过")
    return issues

def audit_github_actions(repo_path: str = "."):
    """检查 GitHub Actions 中是否有未 pin SHA 的 action"""
    import glob
    import re

    workflow_files = glob.glob(f"{repo_path}/.github/workflows/*.yml")
    unpinned = []

    sha_pattern = re.compile(r"uses:\s+[\w\-./]+@[a-f0-9]{40}")
    tag_pattern = re.compile(r"uses:\s+([\w\-./]+@v[\d.]+)")

    for wf in workflow_files:
        with open(wf) as f:
            for i, line in enumerate(f, 1):
                if tag_pattern.search(line) and not sha_pattern.search(line):
                    match = tag_pattern.search(line)
                    unpinned.append(f"{wf}:{i} -> {match.group(1)}")

    if unpinned:
        print(f"\n⚠️  {len(unpinned)} 个 Action 未 pin SHA:")
        for item in unpinned:
            print(f"  - {item}")
    else:
        print("✅ GitHub Actions 检查通过")
    return unpinned

if __name__ == "__main__":
    print(f"=== 安全审计 {datetime.now().strftime('%Y-%m-%d %H:%M')} ===\n")
    audit_vercel_env()
    audit_github_actions()

总结几个可操作的点

这次事件给开发团队的教训不复杂,但做不做差别很大:

  1. OAuth 应用当供应商管理。每季度审计一次,不用的立刻撤销。关注 scope 范围,邮件和 Drive 权限给 AI 工具之前想清楚。
  2. 环境变量全标 sensitive。Vercel 已经改了默认值,但其他 PaaS 平台(Netlify、Railway、Render)不一定。自己检查。
  3. CI/CD Action pin SHA。tag 可以被覆盖,SHA 不行。麻烦但有效。
  4. Webhook 验签。接收外部请求不验签就等于裸奔。
  5. Agent 工具配置最小权限。MCP server 用专用的、scope 最小的 token,别把 admin token 传出去。
  6. Google Workspace OAuth 审计日志只保留 6 个月。如果你的安全调查需要更长的回溯窗口,得自己做日志转储。