登录链接其实是一条命令
10 月 19 日,Pillar Security 披露了 opencode 在 Windows 和 WSL 上的一个远程代码执行漏洞。远程 MCP 服务器可以把一段 PowerShell 子表达式塞进一个普通的 https:// 登录 URL,而 opencode 的浏览器打开程序会在 OAuth 握手期间执行它。修复以重构的形式发布,没有 CVE,也没有安全公告。在 Bromure Agentic Coding 中,MCP 登录由 Mac 自己完成,来自 VM 的 URL 以文本形式到达 Mac,没有任何 shell 会去读它。
登录页面是服务器选的,而这个页面的地址里藏着一条命令。代理打开了它,浏览器还没出现,PowerShell 就已经执行了这条命令。
你给编程代理添加了一个远程 MCP 服务器。MCP 即 Model Context Protocol,是代理连接外部工具的方式:问题跟踪系统、数据库、文档索引。服务器要求你登录,于是代理执行 OAuth 握手。它向服务器询问登录页面在哪里,拿到一个地址,然后在你的浏览器里打开。你自始至终都没碰过这个地址。替你省掉这次复制粘贴,正是握手的意义所在。
10 月 19 日,Pillar Security 发布了 Eilon Cohen 撰写的 Open and Shut: Remote Code Execution in opencode via MCP OAuth。opencode 是一个开源编程代理,每周在 npm 上约有 300 万次下载。在 Windows 和 WSL 上,1.0.137 到 1.18.32 版会在这次握手期间执行 MCP 服务器选定的命令,无需任何确认,也无需点击。Pillar 建议的 CVSS 评分为 9.8。Infosecurity Magazine 在 10 月 20 日做了报道。
从发现文档到 PowerShell
地址由服务器决定。在 OAuth 握手中,服务器的发现文档会指定一个 authorization_endpoint,这是一个格式不受限制的字符串,客户端会把你送过去。Pillar 追踪了它在 opencode 1.18.32 中经过的路径:
server's discovery doc → MCP SDK → oauth-provider.ts → mcp/index.ts → browser.ts → open() → PowerShell
这条路径上没有任何地方检查 scheme,也没有把地址和你配置的服务器进行比对。open 是一个很流行的、用来启动浏览器的 npm 包,每周约有 1.84 亿次下载。在 macOS 和 Linux 上,它把 URL 作为单个参数传给 open 或 xdg-open,所以没有 shell 会读到它。在 Windows 上,以及会把工作转交给 Windows 宿主机的 WSL 中,10.1.2 版会拼出一行 PowerShell 命令。它对应用程序名做了转义,却把 URL 原样放在双引号里。PowerShell 会展开双引号字符串中的 $(...)。Pillar 的载荷如下:
https://evil.test/$([IO.File]::WriteAllText('C:\Users\Public\pwned.txt','RCE'))
PowerShell 在尝试启动任何东西之前就先对子表达式求值。等到浏览器启动失败时,文件已经写入磁盘,而 opencode 把错误丢弃了。
这个 URL 以 https:// 开头。在 mcp-remote 出现一模一样的漏洞(CVE-2025-6514,2025 年 7 月)之后,标准建议是只接受 http: 和 https: 链接。MCP 的 TypeScript SDK 会拦截 javascript:、data: 和 vbscript:。opencode 自己的补丁加上了 http/https 检查。Pillar 的载荷全都能通过。真正在 opencode 1.18.33 中堵上漏洞的,是升级到 open 11.0.4,它用单引号包住 URL,而 PowerShell 在单引号里什么都不展开。
还有两个细节让这件事很难管理:
- 服务器可能在你添加之后才变成敌人。 载荷是在连接时通过 HTTP 响应送来的。一个在你配置当天毫无问题的服务器,之后可能被攻陷,它的域名、它在注册表中的条目,或者一个仿冒名称的副本也一样。
- 没有任何扫描器会标出它。 Pillar 在 8 月 29 日通过 GitHub 的私有安全公告报告了这个漏洞。opencode 的开发者 Anomaly 表示恶意 MCP 服务器不在其威胁模型之内,于 9 月 25 日以浏览器启动代码重构的名义合并了修复,并在 9 月 28 日发布了 1.18.33。它没有申请 CVE,也没有发布公告,所以依赖机器人没有任何东西可以比对。Pillar 的建议是手动盘点 opencode 的安装情况。
WSL 与 Bromure,只差一个设计选择
WSL 让这个漏洞变成了宿主机被攻陷。它在 Windows 之上运行 Linux,当里面的程序想要一个浏览器时,请求会发到宿主机。负责打开的是 Windows 的 shell,所以藏在 URL 里的命令会在 Linux 之外、在你的 Windows 电脑上、以你的 Windows 账户运行。
Bromure Agentic Coding 的架构相同。它在你 Mac 上的 Linux VM 中运行 Claude Code、Codex、Grok、Kimi 和 omp,从 VM 中打开的登录页面会出现在 Mac 的浏览器里。两者的分歧点在于交接。
在每一台 Bromure 机器中,xdg-open 和 $BROWSER 都指向一个名为 bromure-open 的小脚本。Linux 上的 open 包会调用 xdg-open,大多数会“打开浏览器”的 CLI 也是如此。bromure-open 只做一件事:把 URL 作为单独一行文本,写入与 Mac 共享的文件夹中的一个文件。它不会执行或展开其中的任何内容。
Mac 取走这个文件,删除它,然后把那一行当作 URL 来读。只有指向公共主机的 http 和 https 地址能通过。mailto:、file:、自定义 scheme、localhost 和私有地址都会被丢弃。剩下的会交给 NSWorkspace.open,也就是把 URL 交给你默认浏览器的 macOS 调用。在 VM 边界的两侧,都没有 shell 参与。
Pillar 的 URL 以 URL 的身份到达你的浏览器,$([IO.File]::WriteAllText(…)) 只是路径中的一段纯文本。浏览器会打开 evil.test 上的一个页面,或者打不开,而不会有任何命令被执行。
MCP 登录由 Mac 完成
使用 Bromure 自带的代理时,登录 URL 根本不会进入 VM。远程 MCP 服务器只需在机器的 MCP 面板中添加一次。Bromure 会把每一个转换成 Claude Code、Codex、Grok、Kimi 和 omp 的配置格式,在启动时交给 VM。
对于 HTTP 服务器,Authorize with OAuth… 会在 Mac 上执行登录。Bromure 的中介程序会获取服务器的发现文档,以 Bromure AC 的名义注册自己,用 Apple 的 URL API 构造授权 URL 并用 NSWorkspace.open 打开,随后在 Mac 的回环端口上接收回调。令牌留在 Mac 上。代理的配置里不含任何凭据,发往该服务器的请求由代理服务器加上授权头。
代理服务器还会亲自响应对该服务器的 OAuth 发现请求。代理看到的是一个它已经登录的服务器,所以它从不发起自己的握手,从不向服务器索要 authorization_endpoint,也从不把它交给打开程序。Pillar 利用的那条代码路径根本不会运行。
连接到哪里,不由 VM 决定
Pillar 警告说,服务器可能在你添加之后才变成敌人。Bromure 从 Mac 端控制 VM 能连接哪些服务器。机器发出的每一条连接都会经过一台虚拟网络交换机和一个代理服务器,两者都在 VM 之外。在机器的 Guardrails 面板中,把 Unmatched traffic 设为 Deny,再列出你使用的 MCP 服务器:
allow web mcp.linear.app
allow web mcp.notion.com
allow web registry.npmjs.org
allow web github.com
allow web api.anthropic.com
default deny
不在列表上的服务器,比如仿冒名称的域名,或某人从论坛贴来的条目,根本无法提供发现文档,所以也就没有 authorization_endpoint 可以打开。交换机按 IP 以及它在 DNS 中看到的主机名检查每一条连接,代理服务器再按 TLS 服务器名称检查一次。每一次拒绝都会成为 Security Timeline(Window → Security Timeline…)中的一行红色 deny,记下主机、端口和机器。规则在你点击 Save 的那一刻就生效,VM 里的任何东西都无法修改它们。
在 VM 中,每一把密钥都是替身。ANTHROPIC_API_KEY、gh 令牌、AWS 密钥和 MCP 静态令牌装的都是假值,代理服务器只有在请求发往该密钥所属的主机时才换上真的。如果某条命令收集这些值,并把其中一个发到不属于它的主机,入侵检测器会返回 HTTP 451,什么都不转发,并以 This environment may have been compromised 暂停这台机器。
找出没有扫描器会标出的版本
既然没有 CVE,Pillar 的建议是自己盘点 opencode 的安装情况。在 Bromure 中,这些记录早就有了。
找出版本。 无论供应链设置如何,Security Timeline 都会记录每一次包下载,并在 Condition 列写下包名和版本。在 Filter events… 中输入 opencode-ai,任何写着 npm · [email protected] 或更旧版本的行,都会指出是哪台机器。Export… 可以把这些行保存为 CSV。在已注册到 bromure.io 的 Mac 上,每一次下载也都是一条 supply_chain.fetch 事件,所以管理员可以从一个控制台,在所有开发者的机器上搜索 opencode-ai,而不必逐个询问每个团队。
找出服务器。 bromure-cli trace hostnames 会列出机器连接过的每一台主机,以及请求次数。你的代理在通信的 MCP 主机都在这份列表里,包括那些从未添加到 MCP 面板的。该审查的,或者该挡在允许列表之外的,正是这些。
在 VM 中升级。 在机器的终端里执行 npm install -g opencode-ai@latest,就会装上 1.18.33 或更新版本,而新的下载会出现在 Timeline 上作为证明。
URL 以文本形式离开 VM
xdg-open 和 $BROWSER 会把 URL 写入给 Mac 的文件。Mac 只接受指向公共主机的 http 或 https,并在不经过 shell 的情况下打开它。
MCP 登录在 Mac 上完成
中介程序执行 OAuth 并保管令牌。代理服务器响应代理的发现请求,所以代理从不打开服务器提供的登录 URL。
MCP 主机的允许列表
把 Unmatched traffic 设为 Deny 后,只有你列出的服务器才能连上。每一次拒绝都是 Security Timeline 中的一行。
没有 CVE 也能盘点
每一次包下载都连同版本记录在案,机器连接过的每一台主机,一条命令就能列出。
每个字段都不可信
Pillar 最后的教训是写给构建 MCP 客户端的人的:服务器发来的每个字段都是不可信的输入,而且其中大部分早在模型读到任何内容之前就已经被解析了。在这个漏洞中,模型自始至终没见过载荷,执行它的是一个浏览器启动程序。
你不可能审计你安装的每个代理中的每个启动程序,而这一个在没有通知任何人的情况下就修好了。Bromure 把决定移出 VM。登录、打开 URL、决定 VM 能连到哪些主机的,都是 Mac。真正的凭据也在 Mac 手里。关于 MCP 服务器本身就是攻击者的更早案例,请阅读《模型就是他们的服务器》。关于信任了错误来源的代理配置,请阅读《你信任的是工作区,而不是那些服务器》。