Skip to content

Core ToLO Patterns

ToLO 不是单一漏洞,而是同一信任模型失效与不同下游 sink 结合后分化出的具体形态。本页按 sink 类型列出七子类,每个对应一个已有 CWE,但 source 端语义是新的。

七子类的命名是教学和检测上的便利。它们不表示七个互相独立的漏洞,而是帮助读者把同一条 source family 映射到不同后果:代码执行、命令执行、数据库查询、文件访问、网络访问、模板执行和反序列化。

怎么读这一页

每个子类按统一模板:

  1. 是什么:对应哪类 sink,典型函数。
  2. 最小教学代码:不可直接复现的最简化骨架,用来理解形态。
  3. 典型 source-transform-sink-guard 四列拆解
  4. 真实公开案例(标注 verification 状态)。
  5. 为什么开发者会写出这种代码:工程动机。
  6. 类型匹配的 sanitizer:用哪类 C_SAFE
  7. 常见错配:看起来像 sanitizer 但其实不算。

所有代码片段都是教学骨架,不含 exploit payload 和 PoC 复现指令。

先修概念:什么是 sink

Sink 是”数据到这里会产生安全后果”的位置。

例如:把字符串展示在聊天框里通常不是 sink;把字符串交给 eval、数据库、shell 或文件系统就是 sink。

ToLO 的七子类都按 sink 命名,因为后果由 sink 决定。同样是 LLM 输出:

  • 进入 eval → 后果是代码执行
  • 进入 open → 后果是文件访问
  • 进入 requests.get → 后果是网络访问

如果你对 sink 的概念不熟,回去读 先修知识 §5

§1 ToLO-Exec — LLM 输出当代码执行

是什么

LLM 输出作为代码字符串进入 eval / exec / compile / PythonREPL / IPython kernel / Jupyter 等代码执行 sink。对应 CWE-94(Code Injection)。

常见于:PAL(Program-Aided Language model)、code-interpreter、symbolic-math、Plotly 自动可视化、数据分析助手。

最小教学代码

from openai import OpenAI
client = OpenAI()
def answer(question: str) -> str:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system",
"content": "请给出一段计算用的 Python 表达式作为答复。"},
{"role": "user", "content": question},
],
)
expr = resp.choices[0].message.content # ← S_LLM^direct
return str(eval(expr)) # ← ToLO-Exec sink

四列拆解

SourceTransformSinkGuard
S_LLM^direct (message.content)(无 — 直接传给 eval)eval(...) (CWE-94)缺失

真实公开案例

CVE项目 / 组件verification
CVE-2023-29374LangChain LLMMathChainPythonREPLpending
CVE-2023-36258LangChain PALChain (PAL) → 代码执行pending
CVE-2023-39631LangChain _evaluate_expressionnumexpr 历史变体pending

为什么开发者会写出这种代码

开发者通常本来就希望模型”写一段代码帮我算”,所以会把执行设计成正常功能。这不是 misuse,是 architectural choice。修复必须重新设计沙箱与 capability 边界,不能只靠 input validation。

类型匹配的 sanitizer

  • 首选 C_SAFE^safe-codec:把”任意 Python”收窄到”数学表达式子集”,用 numexpr.evaluate(expr, global_dict={}, local_dict={})ast.literal_eval
  • 必备 C_SAFE^capability:执行隔离(RestrictedPython 风险高,生产用 Docker / gVisor / Firecracker)。
  • 辅助 C_SAFE^schema:用 Pydantic Literal 把”代码语言”限制(如 lang: Literal["math"])。

常见错配

  • ❌ 黑名单关键字(if "import" in expr: raise)— 一定可绕。
  • ❌ 长度限制(if len(expr) > 100: raise)— 100 字符够写 __import__('os').system(...)
  • Pydantic(expr: str) — 字符串可装任意 payload。
  • ❌ “我们用了 RestrictedPython 旧版” — Python sandbox 史上多次被绕。要么用真容器隔离,要么用真正受限的求值器(numexpr / ast.literal_eval)。

§2 ToLO-Shell — LLM 输出当 shell 命令

是什么

LLM 输出进入 subprocess.run(..., shell=True) / os.system / os.popen / commands.getoutput / pexpect.run(shell)。对应 CWE-78(OS Command Injection)。

常见于:agent 应用的 shell tool、SRE diagnostic agent、CI/CD 助手、自动化运维。

最小教学代码

from langchain_core.tools import tool
import subprocess
@tool
def run_command(cmd: str) -> str:
"""执行一条 shell 命令"""
return subprocess.run(cmd, shell=True, capture_output=True).stdout.decode()
# ^^^^^^^^^^ ← ToLO-Shell sink,且 shell=True 让攻击面最大

四列拆解

SourceTransformSinkGuard
S_LLM^framework (tool_call.arguments)LangChain 自动反序列化 argssubprocess.run(cmd, shell=True) (CWE-78)缺失

真实公开案例

参见 Public Case Studies 公开 CVE 列表;LangChain _run 的 ShellTool 类家族历史上多次被报告。具体 CVE 待核验。

为什么开发者会写出这种代码

Agent framework 常常有意让 LLM 指定完整命令。这种”灵活性”和 shlex.quote 这种保守做法直接冲突。开发者认为”反正是 LLM 选的命令,模型不会乱来” —— 这是 ToLO 的核心错误信任。

类型匹配的 sanitizer

  • 首选 C_SAFE^parameterized:永远不要 shell=True,用 list 形式 subprocess.run([executable, arg1, arg2])
  • 必备 C_SAFE^allowlist:executable 必须是枚举白名单内的几个固定 binary;参数也限制取值。
  • 辅助 C_SAFE^schema:用 Pydantic Literal["ping", "traceroute"] 让模型只能”选命令”,不能”写命令”。
  • defense in depth C_SAFE^capability:用 seccomp / capabilities / 容器限制可执行的 syscall。

一段安全版重写

from typing import Literal
from pydantic import BaseModel
import subprocess, ipaddress
class DiagnosticAction(BaseModel):
command: Literal["ping", "traceroute"] # ← schema 收窄
target_ip: str # ← 仍需校验
ALLOWED_NETWORKS = [ipaddress.ip_network("10.0.0.0/8")]
def run_diagnostic(action: DiagnosticAction) -> str:
ip = ipaddress.ip_address(action.target_ip)
if not any(ip in net for net in ALLOWED_NETWORKS):
raise PermissionError("target out of scope")
return subprocess.run(
[action.command, "-c", "3", str(ip)], # ← list 形式,无 shell
capture_output=True,
timeout=10,
).stdout.decode()

常见错配

  • shlex.quote(cmd) 给整条 LLM 输出转义,然后 shell=True — quote 只防引号,不防整条命令被替换。
  • ❌ 黑名单字符(if ";" in cmd or "|" in cmd: raise)— 可绕(换行符、${IFS}、 backtick 等)。
  • ❌ “命令必须以 ping 开头” — ping x; rm -rf / 仍以 ping 开头。

§3 ToLO-SQL — LLM 输出当 SQL 执行

是什么

LLM 生成的 SQL 直接送入 cursor.execute / connection.execute / pandas.read_sql / SQLAlchemy text() / Cypher / GraphQL。对应 CWE-89(SQL Injection)。

常见于:text-to-SQL、自然语言 BI 工具、自动 ETL、数据分析 agent。

最小教学代码

def text_to_sql(question: str) -> list:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "把用户问题转成一句 SQL。"},
{"role": "user", "content": question},
],
)
sql = resp.choices[0].message.content # ← S_LLM^direct
return list(db.execute(sql)) # ← ToLO-SQL sink

四列拆解

SourceTransformSinkGuard
S_LLM^direct(无)db.execute(sql) (CWE-89)缺失

真实公开案例

CVE项目 / 组件verification
CVE-2024-5826Vanna AI text-to-SQL → 直接执行pending
CVE-2024-8309LangChain GraphCypherQAChain (Cypher 执行)pending

为什么开发者会写出这种代码

Text-to-SQL 的核心需求就是让 LLM 自由生成 SQL。传统参数化(execute("... WHERE id = %s", (uid,)))只在 SQL 结构固定时有效。整条 SQL 由 LLM 决定时,参数化无从下手。

类型匹配的 sanitizer

  • 首选 C_SAFE^capability:用只读数据库账号(DROP/UPDATE/DELETE 直接被数据库拒绝)。
  • 必备 C_SAFE^allowlist:把可访问的表/列在应用层 allowlist;模型给的”表名”先经过 dict 映射。
  • 架构改动:把 LLM 输出从”整条 SQL”收窄到”WHERE 条件值”,或”枚举查询模板 + 参数”。
  • defense in depth:Query rewriter(在执行前用 SQL parser 重写 LLM 输出,确认只是 SELECT,确认表/列在白名单)。

一段安全版重写

from typing import Literal
from pydantic import BaseModel
# 不让 LLM 自由写 SQL,只让它选模板 + 填参数
ALLOWED_TABLES = {"users": "public.users", "orders": "public.orders"}
ALLOWED_COLUMNS = {
"users": {"id", "email", "created_at"},
"orders": {"id", "user_id", "total"},
}
class CountQuery(BaseModel):
table: Literal["users", "orders"]
where_column: str
where_value: str
def run_count(q: CountQuery) -> int:
real_table = ALLOWED_TABLES[q.table]
if q.where_column not in ALLOWED_COLUMNS[q.table]:
raise ValueError("column not allowed")
sql = f"SELECT COUNT(*) FROM {real_table} WHERE {q.where_column} = %s"
return db.execute(sql, (q.where_value,)).scalar() # ← 参数化只对 value 部分

常见错配

  • cursor.execute(llm_sql) 即使有 read_only=True connection 配置 — 还是建议用专门只读账号。
  • ❌ 黑名单关键字 — SQL 语法有几百种写法可绕(注释、union、子查询、INFORMATION_SCHEMA)。
  • ❌ “用了 SQLAlchemy ORM” — ORM 自动参数化只对 Model.filter(col=value) 形式生效。session.execute(text(llm_sql)) 仍然完全暴露。

§4 ToLO-Path — LLM 输出当文件路径

是什么

LLM 输出作为文件路径进入 open / pathlib.Path.read_text|write_text / shutil / os.makedirs / tarfile.extract / zipfile.extract。对应 CWE-22(Path Traversal)。

常见于:文件管理 agent、笔记 / 知识库写入、上传整理工具、log 查看器。

最小教学代码

@tool
def read_note(name: str) -> str:
"""读取一份笔记"""
return open(f"/srv/notes/{name}").read() # ← ToLO-Path (name = "../../etc/passwd")

四列拆解

SourceTransformSinkGuard
S_LLM^framework (tool_call.arguments["name"])f-string 拼接open(...) (CWE-22)缺失(拼接前没有 Path.resolve() + is_relative_to)

真实公开案例

未在本站当前 CVE 列表中独立锚定;LangChain FileTool 类家族历史上有 path 类报告,具体 CVE 待核验。

为什么开发者会写出这种代码

文件管理 agent 天然需要文件路径。开发者直觉是”模型只会给合理的 name”,不会想到 ../、绝对路径、符号链接。

类型匹配的 sanitizer

  • 首选 C_SAFE^allowlist:p = (ROOT / user_path).resolve(); if not p.is_relative_to(ROOT): raise先 resolve 再判断,避免符号链接 / .. 绕过。
  • 必备 C_SAFE^schema:文件 ID 映射 — 让 LLM 只能给 doc_id(枚举),应用层映射到真实路径。
  • defense in depth C_SAFE^capability:进程级 chroot / 容器 mount 隔离 / seccomp 限制文件 syscall。

一段安全版重写

from pathlib import Path
from typing import Literal
from pydantic import BaseModel
ROOT = Path("/srv/notes").resolve()
ALLOWED_DOCS = {"daily-log": "daily-log.md", "todo": "todo.md"}
class ReadNote(BaseModel):
doc_id: Literal["daily-log", "todo"] # ← schema 限定枚举
@tool
def read_note(req: ReadNote) -> str:
name = ALLOWED_DOCS[req.doc_id]
target = (ROOT / name).resolve()
if not target.is_relative_to(ROOT):
raise PermissionError("path escapes root") # 防符号链接
return target.read_text()

常见错配

  • ❌ 正则过滤 .. (if ".." in name: raise)— Windows 上 ..\\..\\ 不被命中;符号链接也不命中。
  • os.path.join(ROOT, name) 不做 resolve 检查 — name="/etc/passwd" 时直接返回 /etc/passwd(os.path.join 遇绝对路径会丢弃前面)。
  • ❌ “我们用了 pathlib 所以安全” — Path("/srv/notes") / "../../etc/passwd" 等价于 Path("/srv/notes/../../etc/passwd"),必须 resolve。

§5 ToLO-SSRF — LLM 输出当 URL

是什么

LLM 输出作为 URL 进入 requests.get / requests.post / httpx / urllib.urlopen / aiohttp / urllib3。对应 CWE-918(SSRF)。

常见于:web-fetch 工具、URL preview、API caller、外部数据集成 agent、爬虫助手。

最小教学代码

@tool
def fetch_url(url: str) -> str:
"""抓取 URL 内容"""
return requests.get(url, timeout=5).text # ← ToLO-SSRF

四列拆解

SourceTransformSinkGuard
S_LLM^framework (tool_call.arguments["url"])(无)requests.get(url) (CWE-918)缺失

为什么 SSRF 在 LLM agent 里特别危险

SSRF 的传统危害:让服务器访问内网。在 LLM agent 里还多了一层:服务器拉回的内容会被作为 ToolMessage 写回 LLM 上下文,污染下一轮决策(C2 通道)。这是一种”自我放大”。

被 SSRF 拉到的常见目标:

  • 云元数据 IMDS:http://169.254.169.254/latest/meta-data/iam/security-credentials/(AWS)→ 偷凭据
  • 内网管理 API:http://internal-admin.svc.cluster.local:8080/admin(K8s 内服务)
  • 本机 loopback:http://127.0.0.1:6379/(Redis no-auth)、http://localhost:8500/v1/(Consul)
  • file:// scheme:在 urlopen 接受 file scheme 时,可以读本地文件

类型匹配的 sanitizer

  • 首选 C_SAFE^allowlist:host allowlist + scheme 限制(http/https)+ 端口限制。
  • 必备:内网 IP / loopback / 链路本地地址 block(包括 IPv6)。
  • 架构 layer:把 outbound HTTP 限制在专门的 egress proxy,proxy 层做最终允许列表。
  • DNS rebinding 防御:解析 IP 后再次校验(防御 attacker 改 DNS 在第二次解析时返回内网 IP)。

一段安全版重写

import requests, ipaddress, socket
from urllib.parse import urlparse
from typing import Literal
ALLOWED_HOSTS = {"docs.example.com", "api.example.com"}
def safe_get(url: str) -> str:
u = urlparse(url)
if u.scheme not in {"http", "https"}:
raise ValueError("scheme not allowed")
if u.hostname not in ALLOWED_HOSTS:
raise ValueError(f"host {u.hostname} not in allowlist")
# 防 DNS rebinding:解析 IP 后再检查
ip = ipaddress.ip_address(socket.gethostbyname(u.hostname))
if ip.is_private or ip.is_loopback or ip.is_link_local:
raise ValueError("resolved to internal IP")
return requests.get(url, timeout=5).text

常见错配

  • ❌ “只允许 http/https” 不够 — host 仍可指向内网。
  • ❌ 字符串黑名单 if "169.254" in url: raise0x0a.0x00.0x00.0x01 / 2130706433(十进制 IP)绕过。
  • ❌ 一次性 host 检查 — DNS rebinding 可在第二次解析时返回不同 IP。

§6 ToLO-Deser — LLM 输出当反序列化输入

是什么

LLM 输出或 LLM-influenceable 数据进入 pickle.loads / dill.loads / joblib.load / torch.load (default) / yaml.load / marshal.loads。对应 CWE-502(Deserialization of Untrusted Data)。

常见于:cache / 持久化中间结果、agent state 读写、自定义模型加载、langchain loads / dumps 历史路径。

最小教学代码

import pickle, base64
def restore_state(llm_blob: str):
raw = base64.b64decode(llm_blob)
return pickle.loads(raw) # ← ToLO-Deser

四列拆解

SourceTransformSinkGuard
S_LLM^direct (base64 编码后嵌在响应里)base64.b64decodepickle.loads(...) (CWE-502)缺失

真实公开案例

LangChain langchain.load.loads / serializable 体系历史上有 deserialization 安全考虑,具体 CVE 待核验。本站案例列表中暂未独立条目。

为什么开发者会写出这种代码

性能或便利:pickle 序列化任何 Python 对象,yaml 比 JSON “类型更丰富”。torch.load 默认行为是反序列化 pickle。开发者认为”这是我们自己存的数据,不会被改”,忘了 LLM 输出可能影响”我们存的数据”。

类型匹配的 sanitizer

  • 首选 C_SAFE^safe-codec:json.loads;yamlyaml.safe_load;模型权重用 torch.load(..., weights_only=True) 或 safetensors。
  • 架构改动:不要让 LLM 输出参与 pickle 流。如果必须持久化 LLM 状态,只持久化字符串字段(经 schema 验证)。
  • defense in depth:即使解码用 safe codec,解码后的字段仍需做内容校验。

常见错配

  • ❌ 加密包装(pickle.loads(decrypt(blob)))— 攻击者只要能影响 plaintext 就可控。
  • ❌ HMAC 签名 — 只防”第三方”篡改;如果 LLM 输出是签名的输入,攻击者通过 prompt injection 让模型生成”签名前内容”仍然有效。
  • ❌ “用了 restricted_globals” — Python pickle 反序列化的 gadget chain 历史上多次绕过 restrictions。

§7 ToLO-Template — LLM 输出当模板字符串

是什么

LLM 输出作为模板字符串(而非模板变量)进入 jinja2.Template(...) / Environment.from_string(...) / Django template 直接渲染。对应 CWE-1336(Improper Neutralization of Special Elements Used in a Template Engine)。

⚠ 注意:这里的 source 是模板字符串本身,不是模板变量。模板变量永远应当被当 untrusted —— 那是 sink-side 的 autoescape 问题,不是 ToLO。

最小教学代码

from jinja2 import Template
def render_dynamic(llm_output: str, data: dict) -> str:
return Template(llm_output).render(data) # ← ToLO-Template
# llm_output = "{{ ''.__class__.__mro__[1].__subclasses__()[...]... }}"
# → 模板沙盒逃逸 → RCE

四列拆解

SourceTransformSinkGuard
S_LLM^direct (作为模板字符串)Template(...).render(...) (CWE-1336)缺失

为什么开发者会写出这种代码

让 LLM “生成 HTML / Markdown / 邮件模板” 等任务里,开发者会把整段输出当模板渲染。Jinja2 默认环境不沙盒化,Python 内省可触达任意对象 → 历史上多次 SSTI(Server-Side Template Injection)RCE。

类型匹配的 sanitizer

  • 首选:模板字符串绝不来自 LLM。模板由开发者固定,LLM 只填变量。
  • 如果必须 dynamic template,用 jinja2.sandbox.SandboxedEnvironment,且配合白名单 filter / function。
  • C_SAFE^capability:配合执行隔离(容器,无网络,只读 FS)。

常见错配

  • ❌ 用 Environment(autoescape=True) — autoescape 只 HTML escape 变量输出,防 attacker 控制模板字符串本身。
  • ❌ “我们替换了 {{ 字符” — Jinja 支持 {%- if -%} 等 100 种语法形式。

子类之间会组合

真实框架里,一个功能可能同时触达多个 sink。例如:

  • text-to-SQL 应用先让 LLM 生成 SQL(ToLO-SQL),再让 LLM 生成 Plotly 代码做可视化(ToLO-Exec)。
  • 文件管理 agent 先让 LLM 选择路径(ToLO-Path),再调用 shell 命令处理文件(ToLO-Shell)。
  • Web agent 先 SSRF 抓内容(ToLO-SSRF),再把内容做模板渲染(ToLO-Template)。

因此分类时以”每条 source-to-sink 路径”为单位,而不是以”每个产品功能”为单位。同一个 CVE 或同一个 issue 可能包含多条 ToLO 路径。

如何使用七子类

分析一个新案例时,不要先问”这是哪个 CVE”,而要先写四列:

要写什么
Source哪个 S_LLM 子集产生或承载污染
Transformparser、message wrapper、agent action、tool input 如何传播
Sink进入七子类中的哪一种危险操作
Guard是否存在类型匹配的 C_SAFE

如果 source 不清楚 → 不能判 ToLO。 如果 sink 不敏感 → 可能只是输出展示问题。 如果 guard 类型匹配 → 可能是已缓解路径。 如果 guard 只是 prompt 约束或裸字符串类型检查 → 通常不能切断 ToLO

快速归类表

如果 LLM 输出进入先按哪类看优先检查什么
pickle.loads / yaml.loadToLO-Deser是否换成 safe codec
eval / exec / code runnerToLO-Exec是否有 sandbox 和 capability
os.system / shell=TrueToLO-Shell是否避免 shell 和命令白名单
cursor.execute / raw queryToLO-SQL是否参数化、只读、限制表/列
open / Path.read_textToLO-Path是否规范化并限制根目录
requests.get(url)ToLO-SSRF是否限制 host、scheme、内网
Template(...).renderToLO-Template模板是否固定、是否 sandbox

与传统同名 CWE 的关键区别

每一子类的 sink 端与传统 CWE 一致,但 source 端是新的。差异主要在三点:

  1. 开发者意图:传统 CWE 通常是 misuse(开发者忘了过滤);ToLO 是 architectural choice(开发者有意让 LLM 决定 sink 参数)。
  2. 可观测性:传统 source 有 nginx log / WAF;LLM 输出几乎无审计。
  3. 修复路径:传统 CWE 修复路径标准(参数化 / escape);ToLO 修复路径更架构化(收窄动作空间、capability gate)。

具体每子类:

  • ToLO-Deser vs CWE-502:传统 source 是 HTTP body,开发者通常会避免反序列化它们;ToLO 的 source 是”框架内部对象”,反序列化路径常根本不被识别为敏感操作。
  • ToLO-Exec vs CWE-94:ToLO-Exec 的 source 是有意设计为接受 LLM 代码的输入。修复必须重新设计沙箱与 capability 边界。
  • ToLO-SQL vs CWE-89:传统修复是 parameterized query;text-to-SQL 整条 SQL 由 LLM 生成,参数化无从下手,只能靠 schema 限制 + RBAC。
  • ToLO-Shell vs CWE-78:agent 框架常有意让 LLM 指定完整命令,shlex.quote 与设计意图冲突。

剩余三类(Path / SSRF / Template)的区别原理类似:经典防御假设 source 是用户输入,但 ToLO 的 source 是被开发者视为”系统内部决定”的 LLM 输出,监控与限制信号都更弱

分类不闭合性声明

以上七类是基于已观察到 CVE 的归纳,不是穷尽枚举。如果在后续公开案例中发现新的 sink 子类(例如 LLM 输出进入 OS 资源限制操作、序列化协议字段、RPC 调用、IPC 消息等),允许扩展。ToLO 这一 family 由 source class S_LLM 锚定,sink 子类是 open taxonomy。

扩展 taxonomy 时需要谨慎。新增子类应满足两个条件:

  1. sink 会影响明确的被保护对象。
  2. 该模式不能被现有七类自然覆盖。

否则优先作为某个现有子类的变体记录。

读完检查

判断下面说法是否正确:

  • “LLM 输出进入页面展示,一定是 ToLO。”
    • 。要看是否进入危险 sink。展示到 HTML 时如果浏览器 textContent 输出,不构成 ToLO;如果通过 innerHTML 渲染,可能构成 XSS 风险,但 source 端通常归 OWASP LLM05 而非 ToLO。
  • “LLM 输出进入 json.loads 后就安全。”
    • 。解析后的字段仍可能进入 SQL、shell、path 等 sink。
  • “LLM 输出进入 subprocess.run(['git', 'status'], shell=False) 一定安全。”
    • 未必。还要看命令和参数是否被模型自由控制、当前会话是否有执行 capability。如果 ['git', user_branch]user_branch 来自模型,仍可能 inject 命令选项。

下一步阅读