pull_request_target 不是“方便 PR 跑 CI”的开关,是一条通往生产凭据的路径
2026 年 5 月 11 日,一个自我传播的供应链蠕虫通过劫持合法的 CI/CD OIDC token,攻陷了 TanStack、Mistral AI 和 170 多个 npm/PyPI 包。攻击链里有一环是 GitHub Actions 的 pull_request_target 缓存投毒:一个恶意 fork PR 触发了以仓库权限运行的 pull_request_target 工作流,攻击者利用 fork 与 base 之间的信任边界,污染缓存,最终窃取 OIDC token。这不是一个孤立的配置失误,是同一类错误模式在多个项目里的重复出现。
pull_request_target 的设计初衷是让维护者能够对来自 fork 的 PR 执行一些需要仓库权限的操作,比如打标签、评论。它运行在 base 仓库的上下文中,拥有 secrets 和写权限。危险在于:如果这个工作流检出、构建、安装、测试、lint 或以其他方式执行 PR 中的代码,那么来自 fork 的不可信代码就在一个拥有高权限的环境里运行了。
Feast 的 CVE-2026-55563 展示了一个更微妙的变体。它的 pr_integration_tests.yml 使用 pull_request_target 加 synchronize 事件,并且在新的推送提交上保留了 ok-to-test、approved、lgtm 这些标签。攻击者可以先提交一个良性修订,获得维护者批准;然后向同一个 PR 分支推送新的恶意提交,由于标签被保留,不需要重新审批,恶意代码就在特权环境中通过 refs/pull/${{ github.event.pull_request.number }}/merge 执行了。这个 job 暴露了 GCP、AWS 和 Snowflake 的凭据,允许 runner 代码执行、凭据泄露,并可能访问下游云资源。
这个攻击链的每一步单独看都不像“漏洞”。pull_request_target 是合法特性;标签保留是为了减少维护者的重复劳动;synchronize 事件是为了在新提交时重新触发 CI。组合在一起,就形成了一条从“外部贡献者”到“云凭据”的路径。防御不是“不要用 pull_request_target”这么简单,因为有些场景确实需要它。更可操作的规则是:
如果工作流需要执行 PR 代码,就不要用 pull_request_target;用 pull_request,它在 fork 的上下文中运行,没有 secrets,权限受限。如果确实需要 pull_request_target 来做打标签或评论,就把执行 PR 代码的 job 和需要特权的 job 严格分开,前者用 pull_request,后者只做元数据操作,不检出 PR 代码。如果必须在特权上下文中运行某些东西,不要保留跨提交的审批标签,新提交必须重新触发审查;用环境保护规则或外部集成在推送时移除 ok-to-test 类标签。缓存的使用要区分 fork 和 base 的命名空间,不要让 fork 能够写入或覆盖 base 会读取的缓存。OIDC token 的受众和权限要收紧到最小,一个用于测试的 token 不应该拥有部署或云资源管理的权限。
供应链安全的难点不在于不知道这些规则,而在于 pull_request_target 的便利性让人倾向于“先让它跑起来”,而把权限边界留到以后。攻击者不需要你犯一个巨大的错误,只需要一个组合出来的路径。
- 点赞
- 收藏
- 关注作者
评论(0)