模型输出就是用户输入——流向 SQL、HTML、shell 时
一句话总结
模型的输出流向 SQL、浏览器、shell、数据库写入时,老的注入攻击就会经由模型卷土重来。要把模型输出与用户输入一样当作不可信的值,并在接收方施加与其上下文相符的防御(选择意图、参数、转义、不经 shell 的执行、schema 校验)。
为什么需要它
正如前面三个模块所见,模型的输出是攻击者可以书写的——文档中的指令、用户的问题、工具结果,哪里都行。可是许多应用把模型的输出当作“我们系统生成的值”,原样交给下一步:把自然语言转成 SQL 执行的报表助手、把模型回答嵌入 HTML 显示的页面、用模型决定的文件名拼出 shell 命令的导出功能、用字符串拼接模型整理的笔记的 INSERT。于是 SQL 注入、XSS、命令注入这些老问题,在“是模型写的所以没关系”的错觉下重新敞开。OWASP LLM Top 10 2025 把它列为不当的输出处理(LLM05 Improper Output Handling)。
它如何工作
| 流向 | 不要做的 | 要做的 |
|---|---|---|
| SQL 读取 | 执行模型写的 SQL | 让模型“按名称”从服务器持有的查询中挑一个,值用参数,连接用只读 |
| HTML | 原样嵌入回答 | 按嵌入位置的上下文(正文、属性)转义——回显问题的位置也一样 |
| shell | 拼接字符串用 shell=True 执行 | 使用不经过 shell 的函数(文件复制)或参数列表,名称用允许规则(字母、数字、-、_ 加扩展名) |
| SQL 写入 | 把值拼接在引号中 | 使用占位符,在此之前先做 schema 校验(类型、长度、引用的值是否存在) |
只读连接值得单独强调。检查 SQL 字符串来过滤 DROP、UPDATE 的方式,必须无休止地追赶各种变形,而以 mode=ro 打开的连接会由引擎拒绝写入。它不是字符串检查,而是由引擎守护的边界,从这一点来说,与参数化属于同一类防御。
schema 校验就是不相信“模型遵守了格式”。应该是数字的位置可能来了字符串,应该是 300 个字符的位置可能来了 1000 个字符,应该是已有客户的位置可能来了不存在的名称。不符合就不执行,以 4xx 拒绝——不要以 500 崩溃,也不要修正后接受。
再补充一点:转义因“放在哪里”而不同。HTML 正文、属性值、JavaScript 字符串、URL 中危险的字符各不相同,所以 OWASP 的 XSS 防护指南要求按放入位置的上下文选择编码。这个实验只处理正文这一种上下文,但如果设计中要把模型回答放进链接地址或脚本内部,用同一个函数是不够的。这种位置最好一开始就不放入模型输出。
shell 也是同样的道理。加引号的函数(shlex.quote)能防住命令注入,却防不住用 .. 往上跳目录的名称。所以名称要用允许规则收窄,不需要 shell 的事(文件复制)就不经过 shell 来做。“拒绝不符合规则的”之所以比“修正危险字符后接受”更牢靠,是因为修正的一方必须知道攻击者所选输入的所有情况,而拒绝的一方只需知道允许的形态。
这门课程的假模型只要有指令,就总会原样输出攻击者的文字。在真实模型中,这样的输出偶尔、不可预测地出现。输出处理的防御是在接收方施加的,无论这个“偶尔”何时到来都给出相同结果,所以可以与模型有多乖无关地进行测试。
现场遇到的情形
典型的事故有:自然语言查询工具用模型生成的查询,原样返回了权限之外的表,也就是其他客户的数据;聊天页面原样渲染了模型回答中的标签,用户的浏览器向外部地址发出了请求;“把报表以这个名称保存”的功能执行了名称中的 shell 语法。这个实验最后的故障事件把整门课程串了起来。三个优惠券代码在外部被使用了,可能的途径有:模型写的 SQL、系统文本被提取、导出时的 shell 命令、发给合作方的记录包——其中三条是真正的路径。
加入了接收方的防御,记录也要改。原样记下模型给出的 SQL、HTML、命令的记录,本身就成了攻击字符串的仓库,如果其他工具再读取并执行这些记录,同样的问题会再发生一次。修好之后,只留下判断所需的东西,比如模型选择的意图名称、拒绝的原因、请求 id。
接下来的实验要做什么
在报表助手的查询记录中统计模型给出的危险 SQL,把查询改为选择意图、参数、只读,把页面改为转义,把导出改为不经 shell 的复制和名称规则,把笔记保存改为 schema 校验和占位符。最后分辨并堵住优惠券泄漏事件的三条路径,只更换泄漏出去的优惠券。