沙箱里那条 find 删掉了 3187 个文件:工具调用沙箱的三层防线怎么被逐个绕过
上个季度我给内部运维 Agent 挂了一个 shell 执行工具,前后花三周做"沙箱",自以为想得挺全:命令白名单、容器隔离、执行超时,一样不少。上线第 19 天,它收到一句"帮我清一下 /data/tmp 里的临时文件",然后删掉 3187 个文件,其中 412 个是当晚还在写的业务临时文件。
事后复盘,三道防线全部失效,而且每一道失效都很朴素,既没有零日漏洞,也不是模型越狱。下面是当时的现场和三处具体的漏,以及我后来重做的四道闸。
一、现场:312 毫秒,3187 个 inode
先看日志。从工具调用发出到 find 返回,312 毫秒。容器里被 unlink 的 inode 一共 3187 个,其中 412 个文件的 mtime 落在当天 18:00 之后。
模型生成的命令是这个样子:
- find /data/tmp -name "*.log" -mtime -7 -delete
- 它想表达的语义是"7 天以前的日志"
- 实际语义是"7 天以内的日志"
- 一个 + 写成了 -,删除方向完全反过来
这条命令没有触犯任何一条我们写在策略文件里的规则。它用的是白名单里的命令,没有 rm,没有 dd,没有管道拼 shell。从校验器视角看,这是一条完全合规的调用。
二、第一层失效:白名单只认命令名
我们第一版的策略是一张命令白名单:find、grep、du、df 放行,rm、dd、mkfs、shutdown 直接拒绝。
看起来抓住了要害,实际只覆盖了最笨的一类错误。find 在白名单里,参数由模型自己生成,参数错了照样执行。这等于一间只没收了电锯、却放行所有锤子的屋子。
顺着这个思路往下想会更不安,类似的"白名单内的杀伤力"不止 find 一个:
- awk 可以在 BEGIN 里调 system(),直接拼出任意 shell 语句
- sed 带 e 修饰符能把匹配内容当命令执行
- xargs 接受任何后续命令,白名单形同虚设
- sh -c 更直接,一条白名单命令就能变成任意脚本
结论很直接:一个只检查命令名的沙箱,等于没有沙箱。真正决定破坏半径的是参数和目标路径,不是命令本身。
顺带说一句实现上的教训。我们最早想用正则匹配命令字符串,试了一下午就放弃了。find /data/tmp -name '*.log' -exec rm {} \; 这种写法,引号里套命令、反斜杠转义分号,正则根本切不开 token 边界,改成整段字符串模糊匹配又会把正常命令误杀。最后落在 shlex 词法解析上,先把命令拆成 token 序列,再按 token 位置匹配参数模式,才勉强可维护。
三、第二层失效:容器隔离,但挂载把墙拆了
第二版我们上了容器,逻辑上应该"跑在盒子里"。启动参数里有这么一行:-v /data/tmp:/data/tmp,为的是让容器能读业务日志。
只读标志忘了加。
于是沙箱里的 find 操作的其实是宿主机的 /data/tmp。隔离层存在,通往外面的门开着。这一层最容易被忽略,因为 docker run 成功、容器内 ls 正常、进程树看起来都在容器里,所有表征都是"隔离生效"。
真正要命的是我们的排查顺序。出事当晚我先查了容器配置,看到 mount 是通的、路径一致,反而更放心了,因为"路径对上了"。看着对,恰恰是错。
四、第三层失效:超时杀的是父进程
第三版加执行超时,写法是 timeout 30 sh -c "$cmd"。
日志显示它确实在 30 秒时发了 SIGTERM,但收到信号的是 sh。find 作为子进程活在另一个进程组里,收到 SIGTERM 之后继续扫剩下的目录,直到自己跑完。我们的"30 秒上限"实际上限了个寂寞。
后来查进程树才看清整条链路,timeout 只盯住它直接启动的那个进程,sh -c 再 fork 出去的孙进程完全不在它的射程里。修法不复杂:setsid 起新会话,超时用 kill -- -PGID 把整个进程组打掉。
五、重做的四道闸
事故后把校验器重写了一遍,现在是四道:
- 参数级校验。白名单从"命令"升级成"命令 + 参数模式":find 必须同时给出显式 -mtime 与路径前缀,命中 -delete 时必须带 -print 先打印,awk 的 system(、sed 的 e 修饰符、xargs 一律判死。这一道就拦掉了当天那条命令。
- 强制两阶段。所有写操作先 dry-run,输出待影响对象清单,命中数量超过 200 条时必须人工确认才进第二段。这个阈值是拍的,参考点是当天误删 3187 个、日常正常清理通常几十个。
- 挂载收紧。宿主目录只做只读 bind mount,可写范围只留容器内 scratch,写完再由外层按规则回传。
- 进程组级超时 + 全量留档。命令、退出码、stdout/stderr、影响对象清单全部入库,出事当天不用再靠猜。
改完到现在六周,同类参数级高危调用在 dry-run 阶段被拦下 11 次,其中 3 次是 -mtime 正负号写反,2 次是路径变量拼成了 /。一次都没到真正执行。
小结
工具调用沙箱的难点从来不在"要不要隔离",而在隔离之后的边界是不是真的闭合。
- 白名单要落到参数,光看命令名等于没看
- 容器要检查挂载,只读标志漏一个就等于裸奔
- 超时要打进程组,父进程死了不代表活干完了
- 破坏性操作必须能先看一眼影响范围,再看不清就不许动手
一句提醒,跟我当初的想法正相反:做沙箱最该花的力气,不在拦住模型不该做的事,而在确保模型做了不该做的事时,损失被限制在一个能算清的范围内。
- 点赞
- 收藏
- 关注作者
评论(0)