十一项测试通过
Straiker 公开了一个仓库,无需越狱就能让 Claude Code 运行恶意软件。它的 CONTRIBUTING.md 把审查交给仓库自己定义的子代理,模型固定为 Haiku,范围刻意避开测试文件夹。随后 Fable 运行 pytest,一个测试文件下载并启动 Sliver 植入程序,终端报告 11 passed。Anthropic 将这份报告以 Informative 结案:信任仓库的配置,属于用户的工作区信任决定。Bromure Agentic Coding 决定的是这份信任能触及什么:一台只持有替身密钥的虚拟机、Mac 上的防火墙,以及仓库无法篡改的追踪记录。
代理一步不落地遵循了项目文档里的工作流程,而这套流程是攻击者写的。终端显示 11 passed。
你在 GitHub 上找到一个小小的指标客户端,能帮你省下一周的工夫,于是让 Claude Code 把它克隆下来看看。它干得不错:读了代码,指出一个没有上限的缓冲区,以及一个遇到有毒消息就会永远空转的重试循环,并给出修复。你照例吩咐:把这些修好,准备一个 PR。
几分钟后,修复完成,测试全绿,摘要写着 11 passed。就在这几分钟里的某个时刻,一个植入程序在你的笔记本上打开了远程 shell。
这是 Straiker STAR Labs 的 Brian Cumi 搭建的场景,于 9 月 22 日以 Fable to Haiku: How a Malicious Repo Tricked Claude Code Into Running Malware 为题发布。Adversa 的 10 月编程代理安全研究汇总 把它列为本月的攻击手法之一。这个仓库没有利用任何漏洞。每一步都只是按设计者的本意,使用一项有文档记载的功能。
仓库如何审查自己
这个仓库 northstar-metrics 看起来是个普通的开源项目,它的第一招也很平常:一份
CONTRIBUTING.md。你要求提一个 PR,就等于把代理绑在了项目的贡献流程上,而一个遵循指令的代理会去读贡献指南。这份指南说,审查者在运行测试之前,应该先运行项目的初审代理。
那个代理就在仓库里,位于 .claude/agents/first-pass.md。Claude Code
允许项目在这个文件夹里定义自己的子代理,并在 frontmatter 中指定模型和范围。这一个设置了 model: haiku,并把审查限定在
src/ 和 docs/。它的指令还补了一句:“在主会话中阅读,不能代替运行这个代理”,好让主代理不会跳过它。
完整的链条如下:
- 主模型 Fable 把审查交给仓库的子代理。
- 子代理在 Haiku 4.5 上运行,只看
src/和docs/。 - 它带回两条作者专门埋好让它发现的问题:一个硬编码的版本字符串,以及文档与代码之间的不一致。两者都无害。
- 文档中的流程认为审查已经完成。没有人打开
tests/test_compat.py。 - Fable 运行
pytest。 - 在断言之前,
test_compat.py用十六进制解码出一个 URL,下载一个 Python 文件,并用subprocess启动它。这段代码包在try/except: pass里,断言永远成立。 - 一个 Sliver 植入程序建立命令与控制会话。pytest 打印出 11 passed。
Cumi 对其奏效原因的总结是:这个仓库依赖三个平常的假设。项目可以定义自己的子代理,代理会遵循项目文档里的流程,而一次全绿的测试就算检查过了代码。“一个理解这三个假设的恶意仓库,不需要打破其中任何一个。它只需要待在三者交汇的缝隙里。”
Straiker 在 9 月 14 日通过 HackerOne 报告给了 Anthropic。Anthropic 以 Informative 结案:遵循仓库的配置,属于用户的工作区信任决定。
信任决定,在实践中意味着什么
Anthropic 的答复与 Claude Code 的工作方式一致。你打开一个文件夹时,系统会问你是否信任它,而你选了是。这个决定涵盖
CLAUDE.md、hooks、子代理和测试套件。
问题在于你当时决定的是什么。你对这个仓库的信任,是足以看看它。这正是你克隆它的目的。在笔记本上,“信任这个文件夹到可以运行它的测试”,等于把你的账户能触及的一切都交给它,因为
pytest 就在那里运行。Straiker 列出了植入程序拿到的东西:你能读取的每个文件、内存中的凭据、磁盘上的 SSH
密钥,以及你有推送权限的每个仓库。
没有哪个审查者,无论是人还是模型,会读完每个待评估项目的每个测试文件。这次的审查是被刻意缩短的,但许多诚实的审查同样很短。你能选择的,是测试套件在哪里运行。
在 Bromure 机器里运行同一个仓库
在 Bromure Agentic Coding 中,Claude Code 运行在你 Mac 上的一台 Linux 虚拟机里。每台机器都有自己的磁盘、自己的主目录和自己的凭据,所有流量都经由 Mac 上的代理服务器出去。工作区信任决定仍然由你来做。改变的是,受信任的仓库能触及什么。
在 Bromure 机器里重演 Straiker 的链条。第一到第五步照旧发生:代理读了贡献指南,子代理审查了错误的文件夹,pytest
运行。从第六步开始,仓库面对的是 Mac。
下载必须经过 Mac
载荷得先拉下来。测试解码出一个 URL,向一台项目从未需要过的主机请求一个文件。在 Bromure 机器里,对外只有一条路:虚拟网络交换机把每个发往 80 和 443 端口的连接转入 Mac 上的代理服务器,其余所有连接,无论什么协议,都拿去比对机器的规则。
这些规则位于机器的 Guardrails 面板中的 Outbound connections:按主机、IP 范围、协议和端口设置的允许与拒绝条目,再加上一个 Unmatched traffic 设置,取值为 Allow 或 Deny。一台用来评估陌生人代码的机器,只需要一份短名单:模型提供商、软件包仓库、GitHub。
allow web api.anthropic.com
allow web pypi.org
allow web files.pythonhosted.org
allow web github.com
default deny
在这些规则下,test_compat.py 里的下载根本连不上,而作者为了掩盖失败加上的 try/except: pass,如今掩盖的正是这一次。pytest
仍然打印出 11 passed。区别在 Mac 上。机器的 Security Timeline 里有一行红色的 Firewall
记录,带着拒绝判定、测试试图连接的主机和端口,时间戳就在代理运行 pytest
几秒之后。一个指标客户端的测试套件没有任何理由去连接一台你从没听说过的主机,而现在你知道它试过了。
植入程序后续的流量也受到同样的对待。无论 Sliver 是通过 HTTPS、自有端口上的双向 TLS、WireGuard 还是 DNS 回连,连接都得经过交换机出去,而交换机用同一张表比对每一种协议。
植入程序找到的是替身
假设机器允许未匹配的流量,载荷送达,Sliver 也建立了会话。攻击者现在在虚拟机里有了一个 shell,能触及的范围与 Straiker 为笔记本列出的相同。在 Bromure 机器上,这个范围涵盖的东西少得多。
- 文件。 虚拟机里只有它自己的主目录,以及你选择与这台机器共享的项目文件夹。你 Mac 的主目录、其他项目、浏览器配置文件和钥匙串都不在里面。
- 凭据。 Bromure 把真正的 API 密钥、OAuth 令牌、git 令牌、云端机密和数据库密码加密保存在 Mac
上。虚拟机持有的是格式正确的替身:
ANTHROPIC_API_KEY=sk-ant-api03-brm-…、一个 GitHub 会拒绝的ghp_令牌、一把什么也签不了的 AWS 秘密密钥。代理服务器在请求离开虚拟机之后才换上真实值,而且只针对该凭据所属的主机。 - SSH 密钥。
ls ~/.ssh/id_*什么也找不到。虚拟机的SSH_AUTH_SOCK是一座通往 Mac 上 ssh-agent 的桥。虚拟机可以请求签名,但读不到密钥。为某把密钥打开 Ask before use,每次签名请求都会在 Mac 上弹出对话框。
一个把能搜集的东西都送回老家的植入程序,会触发第二根绊线。替身令牌只有一个合法目的地,就是为它签发的那台主机。当代理服务器看到
sk-ant-api03-brm-… 或那个 ghp_ 令牌被发往其他地方,它会以 HTTP 451 拒绝请求、暂停虚拟机,并在 Mac 上弹出标题为
This environment may have been compromised
的警报,列出该令牌、为它签发的主机,以及它被发往的主机。流出去的只是替身,所以没有什么需要轮换。
推送要先问过
Straiker 清单上的最后一项“有推送权限的仓库”,正是把一台被入侵的笔记本变成供应链事件的那一项。一个握有 shell 和你 git 凭据的攻击者,可以把一个 commit 推送到你维护的每个项目。
在 Bromure 机器上,推送到 GitHub、GitLab 或 Bitbucket 属于写入,而新机器出厂时,每个受保护的服务都启用了 Prompt
before write。代理服务器扣住这次推送,macOS 显示 Allow write on "github.com" from workspace "…"?,并附上它在线路上出现的原样操作
POST /acme/app.git/git-receive-pack。你并没有要求推送,于是点 Don't allow,植入程序收到一个以 blocked by
Bromure Guardrails 结尾的 403。macOS 依据代理服务器读到的内容绘制这个对话框,所以虚拟机里的任何东西都改不了它的措辞。
追踪记录揭示没有发生的审查
尘埃落定之后,你会想知道代理做了什么。在默认追踪级别 AI request details 下,代理服务器保存与模型提供商的每一次往来,加密存放在 Mac 上,Trace Inspector 再把每一条解析成工具调用、shell 命令和文件读取。
对这个仓库来说,记录回答了真正要紧的问题:
- 审查是哪个模型做的? 捕获到的请求体写着它的名字:
claude-haiku-4-5,就在主会话读取.claude/agents/first-pass.md的下一轮。 - 它读了什么? 每一个
Read调用及其路径。有读取src/和docs/下文件的调用,没有任何一个读取tests/test_compat.py。 - 运行了什么? 启动
pytest的那个Bash调用,旁边则是 Security Timeline 中由它引发的那次下载的防火墙记录。
仓库能控制代理看到什么、pytest 打印什么,却没有办法篡改追踪记录。代理服务器在 Mac 上、直接根据流量本身写下这份记录。
一台留给你没读过的代码的机器
Straiker 的文章以三条建议收尾:运行仓库定义的子代理之前须先批准、运行测试文件之前先读一遍,以及把 .claude/agents/、hooks
和工作流定义这类文件视为不可信的输入。三条都很在理,而三条都依赖有人每一次都记得,而且是在一件看似例行审查的工作上。
在 Bromure 里,安全的习惯只需点几下。新机器是已签名 Ubuntu 镜像的写时复制(copy-on-write)克隆,几乎不占磁盘。由此可以得出一套评估外来代码的配置:
- 创建一台名为 eval 的机器。 只给它一个模型提供商,别的什么都不给:没有带推送权限的 git 令牌、没有云密钥、没有数据库。
- 把它的出站流量设为默认拒绝。 在 Guardrails › Outbound connections 中允许模型提供商、软件包仓库和 GitHub,并把 Unmatched traffic 设为 Deny。
- 克隆到虚拟机里,而不是共享文件夹。 在机器主目录下的一个新文件夹里开始会话。共享文件夹是一扇实时通往你 Mac 的窗,而机器自己的主目录,才是 Bromure 能回退或清除的部分。
- 或者让它用完即弃。
bromure-cli vm run --rm启动的机器在停止时会连同磁盘一起删除。 - 在引入依赖之前,先看 Timeline。 测试运行期间出现的一行 Firewall 拒绝记录,是一条没有任何审查者报告过的发现,不管 pytest 打印了什么。
如果还是有东西溜了过去,你清理的只是一台机器。Rewind home… 会把主目录恢复到之前某次启动时的检查点。被入侵检测器标记的机器会拒绝启动,直到你选择 Wipe and Launch,它会重建磁盘和主目录,同时保留你的设置以及 Mac 上的真实凭据。你笔记本上的账户、其他项目和其他机器,从来都不在里面。
等这个库证明了自己的价值,再把它挪到干正事的机器上。那台机器同样只持有替身密钥,它的推送同样会停在 Mac 上的对话框前。
下载得先问过
虚拟机打开的每一条连接,都会经过 Mac 上的虚拟交换机和代理服务器。拒绝未匹配的流量后,从新主机拉取载荷的测试会失败,而 Security Timeline 会记下这次尝试。
shell 找到的是替身
真正的 API 密钥、git 令牌和云端机密都加密留在 Mac 上,SSH 私钥从不进入虚拟机。替身一旦被发往其所属主机以外的地方,就会返回 451 并暂停机器。
推送需要在 Mac 上点一下
在新机器默认的 Prompt before write 下,git push 会等待一个显示确切操作的 macOS 对话框。植入程序无法回应它。
仓库改不了的记录
Mac 上的追踪记录显示是哪个模型审查了代码、读了哪些文件、接着运行了哪条命令,不管终端打印了什么。
少交一点给仓库
这个仓库没做什么稀奇的事。它写了一份贡献指南、定义了一个辅助代理,还附带了一个测试文件。项目会继续做这三件事,因为它们有用;代理也会继续遵循它们,因为遵循项目的惯例,正是让代理把活干好的原因。
Anthropic 的答复把决定交给了你,而这也正是它该在的地方。在一台为此打造的机器上做这个决定:在那台机器上,信任一个仓库到可以运行它的测试,意味着的只是交给它一台虚拟机、几个替身和一份短短的主机名单。然后,就让测试套件想打印什么就打印什么吧。