潜进TeamPCP核心群的几个月:开源供应链的真实攻击面

一个安全分析员在黑客团伙的核心群里待了几个月,看着对方一步步把开源包变成投毒工具,最后把关键身份线索交给执法机构。这个故事听起来像谍战,但对维护者来说,真正值钱的部分不是卧底本身,而是事发时那套 CI 工作流到底哪个环节被绕过了。这也是本文的证据边界所在:卧底部分只能指回 Wired 的一篇采访,而 CI 工作流那部分有当事方自己的复盘可以逐条核对。

结论先行

  1. 这次攻击的技术含量并不高,靠的是三处工程疏忽的叠加。TanStack 官方复盘写得很直白:pull_request_target 触发模式、跨信任边界的 GitHub Actions 缓存投毒、从 runner 进程内存里抠出 OIDC token,三者缺一不可,而且每一处都有公开的缓解办法(当事方一手复盘,来源见文末)。
  2. 外部公告不是第一道防线。TanStack 是自己被人从外面发现的——StepSecurity 的研究员 ashishkurmi 在首个恶意版本发布约 26 分钟后提交了完整分析(官方复盘「Response timing」表中「Compromise publicly detected」一行)。也就是说,从投毒到你收到告警,中间那段空白期只能靠你自己的发布约束来兜。
  3. 真正把损失压下来的动作是「吊销+轮换」,不是「通知」。Google 的做法是先找 AWS、微软批量吊销泄露凭证,再给被影响企业发信,因为按名单逐个通知根本赶不上(一手采访,见 Wired)。
  4. 攻击者拿到 50 万条凭证,却只勒索到「几万美元量级」。也就是说,凭证的价值并不自动等于现金——但对你而言风险并没有变小,因为凭证可以被转卖给别的团伙,而那时候你收到的就不是勒索信了(该金额为 Google 研究员的估算,不是官方统计)。
  5. 依赖安装脚本的信任级别,等于你在那台机器上直接跑了一段陌生代码。TanStack 事件里,恶意逻辑就是挂在一条 optionalDependencies 上——"@tanstack/setup": "github:tanstack/router#79ac49ee…"——再由 npm/pnpm 的 prepare 生命周期脚本拉起一个约 2.3 MB 的混淆文件(官方复盘「IOC fingerprints」)。

证据与过程:三处疏忽怎么串成一条链

先看公开的事实边界。澳大利亚联邦警察(AFP)在 2026 年 8 月 27 日刊出的通报里给的表述是:「估计恶意代码可能影响了全球 1000 家以上机构,导致 50 万条以上凭证被窃取,外泄至少 300 GB 数据」;同一篇通报说「迄今为止,财务影响包括据估计达数亿美元的全球修复成本」;两名西澳男子(21 岁、23 岁)合计被控 14 项罪名。分清这里的三个口径,比记住数字更要紧:

那句话 一句话口径 该怎么标
1000 家以上机构、50 万条以上凭证、至少 300 GB AFP 通报的措辞是「估计(It is estimated)……可能(potentially)」 警方在指控阶段的估算,不是审计结论
修复成本「数亿美元」 通报原句是「据估计达数亿美元的全球修复成本」 同样是估算;参照同类口径,本文一律标为「估算」,不写「官方数据」
14 项罪名、年龄与所在州 直接来自通报正文 可直接引为事实

两名被告在庭前均未被定罪,案件仍在程序中——这一点在转述时不该省略。

谷歌这边只有一条来源:Wired 记者 Andy Greenberg 在 2026 年 9 月 18 日刊出的采访。谷歌威胁情报组(GTIG)研究员 Austin Larsen 在 SentinelOne 的 LABScon 会议上披露,Mandiant 有一名卧底分析员(身份未公开,Larsen 明确说不是他自己)在 TeamPCP 曝光初期就进入了核心圈,3 月被拉进那个约 12 人的核心聊天室 CanisterWorm。Larsen 的措辞是这名分析员只做「墙上的苍蝇」,没有参与或怂恿任何入侵。这条线是媒体一手采访,不是谷歌官方报告:我没有找到 Google 或 Mandiant 自己发布的相关报告或声明,所以可信度标为「一手采访、单一信源」。

(顺带说清「卧底时长」这个数字的来源边界:我最初写标题时用了「卧底两年」,但这句话在原始来源里找不到依据。Wired 的原话是该 persona 花了「好几个月」建立信任,3 月进入核心群,Larsen 的表述是「几乎第一天(almost day one)」就已经在场,整个行动跨度以月计。所以标题与正文一律不采用「两年」,它属于未能核实的数字。顺带补一条 Wired 里同样具体、但常被转述省略的细节:这次内圈收缩是 TeamPCP 在发现 ShinyHunters 私吞勒索款之后自己做的——把数据搬到新服务器,并把 ShinyHunters 和包括谷歌卧底在内的几名成员一起踢出 CanisterWorm 聊天室。也就是说,卧底的暴露原因不是被识破,而是被同一批清理动作顺带清出去的。)

再看维护者真正该看的那部分——TanStack 的官方复盘把整条链拆得很清楚:

时间(UTC,2026-05-11) 事件 来源标注
19:20:39 npm 收到 42 个包的约一半恶意版本发布,走的是 OIDC 可信发布,但不是工作流里那个「发布」步骤发的 官方复盘
19:26:14 第二个 run 发出每包第二个版本,合计 84 个恶意版本 官方复盘
19:46 StepSecurity 研究员 ashishkurmi 提 issue #7383 并附完整指纹 官方复盘
21:03 全量 42 包 / 84 版本完成弃用 官方复盘
22:13–23:55 npm 侧陆续移除全部受影响 tarball,第一个 22:13:38,最后一个 23:55:26 官方复盘

官方的响应耗时统计是这样的:从首个恶意版本发布到被公开发现约 26 分钟(TL;DR 里的说法是「20 到 26 分钟,取决于该版本属于两批发布中的哪一批」),从发现到全量弃用约 1 小时 17 分钟。注意这两段的含义完全不同——前一段你无法控制,后一段才是维护者能练的能力。

技术链路上,攻击者的顺序是:

1. fork 一个仓库(改名以躲开 fork 列表检索),在分支上提交恶意 payload
2. 用 pull_request_target 工作流 checkout 该 PR 的 merge ref 并执行构建
   → 分支代码在「基线仓库」的权限与缓存作用域里跑起来了
3. 脚本把污染数据写进 pnpm store,并以 release.yml 之后会查找的缓存 key 保存
   (缓存写入用的是 runner 内部令牌,permissions: contents: read 挡不住)
4. 下一次 push 到 main 触发 release.yml,恢复被污染的缓存
5. 恶意二进制在 runner 上执行,读 /proc/<pid>/mem 抠出 OIDC token
6. 用该 token 直接 POST 到 registry.npmjs.org 发布 —— 绕过了工作流里真正的发布步骤

第 5 步的 id-token: write 本身没有错:npm 官方文档明确推荐用 OIDC 可信发布来替代长期 access token(官方文档,npm Docs「Generating provenance statements」)。问题在于,一旦「不可信代码能在 runner 上执行」,这个短期凭证就变成了攻击者的发布通道。GitHub 官方文档的原话更狠一点:「不必要时避免使用 pull_request_target 触发器」,并且这类工作流不得显式 checkout 不可信代码;同一份文档还写明「把 action 钉到完整长度 commit SHA 是目前唯一能以不可变发布方式使用 action 的办法」(官方文档,GitHub Docs「Secure use reference」)。另外要注意,Actions 本身对 fork PR 有首次贡献者审批闸门,而 pull_request_target 恰好绕过它——TanStack 复盘里就记着:那次 PR 的 pr.yml(用 pull_request)一直卡在审批没跑,pull_request_target 的两个工作流却自动跑了(官方复盘)。

一个维护者能今天就做的清单

按「对应本次事故的哪一环」来排,不做泛泛的安全口号。

  1. pull_request_target 只做评论、打标签这类不执行代码的事;任何需要 checkout 并构建 fork 代码的 job,改用 pull_request,并保留首次贡献者审批闸门——本次事故里正是 pull_request 那个工作流卡在审批上没跑。
  2. 把第三方 action 引用钉到 commit SHA,不要用 @main 或浮动 tag。TanStack 的加固 PR 做的就是这个。
  3. 缓存 key 区分信任级别:PR 触发的工作流不要与 main/发布工作流共用同一缓存命名空间,也别把 pnpm store 这类可执行产物跨边界复用。
  4. id-token: write 只给真正需要发布的那一个 job,不要在工作流顶层声明;测试、构建、清理阶段都不给。
  5. 依赖安装阶段默认不信脚本:CI 里用 npm ci --ignore-scripts,并把包管理器的「最小发布年龄」约束打开——pnpm 的 minimumReleaseAge 就是干这个的(官方文档,pnpm「Settings」)。OpenAI 在官方事件说明里的说法是,他们当时正在推进这批控制项,而两台受影响设备尚未更新配置,配置本可以挡住恶意包的下载(官方说明,OpenAI 官方博客)。
  6. 告警订阅要覆盖「恶意包」而不仅是 CVE:本次受害包在 OSV 这类恶意包数据库里有独立条目和 IOC 域名——git-tanstack.com、filev2.getsession.org、api.masscan.cloud、seed1.getsession.org,这四条都能在条目里逐字查到(官方数据库条目,OSV MAL-2026-3489)。
  7. 把轮换写成剧本。官方建议:只要在 2026-05-11 装过受影响版本,就把该主机上可达的 AWS、GCP、Kubernetes、Vault、GitHub、npm、SSH 凭证全部轮换,且要在「另一台机器上」操作。
  8. 发布凭据与代码签名证书要能一键换。OpenAI 的处置是轮换代码签名证书,官方 FAQ 里点明的受影响平台是 Windows、macOS、iOS、Android,并要求 macOS 用户在期限内更新应用(官方说明,OpenAI 官方博客)。这一步慢一天,下游用户就多一天风险窗口。

把上面几条压成一份可以贴在 on-call 文档里的最小剧本:

# 收到「依赖包被投毒」告警后的头 30 分钟
# 1) 冻结新增安装面
npm ci --ignore-scripts

# 2) 受影响版本是否真进过锁文件(以本次事件的指纹位为例)
grep -n "optionalDependencies" -A3 package-lock.json | grep -i "tanstack/setup"

# 3) 网络侧处置:OSV 条目登记的 IOC 域名包括 git-tanstack.com、
#    filev2.getsession.org、api.masscan.cloud、seed1.getsession.org
rg -n "git-tanstack.com|filev2.getsession.org|api.masscan.cloud" /var/log/ /etc/

# 4) 装过受影响版本 → 在另一台干净机器上轮换该主机可达的全部凭证
#    (AWS / GCP / Kubernetes / Vault / GitHub / npm / SSH,官方给出的清单)

# 5) 反查发布通道是否被滥用
npm view @your-scope/your-pkg versions --json | tail -20

第 3 步有个细节值得记住:官方复盘指出恶意回传走的是端到端加密的 messenger 文件上传网络,不存在攻击者可控的 C2,所以网络侧唯一的缓解手段就是封域名或 IP(官方复盘)。复盘正文点名的回传域名是 filev2.getsession.org 和 seed{1,2,3}.getsession.org;上一条里的四个域名取自 OSV 条目的 IOC 字段,两者可以互相印证,但别把 OSV 那四条当成复盘原文的照抄。

一个可以自己算的规模指标:AFP 说全球修复成本估算「数亿美元」、影响 1000 家以上机构。取 1 亿到 5 亿美元作保守区间,除以 1000 家,单家机构的外部可见修复成本大致落在 10 万到 50 万美元(这是我的推算,用来估量级)。必须说清这个算式的性质:AFP 没有给出任何单家数字,也没有说明「数亿」的取整方式,把总量除以机构数本身就不是 AFP 的口径,真实分布大概率高度不均(少数大机构承担大部分成本)。这个量级和「一台开发机被投毒」听起来完全不匹配,原因正是第 5 条:一台机器上的凭证可以通向整个组织的云环境。

自己的判断

这篇素材里最容易写歪的地方,是把它写成一个「正义黑客卧底」的爽文。但把时间线拉平看,卧底的价值集中在三件事上:提前知道攻击目标、发现凭证囤积服务器、以及从对手的操作失误里拼出身份线索。这三件事都是「情报」,不是「防御」。而且必须承认:这三件事全部来自 Wired 对 Larsen 的一次专访,谷歌自己没有发布对应报告,我核对不到第二个独立信源——所以它们是「有出处的叙述」,不是「被交叉验证的事实」。真正让下游少流血的,是 Google 拿着情报去 AWS、微软批量吊销凭证这个动作——它把「逐个通知」换成了「按可利用面封堵」。这对中小团队同样成立:你没有情报源,但你可以先把凭证的可利用面收窄。

第二点判断是关于「官方 provenance 能不能救你」。npm 文档自己写清楚了:有 provenance 不代表包内没有恶意代码,它只证明「这个包是从哪、按哪份构建说明构建的」(官方文档原句:it does not guarantee the package has no malicious code)。TanStack 事件里,恶意版本恰恰是通过合规的 OIDC 可信发布通道发出去的——复盘的原话是发布「经 OIDC 可信发布绑定认证,但并非来自工作流定义的发布步骤」。所以 provenance 是溯源工具,不是检测工具;把它当检测工具用,会漏掉这一类攻击。

第三点,我认为社区最该抄的不是工具,而是「响应时间表」这个习惯。TanStack 把每个里程碑的耗时都算出来了(从发布到公开发现约 26 分钟、从发现到全量弃用约 1 小时 17 分钟、到 npm 侧最后一个包被移除约 4 小时 35 分钟)。有这张表,你才知道自己的瓶颈在发现还是在处置;没有这张表,事故复盘只剩情绪。

核实方法与残余不确定性

这篇稿子是被审稿意见打回来重修的,所以把核实方式一并交底,也方便读者判断哪些句子可以照抄、哪些只能当线索:

结论 核实方式 可信度
AFP 的 1000 家、50 万条、300 GB、数亿美元、14 项罪名 打开 AFP 官网通报正文,逐句比对原措辞 唯一来源,但属官方一手;「数亿」「可能」是估算口径
TanStack 时间线、三处根因、响应耗时 打开 TanStack 复盘全文,读时间线表与「Response timing」表 当事方一手,逐条可对
OpenAI 两台设备、证书轮换、minimumReleaseAge 打开 OpenAI 官方说明(含 FAQ) 当事方一手
git-tanstack.com 等四个 IOC 域名 打开 OSV MAL-2026-3489 条目,读 database_specific.iocs 字段 官方数据库字段,可逐字查
「provenance 不等于安全」 打开 npm Docs 原文,引其原句 官方文档
「不用 pull_request_target 的语境」 打开 GitHub Docs「Secure use reference」原文 官方文档
「卧底数月、约 12 人核心群、几万美元勒索」 Wired 单一采访,抓全文核对原句 二手转述;无第二信源,且从未有 Google 官方文本
「修复成本数亿美元」的具体构成 找不到分摊口径 未能核实,见上文

这次没核实的

  • 「卧底两年」:未能核实,已从标题和正文删除。原始来源(Wired)只支持「好几个月 / 几乎第一天 / 3 月进入核心群」这类以月计的量级。
  • 地下团伙的勒索总收入:只有 Google 研究员 Larsen 的估算「几万美元量级」,非官方统计,也没有审计口径。
  • 谷歌卧底行动本身:只有 Wired 记者 Greenberg 对 Larsen 的一次采访(2026-09-18),未找到 Google/Mandiant 的第一手报告或声明,也没有第二个独立信源;卧底分析员的身份、在场时长、具体接触对象均未公开,本文不作补全。
  • Google 披露的 AI 辅助零日漏洞与本案的直接关联:Wired 采访称谷歌 5 月发布过一份案例研究,未点名 TeamPCP;我未找到该案例研究原文,因此本文不把它当作已核实事实使用。
  • 确切的受害者完整名单:Wired 与 AFP 均只列举了部分(如 GitHub、Mercor、OpenAI、欧盟委员会),公开报道对大量受害方匿名处理,完整名单无法核实。
  • Shai-Hulud(2025 年 9 月)与 TeamPCP 的关系:Wired 明确写了「仍不清楚是否有关联」,本文不做推断。
  • 「数亿美元修复成本」的构成:AFP 只给了总量估算,没有分摊方法、没有统计机构,也没有说明是否包含凭证轮换、客户赔偿等科目;本文的单家推算仅用于量级参照。

参考来源

评论区

0 条评论

登录后可评论。

半糖去冰 2280 阅读