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

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

RAG 文档中的隐藏指令——行为在模型之外决定

在 TT Lab 中继续学习

目标

在内部文档助手中复现检索到的外部文档中的隐藏指令发出邮件的情况,并修改为由服务器而不是模型来决定收件人、搁置与确认以及出处。

为什么重要

RAG 应用会把别人写的文字放进模型输入。模型无法确切分辨那段文字是资料还是指令,所以一句看起来像指令的句子就能驱动应用的工具。这个实验中的假模型把这一弱点放大到总能看见——真实模型是概率性的,同一个攻击只会偶尔成功,所以不能凭“试了几次都挡住了”来谈安全。实验不止步于过滤标记,而是让服务器的规则来决定行为,无论模型给出什么。评分器每次评分都会向修好的应用发送新生成的攻击,并检查正常请求是否依然可用。

步骤

  1. 用 python3 /opt/fixtures/llmsec/kit.py rag 展开素材,然后把 /root/llmsec/rag/app.py 启动在 127.0.0.1:8101 上(禁止 0.0.0.0,关掉终端也要保持运行)。curl 127.0.0.1:8101/health 的 seed 必须与展开时得到的种子相同。
  2. 把 /root/llmsec/rag/questions.txt 中的第 3 个问题原样发送到 /ask,并把放入模型输入(data/prompts.log)的文档 id 按放入顺序以一行 q3_docs=<id,id,...> 写入 /root/llmsec/rag/trace.txt。
  3. 在 data/docs/ 的文档中找出所有藏有假模型会读作指令的标记的文档,把 id 每行一个写入 /root/llmsec/rag/poisoned.txt。标记有多种形态。
  4. 发送一个能检索到藏有邮件指令的文档的问题,复现一次攻击,并在 /root/llmsec/rag/repro.txt 中写下 mail=<spool-file>(data/spool 中的文件名)和 to=<recipient>(该邮件的收件人)两行。
  5. 修改 app.py 的 run_call,只允许邮件收件人为发出请求的用户本人的地址(users.json)。其他地址不执行,并在 data/audit.log 中留下一行包含该请求 rid 和 "event": "blocked" 的 JSON。要求发送到自己地址的正常请求必须照常发出。
  6. 修改 gate、handle_confirm:只要检索到的文档中有一份不是 trust: internal(包括没有标记或其他值),就不执行模型给出的行为,而在响应的 actions 中以 {"status": "pending", "id": <confirm-id>, ...} 搁置。同一用户发送 POST /confirm {"id": ...} 时只执行一次;其他用户的确认和第二次确认以非 2xx 的代码拒绝,并且其他用户的确认尝试不能清除搁置(让本人之后仍能确认)。只使用内部文档的回答所产生的行为立即执行。
  7. 修改 sources_of,使响应的 sources 不是模型回答中的引用标记,而是服务器实际放入提示的文档:[{"id": ..., "trust": "internal|external"}]。
  8. 运行 incident ls-rag-exfil 会触发故障事件。三个部门(faq、hr、sales)的助手昨晚向公司外部地址发出了邮件。修改代码 /srv/ls-rag/bot.py(三个部门共用),使无论经由什么路径,都不会向公司外部(非 @corp.example)地址发出邮件;整理每个部门的原因来源,然后在 /root/incident/ls-rag-exfil/report.txt 中写下 faq: <code>、hr: <code>、sales: <code> 以及 evidence: <log line>。代码是 config-cc(配置中的抄送地址)、memory-note(用户笔记中的指令)、poisoned-doc(检索文档中的指令)、user-prompt(用户问题中的指令)之一。

参考

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

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

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

读取模型实际收到的输入

把 /root/llmsec/rag/questions.txt 中的第 3 个问题原样发送到 /ask,并把放入模型输入(data/prompts.log)的文档 id 按放入顺序以一行 q3_docs=<id,id,...> 写入 /root/llmsec/rag/trace.txt。

方向:在 RAG 中,模型输入的大部分不是用户写的文字,而是检索来的别人的文字。放进了什么不要猜,要从记录中读。 工具:curl、grep、awk。 具体:抓住响应中的 rid,在 prompts.log 中查看该 rid 的 part=context 行,按首次出现的顺序收集每行附带的 src=。

找出所有形态不同的隐藏指令

在 data/docs/ 的文档中找出所有藏有假模型会读作指令的标记的文档,把 id 每行一个写入 /root/llmsec/rag/poisoned.txt。标记有多种形态。

方向:模型读得比过滤器宽松。只用一种形态 grep,就会漏掉形态不同的;看起来相似的诱饵并不是指令。 工具:fakellm.py 的 directives()、unicodedata、base64、grep。 具体:先读这个函数,看假模型如何规范化输入、解开读取什么,再用同样的规则逐个判断文档。

复现一次攻击

发送一个能检索到藏有邮件指令的文档的问题,复现一次攻击,并在 /root/llmsec/rag/repro.txt 中写下 mail=<spool-file>(data/spool 中的文件名)和 to=<recipient>(该邮件的收件人)两行。

方向:攻击者不和我们的用户说话。只要用户的普通问题把那份文档拉进来就行了。 工具:检索规则(retrieve)、curl、ls data/spool。 具体:挑一份含有邮件指令的文档,用该文档正文中的词组成问题,那份文档就会排到前面。把响应的 actions 与 spool 文件的 to 对照一下。

收件人由服务器决定

修改 app.py 的 run_call,只允许邮件收件人为发出请求的用户本人的地址(users.json)。其他地址不执行,并在 data/audit.log 中留下一行包含该请求 rid 和 "event": "blocked" 的 JSON。要求发送到自己地址的正常请求必须照常发出。

方向:模型给出的参数(to)可能是攻击者写的文字。用这个值做什么,要由请求者的身份来决定。 工具:run_call 的 user 参数、audit()。 具体:收件人与 user 的 email 不同时,不发送,并记录挡住的事实。评分器会同时发送隐藏指令(形态随机)、用户直接给出外部地址的请求以及正常请求。

混入了不可信的文字时,由人来确认

修改 gate、handle_confirm:只要检索到的文档中有一份不是 trust: internal(包括没有标记或其他值),就不执行模型给出的行为,而在响应的 actions 中以 {"status": "pending", "id": <confirm-id>, ...} 搁置。同一用户发送 POST /confirm {"id": ...} 时只执行一次;其他用户的确认和第二次确认以非 2xx 的代码拒绝,并且其他用户的确认尝试不能清除搁置(让本人之后仍能确认)。只使用内部文档的回答所产生的行为立即执行。

方向:即使把收件人限定为本人,外部文档中的指令仍可能让系统给本人发送不相干的邮件(比如假的付款链接)。只在危险的时刻让人介入。 工具:PENDING 字典、secrets.token_urlsafe,确认之后也要经过 run_call。 具体:确认 id 要连同是谁创建的一起保存,用过就删掉。别人的和不存在的用同样的响应拒绝,就连 id 是否存在都不会泄露。

出处由服务器附加

修改 sources_of,使响应的 sources 不是模型回答中的引用标记,而是服务器实际放入提示的文档:[{"id": ..., "trust": "internal|external"}]。

方向:“根据 [kb-07]”这句话也是模型的输出。外部文档冒充内部规定时,人们会相信那个引用。 工具:sources_of 收到的 docs。 具体:不要解析回答文本,而要从检索结果中原样搬运 id 和 trust。没有标记的文档当作外部文档处理更安全。

故障事件——三个部门有邮件发到了公司外部

运行 incident ls-rag-exfil 会触发故障事件。三个部门(faq、hr、sales)的助手昨晚向公司外部地址发出了邮件(spool:/srv/ls-rag/bots/<dept>/spool/,记录:prompts.log、audit.log)。修改代码 /srv/ls-rag/bot.py(三个部门共用),使无论经由什么路径,都不会向公司外部(非 @corp.example)地址发出邮件(收件人和抄送都要管住,发往内部地址的正常邮件照常发出),并整理每个部门的原因来源——被污染的文档从检索对象中移除,被污染的笔记从笔记中移除,放入指令的账号让其令牌失效,配置中的外部抄送删掉(与原因无关的文档和笔记要保留)。在 /root/incident/ls-rag-exfil/report.txt 中写下 faq: <code>、hr: <code>、sales: <code> 以及 evidence: <log line>。每个部门的代码是以下之一:config-cc(配置中的抄送地址)、memory-note(用户笔记中的指令)、poisoned-doc(检索文档中的指令)、user-prompt(用户问题中的指令)。

方向:三者症状相同,但指令进入的位置不同。从一封邮件倒推上去,按片段查看该请求的输入。 工具:grep、jq、spool 中的 rid 和 cc、prompts.log 中的 part= 和 src=。 具体:用含有公司外部地址的 spool 文件的 rid 打开 prompts.log,看指令标记出现在哪个 part(memory、context、user)中;如果没有标记却发出去了,就看配置,查邮件的 cc 是从哪里附加的。每个部门也有诱饵(没被检索到的指令文档、无害的指令)——只有与实际发出的邮件相关联的才是原因。