模型输出流向 SQL、HTML、shell 时——以及串起整门课程的故障事件
目标
用接收方的防御修好把模型输出原样交给 SQL、HTML、shell、数据库写入的报表助手,并分辨混有整门课程各条路径的优惠券泄漏事件。
为什么重要
模型的输出是攻击者可以书写的值(前面模块中的注入)。把这个值原样交给下一个系统,SQL 注入、XSS、命令注入就会经由模型卷土重来。防御不施加在模型上,而施加在接收方——让模型按名称选择服务器持有的查询、占位符、只读连接、与上下文相符的转义、不经 shell 的执行、schema 校验。这个实验的假模型只要有指令就总会输出攻击者的文字,而真实模型偶尔会给出这样的输出。接收方的防御无论这个偶尔何时到来,都给出相同的结果。评分器每次评分都会用新的数据库、优惠券、标记启动应用,查看数据库是否被改动、优惠券是否泄露、标签是否被渲染、标记文件是否被创建,也会检查正常问题的结果是否正确。
步骤
- 用
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 必须与展开时得到的种子相同。 - 查看查询记录
/root/llmsec/report/data/history.log中sql=之后的部分(模型给出的 SQL),把不是单条 SELECT 语句的(多条语句或非 SELECT)的数量和触及 coupons 表的数量,以non_select=<count>、coupon_reads=<count>两行写入/root/llmsec/report/flow.txt。 - 修改
plan_query和/root/llmsec/report/templates.txt,不要把模型的输出当作 SQL 执行——让模型按名称从服务器持有的查询中挑一个,值用参数,连接以只读方式打开。“按地区的销售合计”“按月的销售合计”“前五名客户”这三个问题的结果必须与现在相同。 - 修改
render_page,在把/page的问题(q)和模型回答嵌入 HTML 之前进行转义。标签要以文字显示(不要删除),正常的帮助回答必须照常显示。 - 修改
export_file,用规则(字母、数字、-、_共 1–40 个字符 +.csv)校验模型选择的文件名,并不经过 shell 复制到data/out/。符合规则的名称必须照常导出。 - 修改
save_note,先用 schema(字符串、1–300 个字符、customers中存在的客户)校验模型整理的笔记,再用占位符保存。不符合就不保存,以 4xx 应答。含有单引号的正常笔记也必须按原样保存。 - 运行
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 /root/llmsec/<app>/app.py启动。放到后台,关掉终端也要保持运行(setsid nohup … > <file> 2>&1 &)。每次修改后,都要停掉正在运行的并重新启动——第 1 步的评分器会检查正在运行的应用是否由现在的 app.py 启动(这是模拟修改前的代码仍在接收请求这一运维事故的检查)。结束时也必须保持运行,第 1 步才会重新评分。 - 请求:
curl -s -H "X-Token: <token>" -H 'Content-Type: application/json' -d '{"q":"…"}' 127.0.0.1:<port>/<path>。令牌在data/users.json中。 - 评分器不使用你启动的应用,而是把 app.py 文件复制到旁边的目录,用其中新的
data/(评分器生成的随机数据)和--port,以与你相同的方式启动,并发送随机攻击。不要删掉以应用旁边的data/为基准读取数据、以--port读取端口的那些行。要修改的函数写在文件开头的说明中。察觉到正在评分而表现不同的代码判为失败。 - 假模型是
/opt/fixtures/llmsec/fakellm.py。先读一读该文件开头的说明和directives(),看看它把什么读作“指令”。 - 端点:
POST /query {"q"}、GET /page?q=…(X-Token 头部)、POST /export {"q"}、POST /note {"customer", "q": "note: …"}。 - 这个镜像中没有 sqlite3 CLI。用
python3 -c 'import sqlite3; …'查看数据库。 - 事件工具:用
incident list查看可触发的事件及一行症状,用incident show <name>重新查看工单。再次触发同一事件,会以新的种子回到最初的故障状态(对象的原因也会重新确定)。 - 报告是
/root/incident/<name>/report.txt。每个对象写一行<name>: <cause-code>,最后原样附上evidence: <log line>(显示原因的一行记录,整行日志或配置,20 个字符以上)。评分器会向修好的代码重新发送新的随机攻击(不仅是这个事件的路径,也会把同一实验前面步骤的攻击作为回归测试),检查每种原因是否做了不同的善后,然后再看报告。证据行必须是事件刚发生后记录中的行,并且要与该对象的事故请求或泄漏出去的值相关联。 - 常见错误:在 SQL 字符串中查找 DROP、UPDATE 之类的词来过滤。这得无休止地追赶各种变形——要让模型不写 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 = ? 逐个进行。