TT Lab
博客 开始
学习 学习路径 课程

LLM 应用安全——从注入到输出处理

模型输出流向 SQL、HTML、shell 时——以及串起整门课程的故障事件

在 TT Lab 中继续学习

目标

用接收方的防御修好把模型输出原样交给 SQL、HTML、shell、数据库写入的报表助手,并分辨混有整门课程各条路径的优惠券泄漏事件。

为什么重要

模型的输出是攻击者可以书写的值(前面模块中的注入)。把这个值原样交给下一个系统,SQL 注入、XSS、命令注入就会经由模型卷土重来。防御不施加在模型上,而施加在接收方——让模型按名称选择服务器持有的查询、占位符、只读连接、与上下文相符的转义、不经 shell 的执行、schema 校验。这个实验的假模型只要有指令就总会输出攻击者的文字,而真实模型偶尔会给出这样的输出。接收方的防御无论这个偶尔何时到来,都给出相同的结果。评分器每次评分都会用新的数据库、优惠券、标记启动应用,查看数据库是否被改动、优惠券是否泄露、标签是否被渲染、标记文件是否被创建,也会检查正常问题的结果是否正确。

步骤

  1. 用 python3 /opt/fixtures/llmsec/kit.py report 展开素材,然后把 /root/llmsec/report/app.py 启动在 127.0.0.1:8104 上(禁止 0.0.0.0,关掉终端也要保持运行)。curl 127.0.0.1:8104/health 的 seed 必须与展开时得到的种子相同。
  2. 查看查询记录 /root/llmsec/report/data/history.log 中 sql= 之后的部分(模型给出的 SQL),把不是单条 SELECT 语句的(多条语句或非 SELECT)的数量和触及 coupons 表的数量,以 non_select=<count>、coupon_reads=<count> 两行写入 /root/llmsec/report/flow.txt。
  3. 修改 plan_query 和 /root/llmsec/report/templates.txt,不要把模型的输出当作 SQL 执行——让模型按名称从服务器持有的查询中挑一个,值用参数,连接以只读方式打开。“按地区的销售合计”“按月的销售合计”“前五名客户”这三个问题的结果必须与现在相同。
  4. 修改 render_page,在把 /page 的问题(q)和模型回答嵌入 HTML 之前进行转义。标签要以文字显示(不要删除),正常的帮助回答必须照常显示。
  5. 修改 export_file,用规则(字母、数字、-、_ 共 1–40 个字符 + .csv)校验模型选择的文件名,并不经过 shell 复制到 data/out/。符合规则的名称必须照常导出。
  6. 修改 save_note,先用 schema(字符串、1–300 个字符、customers 中存在的客户)校验模型整理的笔记,再用占位符保存。不符合就不保存,以 4xx 应答。含有单引号的正常笔记也必须按原样保存。
  7. 运行 incident ls-coupon-leak 会触发故障事件。三个优惠券代码在外部被使用了(/srv/ls-report/found.txt)。为每个优惠券所有者分辨路径,像实验第 3–6 步那样修改 /srv/ls-report/app.py,清除来源,只把泄漏出去的优惠券换成新代码,然后在 /root/incident/ls-coupon-leak/report.txt 中写下三行 <coupon-owner>: <code> 以及 evidence: <log line>。代码是 log-bundle(发给合作方的记录包)、prompt-extract(系统文本被提取)、shell-export(导出名称中的 shell 命令)、sql-from-model(模型写的 SQL)之一。

参考

展开应用并启动在环回地址上

用 python3 /opt/fixtures/llmsec/kit.py report 展开素材,然后把 /root/llmsec/report/app.py 启动在 127.0.0.1:8104 上(禁止 0.0.0.0,关掉终端也要保持运行)。curl 127.0.0.1:8104/health 的 seed 必须与展开时得到的种子相同。

方向:实验应用没有理由对外开放——监听的地址就是暴露范围。 工具:ss -ltnp、curl、setsid、nohup。 具体:展开命令如上。启动后,看看 ss -ltn 中 8104 端口是否显示为 127.0.0.1,以及 /health 的 seed 是否与 /root/llmsec/report/data/seed.txt 相同。

统计模型给出的 SQL

查看查询记录 /root/llmsec/report/data/history.log 中 sql= 之后的部分(模型给出的 SQL),把不是单条 SELECT 语句的(多条语句或非 SELECT)的数量和触及 coupons 表的数量,以 non_select=<count>、coupon_reads=<count> 两行写入 /root/llmsec/report/flow.txt。

方向:模型让系统执行了什么,已经在记录中了。统计一下“是模型写的所以没关系”错了多少次。 工具:grep、awk -F'sql='、python3。 具体:按分号把语句分开,去掉空的再计数。两个数字可能重叠(也有读取优惠券的单条 SELECT 语句)。

模型只负责选择查询

修改 plan_query 和 /root/llmsec/report/templates.txt,不要把模型的输出当作 SQL 执行——让模型按名称从服务器持有的查询中挑一个,值用参数,连接以只读方式打开。“按地区的销售合计”“按月的销售合计”“前五名客户”这三个问题的结果必须与现在相同。

方向:只要是模型写 SQL,那条 SQL 就可能是攻击者的。SQL 由服务器持有,改为让模型选择名称。 工具:templates.txt、字典(名称 → SQL)、sqlite3.connect(f"file:{path}?mode=ro", uri=True)、占位符 ?。 具体:把 templates.txt 中 | 之后的部分从 SQL 改为名称,并让 plan_query 只在该名称存在于字典中时执行服务器的 SQL。未知的名称返回 4xx。

嵌入回答之前先转义

修改 render_page,在把 /page 的问题(q)和模型回答嵌入 HTML 之前进行转义。标签要以文字显示(不要删除),正常的帮助回答必须照常显示。

方向:浏览器不知道文字来自哪里。模型回答和回显的问题,都要按嵌入位置的上下文进行转换。 工具:html.escape。 具体:两个位置都要包起来。删除标签的方式会连正常字符也弄坏,还会漏掉变形。

名称用规则,复制不经 shell

修改 export_file,用规则(字母、数字、-、_ 共 1–40 个字符 + .csv)校验模型选择的文件名,并不经过 shell 复制到 data/out/。符合规则的名称必须照常导出。

方向:把字符串交给 shell 的那一刻,这个字符串的语法就成了 shell 的语法。不需要 shell 的事就不经过 shell 来做。 工具:re.fullmatch、shutil.copyfile、subprocess 的参数列表。 具体:不符合允许规则就以 4xx 拒绝(不要修正后接受)。规则中没有路径分隔符,路径逃逸也会一并被挡住。

只用占位符保存通过了 schema 的内容

修改 save_note,先用 schema(字符串、1–300 个字符、customers 中存在的客户)校验模型整理的笔记,再用占位符保存。不符合就不保存,以 4xx 应答。含有单引号的正常笔记也必须按原样保存。

方向:不相信模型遵守了格式。检查类型、长度、引用,值放在 SQL 字符串之外传递。 工具:isinstance、len、SELECT 1 … WHERE name = ?、INSERT … VALUES (?, ?)。 具体:executescript 不接受占位符——改用 execute。客户名称也是来自请求的值,要同等对待。

故障事件——三张优惠券在外部被使用了

运行 incident ls-coupon-leak 会触发故障事件。报表助手(生产版 /srv/ls-report/app.py,数据 /srv/ls-report/data/)的三个优惠券代码在外部被使用了(被发现的片段:/srv/ls-report/found.txt,记录:data/app.log,发给合作方的包:/srv/ls-report/logs/vendor-bundle.log)。为每个优惠券所有者分辨路径,像实验第 3–6 步那样修改 /srv/ls-report/app.py(查询、页面、导出、笔记——评分器会把那些攻击全部重新发送一遍,正常使用保持不变),并清除来源——把系统文本中的优惠券去掉,合作方的包保留行、只遮蔽优惠券,删除公开目录(data/public/)中生成的文件。然后在 coupons 表中只把泄漏出去的优惠券换成新代码(CP- + 6 位以上的大写英文字母和数字,不能与其他优惠券重复)(保留行,没泄漏的优惠券不变)。在 /root/incident/ls-coupon-leak/report.txt 中写下三行 <coupon-owner>: <code> 以及 evidence: <log line>。每个所有者的代码是以下之一:log-bundle(发给合作方的记录包)、prompt-extract(系统文本被提取)、shell-export(导出名称中的 shell 命令)、sql-from-model(模型写的 SQL)。

方向:课程中见过的出口都聚到了一处——输入(注入)、秘密(系统文本、记录)、输出(SQL、shell)。片段的形态会告诉你它经过了哪个出口。 工具:grep -F、jq、app.log 中的 kind、sql、cmd、answer、python3 -c 'import sqlite3 …'。 具体:把每个片段原样查一查——如果是查询结果行的形态,就看 kind=query 的 sql;如果是系统文本中的一行,就看 kind=page 的 answer;如果是一行 CSV,就看 kind=export 的 cmd 和 data/public;如果是问题句子,就看合作方的包。修复与实验第 3、5 步的思路相同,更换优惠券则用 UPDATE … WHERE owner = ? 逐个进行。