输出边界编码
将模型输出的所有内容都视为对其消费端的恶意输入。在每个目标端(渲染器、shell、查询、下游智能体)使用该目标端自身的规则进行编码和验证。单一的全局净化器无法做到这一点:对 HTML 正确的转义对 shell 来说毫无意义。
定义
输出边界编码是指在模型输出跨入将对其进行解析的系统的每一个节点上,应用针对特定目标端的编码和验证,而不是在输出模型时进行一次通用的过滤。缺少这一实践即是 OWASP 所称的“不当输出处理”漏洞。
问题
团队往往会针对提示词注入对输入端进行加固,却让输出端处于不设防状态。随后,模型的文本到达渲染器、shell、数据库或其他智能体的上下文,并在那里被解析为指令而非作为数据展示。攻击者根本不需要直接接触模型。
适用场景
任何其输出会到达对其进行解析的系统的智能体:例如渲染 markdown 的聊天界面、运行建议命令的编码智能体、根据生成的参数构建的工具调用、输入到第二个智能体提示词中的摘要,或者向前转发文本的 webhook。
解决方案
在编写任何过滤器之前,先列举所有的目标端。模型输出落地并被解析的每个地方——HTML 渲染器、shell、查询、文件路径、URL 获取器、另一个智能体的上下文、下游 webhook——都是一个具有不同规则的独立边界。
在目标端进行编码,而不是在源端。为渲染器进行 HTML 转义,为查询进行参数化,向进程传递 argv 数组。编码应当发生在解析发生的地方,因为只有在那里你才知道什么将被解析。
优先选择结构化输出,而不是需要重新解析的自然语言。带有类型化参数的工具调用具有可用于验证的 schema;而你用正则表达式提取文件名的句子则没有。
不仅要验证语法,还要验证值。编码后的路径仍然是路径:在打开它之前,请检查它是否解析在你预期的目录内。
将出站 URL 视为一个独立的目标端。渲染的图片和链接会在无需点击的情况下触发获取请求,从而将数据带出;请将渲染链接可以访问的主机加入白名单。
使该边界成为唯一的路径。如果任何代码路径可以在不经过目标端编码器的情况下消费原始模型输出,那么这种控制只是建议性的,而非实质性的。
组件
优势
- 在关键环节切断注入链:即使模型被完全说服,也无法让下游系统执行操作,因为该系统绝不会解析其文本。
- 独立于模型行为。它在模型升级、新的越狱和提示词更改时依然有效,因为它不依赖于模型拒绝任何内容。
- 可测试。每个目标端都有一个有效载荷以及通过或失败的结果,因此该控制措施产生的是证据而非口头保证。
- 尽早应用成本较低。添加编码器属于边界变更;而在目标端无处不在之后再进行补救则属于重构。
风险
- 对所有目标端使用同一个净化器。这看起来像是一种控制措施,满足了检查清单的要求,但除了它专门针对的那个边界之外,在其他所有边界上都是错误的。
- 编码破坏了产品:过度转义会将合法的 markdown、代码块和非拉丁文本变成噪音,而放宽限制的压力最终会落在编码器上,而不是目标端清单上。
- 目标端清单老化。新的集成增加了消费者,而当遗漏某个消费者时,系统并不会报错。
- 混淆检测与编码。扫描输出中的可疑字符串只能捕获去年的有效载荷;而编码根本不需要识别攻击。
不适用场景
- 绝不会被解析的输出——例如调用者进行比较的分数、枚举或布尔值。此时应限制其类型;在封闭的值集上使用编码器只是流于形式。
- 完全本地的单用户工具,没有渲染,也没有进程执行,唯一的消费者是阅读文本的人。
- 目标端在构建时已经进行了参数化,例如 ORM 绑定或默认进行转义的模板引擎。第二个编码器不会带来任何好处,反而可能导致双重编码。
技术
示例
- 支持智能体对工单进行摘要,该工单正文中包含一个指向攻击者主机的 markdown 图片。控制台渲染了该图片,浏览器获取了该 URL,于是在无人点击任何内容的情况下,对话内容泄露了。禁用原始 HTML 并将图片主机加入白名单可以解决此问题。
- 编码智能体建议了一条 shell 命令。运行器将该字符串传递给 shell,导致包含命令分隔符的文件名被执行。改为传递 argv 数组则可以完全省去 shell 的解析步骤。
- 一个智能体的摘要被放入第二个智能体的提示词中。该摘要包含指令,而第二个智能体执行了这些指令。在这种情况下,隔离不可信的跨度并将其标记为数据就是其边界。
KPI
- 目标端覆盖率
- 在边界处设有编码器的已知模型输出消费者的比例。低于 100% 意味着控制措施存在特定的漏洞,指明具体的目标端比用一个平均百分比将其掩盖更有用。
- 有效载荷中和率
- 在边界处被中和的每个目标端测试有效载荷的比例。目标是 100%:任何其他结果都意味着需要指出待修复的目标端,而不是需要提高的数字。
- 覆盖新目标端所需的时间
- 从新集成上线到其编码器就绪所需的时间。这衡量的是清单是否能跟上产品的步伐,而不是它是否曾经正确过。
- 边界触发量
- 编码器在生产环境中中和内容的频率。完全为零通常意味着编码器未处于路径上,而不是没有恶意内容到达。
已观测到的失效模式
- 通过渲染的标记进行静默外传:图片或链接在无需点击的情况下触发获取请求,从而在没有用户操作且无人会注意到的错误的情况下将数据带出。
- 二阶注入:针对控制台正确编码的输出被存储,随后在其他地方(日志查看器、工单、摘要邮件)进行渲染,而那里的编码并不适用。
- 仅存在于正常路径(happy path)上的编码器。错误分支、重试和回退通过一条没有任何防护的、不同的代码路径发送相同的文本。
- 双重编码。两层都进行了正确的转义,用户在产品中看到了转义后的实体,而修复措施却移除了错误的一层。
经验教训
- 在编写过滤器之前,先列举所有的目标端。这里几乎所有实际的失败都是因为存在无人列出的消费者,而不是因为编码器写错了。
- 你遗忘的目标端很少是屏幕。它通常是 webhook、日志查看器、导出或摘要邮件——即输出所流向的、但没人会将其视为渲染的某个地方。
- 编码优于检测,因为编码不需要识别攻击。有效载荷黑名单只是对已经公开的攻击的描述。
- 不要让单一的 `sanitise()` 成为解决方案。这个名字暗示了完整性,但其行为仅对恰好一个目标端是正确的。
常见问题
- 这与针对提示词注入过滤输入不是一回事吗?
- 不——它们防御的是相反的两端。输入过滤试图阻止模型被说服,这取决于模型。而本方法假设说服已经成功,并阻止输出被执行,这完全不依赖于模型。仅具有前者的系统在出现新的越狱手段时就会失效。
- 这不就是输出净化吗?为什么不使用一个通用的净化器呢?
- 因为编码是与上下文相关的。转义引号可以保护 HTML 渲染器,但对 shell 毫无作用;shell 引号转义可以保护 shell,但会破坏显示的文本。共享函数必须选择一种上下文,在其他上下文中则是错误的,这看起来实现了覆盖,实则是一个单点故障。
- 模型是我们的,提示词也是固定的。我们还需要这个吗?
- 是的,只要模型读取的任何内容来自外部:获取的页面、用户文件、工具结果、内存记录。指令不一定非要通过你的提示词传入——它可以通过模型读取的任何内容传入,而你的提示词是固定的并不能约束这一点。