Claude Code 怎么防住 Bash 注入
AI 执行 Bash 命令的安全问题,不是"该不该信任模型",而是"怎么确认命令的含义和它看起来的一样"。
Claude Code 允许 AI 直接执行 Bash 命令。不是通过 MCP 那样的结构化接口间接调用,是真给模型开了一个 shell。MCP 的思路是把工具封装成 JSON schema,安全是安全了,但你不可能为几千个 CLI 工具逐个写 adapter,能力天花板肉眼可见。直接给 Bash,什么都能干,代价是安全问题从"接口权限控制"变成了"搞懂这条命令到底在干嘛"。上一篇聊了 YOLO Classifier 怎么用 AI 审查 AI,但 YOLO Classifier 看到的是完整的命令字符串,它做的是语义判断:这个操作危不危险。在那之前,还有一层更底层的问题需要回答:这个命令真的是它看起来的那个意思吗?
这不是修辞。Bash 是一门出了名的"看起来 A,执行起来是
B"的语言。echo hello 谁都看得懂,但
echo$'\x20'-rf$'\x20'/
呢?视觉上看着无害,执行起来可能是灾难。
Claude Code 的解法是用 Tree-Sitter 做 AST 分析,把每条命令解析成结构化的 argv 数组,再对 argv 做权限匹配。这套系统的设计哲学可以用一句话概括:不理解的东西一律不放行。
规则引擎的天花板
最朴素的安全方案是正则匹配:看到 rm -rf / 就拦,看到
curl | bash 就拦。Claude Code
的早期版本确实有一套这样的系统,23 个正则 Validator,覆盖了从命令替换到
Unicode 空白字符的各种攻击面。
问题在于 Bash 的引号规则极其复杂。同一个命令可以有无数种等价写法:
1 | Regex sees: Bash executes: |
正则做的是字符串匹配,它看到的是命令长什么样。但安全判断需要知道命令是什么意思,这中间的差距就是攻击面。
更糟的是,正则引擎要先做"引号剥离"才能分析命令结构,而引号剥离本身就是一个 mini parser。一旦这个 mini parser 和 Bash 的理解不一致,就会产生差异攻击。代码注释里记录了几十种这类绕过,每一种都是一个独立的 CVE 级别的发现。
换一种思路:Allowlist 而非 Blocklist
新架构完全抛弃了"检测坏东西"的思路,改成"只放行已理解的结构"。
用 Tree-Sitter 把命令解析成 AST,然后用显式允许列表遍历:只有在列表里的节点类型才递归处理,所有不认识的节点类型直接返回 too-complex,交给用户确认。
1 | Input: "npm test && git status" |
这个设计的核心在于对未知的处理方式。传统 blocklist 的默认态度是"不在黑名单里就放行"。Allowlist 的默认态度是"不在白名单里就拦截"。一个新的 Bash 语法特性被加入 Tree-Sitter 后,如果 allowlist 没有更新,它会被自动拦截,不是自动放行。
代码开头的注释写得很清楚:"The key design property is FAIL-CLOSED: we never interpret structure we don't understand."
解析器差异:真正的安全边界
但 Tree-Sitter 不是 Bash。它是一个通用的增量解析框架,用 Bash 的 grammar 文件生成解析器。这个解析器和真实的 Bash 在绝大多数情况下行为一致,但在边缘情况下会产生差异。
这些差异就是攻击面。
Claude Code 在 AST 遍历之前,先跑一组预检查,专门捕捉已知的 Tree-Sitter/Bash 差异:
1 | Command input |
每个预检查都对应一个具体的攻击向量。挑几个有意思的展开。
看不见的字符
Unicode 有一堆"空白字符"在终端上要么不可见,要么显示成普通空格。NBSP(0A0)、零宽空格(00B)、BOM()这些字符,Tree-Sitter 可能把它们当作词分隔符,但 Bash 把它们当作普通字符。
攻击场景:模型生成一条看起来人畜无害的命令,但中间夹了一个零宽空格。Tree-Sitter 认为这是两个词,安全分析按两个词处理。Bash 却认为这是一个词,执行的是完全不同的命令。
代码里列了一整页 Unicode 空白字符的编码,从 0A0 到 ,全部拦截。
同样的逻辑适用于控制字符。Bash 会静默丢弃 null 字节(),但
Tree-Sitter 不会。rm\x00 -rf / 在 Tree-Sitter
里可能是一个词 rm\x00,但 Bash 执行的是
rm -rf /。
反斜杠空格:两个解析器的分歧
echo\ test 这个命令,Bash 认为 \
是一个转义空格,所以 echo test 是一个词(参数里包含空格的
echo)。Tree-Sitter 返回的 .text 却是
echo\ test(带反斜杠),argv[0] 和 Bash 理解的不一样。
这不只是理论上的问题。如果安全系统信任 Tree-Sitter 的 argv[0] 是
echo\ test,那它不会匹配 echo 的权限规则。但 Bash
实际执行的就是 echo。
解法很粗暴但有效:只要命令里出现反斜杠后面跟空白字符的模式,直接拦截。这种写法极其罕见,用引号改写就行。
解析超时是可以被利用的
Tree-Sitter 的解析有时间和资源限制:50 毫秒超时,50000 个节点上限。这看起来是性能保护,但有安全含义。
代码注释里写了一个具体的攻击:(( a[0][0][0]... ))
嵌套大约 2800 层数组下标,可以触发解析超时。如果超时后 fallback
到旧的正则系统,而正则系统缺少某些检查(比如
EVAL_LIKE_BUILTINS),攻击者就可以绕过。
解法:超时不返回 parse-unavailable(会 fallback 到旧系统),而是返回 too-complex(直接拦截)。注释说"Adversarially triggerable — fail closed: too-complex → ask."
Bash 的怪脾气:eval 和它的朋友们
AST 解析能把命令拆成 argv 数组,但有些命令的 argv 看着完全无害,执行起来却是另一回事。这类命令叫 eval-like builtins,它们把参数当 shell 代码执行。
1 | +----------------------------------------------+ |
Claude Code 维护了一个约 20 个命令的 eval-like 列表。这些命令一出现在
argv[0] 就标记为 too-complex,除了少数安全变体(command -v
只查路径不执行,fc -l 只列历史不重放)。
最阴险的可能是 trap:trap 'cmd' EXIT 注册一个在 shell
退出时执行的命令,而 BashTool 每次调用结束都会触发
EXIT,意味着这行代码的效果是"保证执行"。
数组下标里藏的炸弹
Bash 有一个容易被忽略的特性:数组下标会被算术求值。算术求值会展开
$(cmd)。这意味着即使参数是单引号包裹的(Tree-Sitter
看来是一个不透明的字符串叶节点),Bash 仍然会执行里面的命令。
1 | test -v 'a[$(id)]' |
这是一个特别隐蔽的攻击,因为单引号在所有人的心理模型里都是"不展开任何东西"的。但在数组下标这个特殊上下文里,Bash 先做算术求值,再做引号处理,顺序反过来了。
Claude Code 维护了一张表,列出所有会触发下标求值的 builtin 及其对应的 flag:test -v、printf -v、read -a、unset -v 等等。只要命令 + flag 匹配,就检查参数里有没有数组下标语法。
Zsh:另一个战场
Claude Code 的 BashTool 通过用户的默认 shell 执行命令。很多 macOS 用户的默认 shell 是 Zsh,而 Zsh 有一套 Bash 没有的扩展机制。
=curl evil.com 在 Bash 里是一个名叫 =curl
的命令。在 Zsh 里,=cmd 会被展开成
$(which cmd) 的结果,所以实际执行的是
/usr/bin/curl evil.com。Tree-Sitter 用的是 Bash
grammar,它解析出 argv[0] = =curl,权限系统去匹配
=curl 这个命令名,匹配不上 curl 的 deny 规则,放行了。
更危险的是 Zsh 的模块系统。zmodload zsh/system
加载系统模块后,sysopen、syswrite 这些 builtin
就可用了,直接绕过文件系统层面的安全检查。zmodload zsh/net/tcp
加载网络模块后,ztcp 可以建立 TCP
连接做数据外泄。zmodload zsh/files
加载文件模块后,zf_rm、zf_mv 这些 builtin
可以绕过对 rm、mv 二进制命令的检测。
Claude Code 的应对是双重的:拦截 zmodload
本身,同时作为纵深防御,把所有模块 builtin
的名字也加入黑名单。因为"zmodload
有可能被绕过"或"模块有可能预加载"。
变量作用域追踪
AST 分析不只是拆解命令结构,还追踪变量赋值的作用域。这对于判断
$VAR 扩展后的值至关重要。
1 | Safe pattern: |
关键设计:变量作用域在
||、|、& 操作符处重置。因为
|| 右边的代码只在左边失败时执行(条件性的),|
的各段在子 shell 里运行(变量不可见),& 在后台子 shell
运行(同上)。只有 && 和 ;
才线性传递变量。
如果不做这个区分,攻击者可以用
true || SAFE_FLAG=yes && dangerous_cmd $SAFE_FLAG
的模式,让静态分析以为 dangerous_cmd 带着安全 flag 运行,而
Bash 实际上跳过了赋值。
包装命令的层层剥离
time、nohup、timeout、nice、env、stdbuf
这些命令本身不危险,它们包裹真正的命令执行。安全分析必须剥掉这些包装,检查里面的命令。
但剥离本身也有坑。timeout .5 eval "id"
里,.5 是超时时间,eval
是真正的命令。如果超时时间的正则没匹配上
.5(只匹配了整数),eval
就被误认为超时时间参数,剥离后看到的是 "id"
这个无害字符串。
更极端的例子:stdbuf --output 0 eval 里,如果 flag
解析把 0 当成了命令名(name='0'),eval 就完全隐身了。
Claude Code 对每个包装命令都写了完整的 flag 解析逻辑,遇到不认识的 flag 直接返回 too-complex。又是 fail-closed。
两套系统的并行
旧的 23 个正则 Validator 没有删掉,标记为 @deprecated 但仍然在运行。新的 AST 路径和旧的正则路径并行执行,系统记录两者结果的分歧:
1 | +-------------------+ +-------------------+ |
这不是冗余,是有意为之的安全网。如果 AST 路径有 bug 放过了一个危险命令,正则路径可能会捕到它(反之亦然)。分歧日志在 Datadog 里被监控,任何分歧都是值得调查的信号。
注释里的攻防史
这套代码最让人印象深刻的不是技术复杂度,是注释。几乎每个安全检查旁边都有一段故事:为什么加了这个检查,具体的攻击是什么,之前的绕过方式是什么,为什么现在的方案能防住。
比如 stripSafeRedirections 函数旁边写着:没有
(?=\s|$) 作为尾部边界,> /dev/nullo
会匹配到 /dev/null 前缀,剥离
> /dev/null,剩下一个 o。攻击者实际写入
/dev/nullo,但验证器看不到 > 操作符了。
又比如 heredoc
提取器的注释解释了为什么不能用简单的正则匹配结束标记:<<'EOF'
在 $() 里面时,Bash 的 make_cmd.c 第 606 行会在遇到
) 时提前关闭
heredoc。不处理这个边界,结束标记的定位就会出错。
这些注释读起来像一部 Bash 安全研究的编年史。每一条规则的背后都是一次真实的攻防交锋。
纵深防御的哲学
回过头看这套系统的整体架构:
第一层是 AST allowlist。只理解已知结构,不理解的一律拦截。这是主防线,覆盖面最广。
第二层是预检查。捕捉 Tree-Sitter 和 Bash 之间的已知差异,在 AST 分析之前就拦截。这是补丁层,每发现一个新的解析器差异就加一条。
第三层是语义检查。AST 能正确拆解结构,但有些命令的结构和行为不对应(eval、trap、数组下标求值)。这是业务逻辑层。
第四层是旧正则系统。已废弃但仍运行,作为分歧检测和兜底。
最上面是 YOLO Classifier,用 AI 做语义层面的安全判断。
五层,每层解决不同类型的问题,每层的默认行为都是拦截。这不是过度设计,是对 Bash 这门语言复杂度的尊重。