Skip to content

ToLO 五类防御模式

ToLO 的 sanitizer 集合 C_SAFE 在静态分析里是五个谓词类,落到工程上对应五种防御模式。本页给出每类的典型实现形态、容易踩的误用,以及它们之间应当如何组合。

核心原则:sanitizer 必须与 sink 类型匹配 —— 对 SQL sink 用 html.escape 无效,对路径 sink 用 SQL 参数化也无效。

防御 ToLO 的目标不是让模型”永远输出正确内容”,而是让错误或恶意输出无法越权改变程序行为。因此有效防御通常在模型之后、sink 之前,而不是只写在 prompt 或系统消息里。

这一页给你什么

你将能做到用到的内容
区分 5 类 C_SAFE 各自适合什么 sink§1-§5 各类详解
看到一段”防御代码”,判断它真的是 sanitizer 还是错配每节的 §“常见错配”
把 5 类组合成 defense-in-depth§“组合策略”
给一个 ToLO 路径排修复优先级§“修复优先级”

如果你已经熟传统 Web 安全的参数化、白名单、escape 等概念,可以从这里直接跳到 Static Analysis 学怎样把这五类写成谓词。

先修概念:sanitizer 不是”随便校验一下”

Sanitizer 的意思是:某个检查、转换或隔离措施真的切断了 source 到 sink 的危险语义

简单看几个例子:

行为算 sanitizer 吗为什么
if len(output) > 1000: raise长度限制对绝大多数 ToLO 子类无效
re.match(r"^[a-zA-Z0-9_]+$", name)🟡 仅对部分 sink对 SQL 表名 / shell exec 名有效,对路径仍可能误判
urlparse(url).hostname in ALLOWED✅ 对 SSRF 有效类型匹配,且配合 IP 检查后强
subprocess.run(["git", arg], shell=False)✅ 对 shell 有效参数化让 arg 不进入 shell 解析
cursor.execute(sql_template, params)(sql_template 固定)✅ 对 SQL 有效把结构和数据分开
cursor.execute(llm_sql) 但 connection 配 read_only=True🟡C_SAFE^capability 的一种,但 SELECT 仍可被影响,且 read-only 配置在不同驱动可靠性不同

对 SQL sink,参数化是 sanitizer;对路径 sink,目录规范化加根目录检查是 sanitizer;对代码执行 sink,len(output) < 1000 不是 sanitizer

所以本页一直强调类型匹配。防御必须匹配 sink。

§1 C_SAFE^schema — 结构与类型约束

它是什么

把 LLM 输出强制收敛到有限取值空间,让”非法值”在解析阶段就抛错。

工程形态

Pydantic v2(最常见):

from pydantic import BaseModel, ConfigDict, Field
from typing import Literal, Annotated
from pydantic.types import StringConstraints
class ToolAction(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
tool: Literal["search", "summarize", "save_note"] # ← 枚举,sanitizer
note_id: Annotated[str, StringConstraints(pattern=r"^[a-z0-9-]{1,32}$")]
priority: Literal["low", "normal", "high"]

OpenAI structured output:

response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[...],
response_format=ToolAction, # ← 自动转 JSON Schema 给模型
)
action: ToolAction = response.choices[0].message.parsed

Anthropic tool use schema:

tools = [{
"name": "save_note",
"input_schema": {
"type": "object",
"additionalProperties": False, # ← 关键
"required": ["note_id", "priority"],
"properties": {
"note_id": {"type": "string", "pattern": "^[a-z0-9-]{1,32}$"},
"priority": {"type": "string", "enum": ["low", "normal", "high"]},
},
},
}]

它在什么 sink 上有效

  • 任意 sink 有效的”形状越界”防御:阻止字段缺失、类型错乱、模型胡言乱语。
  • ToLO-{Exec, Shell, SQL, Path, SSRF, Template}部分子集有效,前提是字段用了 Literal / Enum / 正则 pattern真正受限的类型。

常见误用

  • str 字段:path: str 当成”已校验”。Pydantic 只验证类型(是字符串)与结构(字段存在),不验证字符串内容。一个合法 str 字段仍可装下任意 shell 命令、SQL 片段或路径穿越序列。
  • extra="allow" 或 schema 里没写 additionalProperties: false:模型可以塞额外字段,如果下游用 **kwargs 展开就被注入。
  • 正则太宽:pattern=r".*" 等于没有约束。

判定规则

只有 Literal / Enum / 受约束 Annotated[str, pattern=...] 才算 schema sanitizer。详见 Sources and Sinks 中”为什么 Pydantic 不构成 sanitizer”。

Schema 最适合约束”动作类别”而不是”任意内容”。例如让 LLM 在 {"action": "read" | "summarize"} 中选择,比让它自由生成 "command": "..." 安全得多。

记住一句话:schema 是第一道门,不是最后一道门。它让输出形状稳定,方便后续做 allowlist、参数化和 capability 检查。

§2 C_SAFE^allowlist — 显式取值白名单

它是什么

枚举出全部合法值或合法前缀,未命中即拒绝。适合 sink 输入空间天然有限的情况。

工程形态

工具名 allowlist:

ALLOWED_TOOLS = {"search_docs", "summarize", "save_note"}
if tool_name not in ALLOWED_TOOLS:
raise PermissionError(f"tool {tool_name} not allowed")

路径前缀 allowlist(对 ToLO-Path):

from pathlib import Path
ROOT = Path("/srv/notes").resolve()
target = (ROOT / user_path).resolve() # ← 先 resolve
if not target.is_relative_to(ROOT): # ← 再判断
raise PermissionError("path escapes root")

URL scheme / host allowlist(对 ToLO-SSRF):

from urllib.parse import urlparse
u = urlparse(url)
if u.scheme not in {"http", "https"}:
raise ValueError("scheme not allowed")
if u.hostname not in {"docs.example.com", "api.example.com"}:
raise ValueError(f"host {u.hostname} not in allowlist")

SQL 表名 / 列名 allowlist(对 ToLO-SQL):

ALLOWED_TABLES = {"users": "public.users", "orders": "public.orders"}
ALLOWED_COLUMNS = {
"users": {"id", "email", "created_at"},
"orders": {"id", "user_id", "total"},
}
real_table = ALLOWED_TABLES[llm_table] # dict 映射,attacker 给的逻辑名 → 硬编码真实名
if llm_column not in ALLOWED_COLUMNS[llm_table]:
raise ValueError("column not allowed")

它在什么 sink 上有效

  • ToLO-Shell 的命令 allowlist:executable 必须 ∈ {"ping", "traceroute", ...}
  • ToLO-SSRF 的 host/scheme allowlist。
  • ToLO-Path 的路径根目录约束。
  • ToLO-SQL 的表/列 allowlist(尤其在 text-to-SQL 场景)。
  • ToLO-Exec 的可调用函数 allowlist(允许的内置 / 模块函数子集)。

常见误用

  • startswith 而不先 resolve() 路径:/srv/notes/../../etc/passwd.startswith("/srv/notes") 仍然 True。
  • 只校验 scheme 不校验 host:留下 SSRF 到内网或 cloud metadata 的口子。
  • 用黑名单替代白名单:"; DROP" not in sql 这类规则永远漏(unicode 等价、双关键字组合、注释字符)。
  • 没考虑大小写 / 编码绕过:host 字符串可以是 EXAMPLE.com / example.com.(末尾点)/ Punycode / 等价 IP 形式。
  • DNS rebinding:一次 socket.gethostbyname 检查通过,真正 requests.get 时 DNS 返回了另一个 IP。

判定规则

白名单适合”合法值有限”的场景。工具名、动作名、数据库表名、文件 ID、URL host 都可以白名单。自由文本、整条 SQL、完整 shell 命令通常不适合只靠白名单 —— 这些场景需要先 schema 收窄结构,再 allowlist 关键字段。

§3 C_SAFE^parameterized — 参数化下游调用

它是什么

不让 LLM 输出参与下游语句的”语法层”,只参与”数据层”。开发者写死结构,LLM 只能提供数据。

工程形态

SQL prepared statement:

# ✓ 安全:结构由开发者写死,uid 只是数据
cursor.execute("SELECT * FROM users WHERE id = %s", (uid,))
# ✗ 不安全:整句拼接
cursor.execute(f"SELECT * FROM users WHERE id = {uid}")

Subprocess list 形式:

# ✓ 安全:[executable, arg] 不进入 shell 解析
subprocess.run(["git", "log", "--oneline", branch], shell=False)
# ✗ 不安全:整条字符串
subprocess.run(f"git log --oneline {branch}", shell=True)

HTTP request params 分离:

# ✓ 安全:URL 和 query 分开
requests.get(BASE_URL, params={"q": q, "page": p})
# ✗ 不安全:拼进 path / query
requests.get(f"{BASE_URL}?q={q}&page={p}")

模板:模板字符串固定:

from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment(autoescape=True)
# ✓ 安全:模板由开发者写死,只填变量
tpl = env.from_string("Hello {{ name }}!")
return tpl.render(name=llm_output)
# ✗ 不安全:整段模板来自 LLM
env.from_string(llm_output).render(...)

它在什么 sink 上有效

  • ToLO-SQL 的 prepared statement(对值字段)。
  • ToLO-Shell 的 list 调用形式。
  • ToLO-SSRF 的 params 分离。
  • ToLO-Template 的”模板固定 + 变量分离”模式。

常见误用

  • text-to-SQL 让 LLM 生成整条 SQL,然后假装 “参数化”:cursor.execute(llm_sql) 不是参数化,因为结构本身就受 LLM 控制。这种场景必须改用 schema 限制(白名单表/列 + LLM 只填 WHERE 值)或 RBAC。
  • shell=False 但用单字符串:subprocess.run("git log", shell=False) 在大多数 OS 上不工作;但 subprocess.run("git log " + branch, shell=False) 在某些情况(Linux)被当做单一文件名,看似安全实际有 surprise。always 用 list。
  • URL 把 LLM 输出拼进 path:requests.get(f"https://api.x.com/users/{user_id}") 即使 user_id 看起来无害,attacker 可能给 123/admin/secrets 实现 path traversal。

判定规则

参数化的核心是把”结构”和”数据”分开。开发者写死结构,LLM 只能提供数据。如果结构也由 LLM 决定,就要回到 schema、allowlist 和 capability。

§4 C_SAFE^safe-codec — 安全编解码

它是什么

语义封闭的解码器替换会执行任意代码的解码器。

工程形态

不安全安全替代
pickle.loads(data)json.loads(data)
dill.loads(data)json.loads(data) 或自定义无代码反序列化
yaml.load(data) (默认是 unsafe)yaml.safe_load(data)yaml.load(data, Loader=yaml.SafeLoader)
eval(expr) 处理字面量ast.literal_eval(expr)
eval(math_expr) 处理数学numexpr.evaluate(expr, global_dict={}, local_dict={...})
torch.load(path) (default)torch.load(path, weights_only=True) 或 safetensors
marshal.loads(data)json.loads(data)

它在什么 sink 上有效

主要对 ToLO-Deser 直接有效。ToLO-Exec 的数学子集场景下,用 numexpr / ast.literal_eval 把”任意 Python”收窄到”字面量 / 数学表达式”,也算 safe-codec。

常见误用

  • 把”无害”格式的解析器当成 sanitizer:json.loads 解出来的字符串字段对下游 SQL/Shell 仍然是 tainted。
  • safe-codec 跨 sink 复用:yaml.safe_load 解决了反序列化代码执行,但解出来的 dict 仍可有恶意路径、SQL、URL 字段。
  • ast.literal_eval(complex_expr) 仍可能 DoS:对方造超大嵌套结构耗光内存。要加超时 / 长度限制。
  • torch.load(..., weights_only=True) 在旧 PyTorch 版本可绕:确认 PyTorch ≥ 2.0,且权重文件格式是支持的。

判定规则

JSON 解析可以避免某些反序列化风险,但 JSON 里的字段仍然是不可信数据。Safe-codec 只解决 deser 这一类 sink,不能跨 sink 复用

§5 C_SAFE^capability — 能力门控

它是什么

执行前在一个独立的、不被 LLM 影响的策略层检查”当前会话是否被授权做这件事”。

这是 ToLO 防御里主流框架几乎缺失的一类,也是对 C5 模型供应链污染唯一仍然有效的防御。

工程形态

会话级 capability 集合:

class Session:
def __init__(self, user, allowed_tools, allowed_paths, allowed_hosts):
self.user = user
self.allowed_tools = allowed_tools # set[str]
self.allowed_paths = allowed_paths # list[Path]
self.allowed_hosts = allowed_hosts # set[str]
# 在 tool dispatch 之前检查
def dispatch(session: Session, tool_call):
name = tool_call.function.name
if name not in session.allowed_tools: # ← capability gate
raise PermissionError(f"tool {name} not in session capability")
return TOOLS[name](session, json.loads(tool_call.function.arguments))

最小权限数据库账号(SQL capability):

# 把 LLM-driven 路径连到只读账号
RO_DB = create_engine(
"postgresql://readonly_user@db/app",
isolation_level="AUTOCOMMIT",
)
# 即使 LLM 生成 DROP TABLE,数据库拒绝

容器化 sandbox(Exec / Shell capability):

import docker
client = docker.from_env()
result = client.containers.run(
"python:3.12-alpine",
["python", "-c", code_snippet],
network_disabled=True,
mem_limit="128m",
cpu_quota=50000,
read_only=True,
detach=False,
remove=True,
)

Egress proxy(SSRF capability):

# 所有 outbound 通过 proxy,proxy 做最终允许列表
session = requests.Session()
session.proxies = {"http": "http://egress-proxy:3128", "https": "http://egress-proxy:3128"}

它在什么 sink 上有效

所有七个 ToLO 子类都受益于 capability。它是 defense-in-depth 的最外层,即使前面所有 sanitizer 都失效,capability 仍兜底。

学术原型

  • IsolateGPT(USENIX Security 2024,unverified):把每个 tool 跑在独立隔离环境,工具间无共享内存。
  • CaMeL(2025,unverified):用控制流隔离 + 数据流隔离,让 LLM 输出无法影响策略决策。

详见 Papers 章对应论文消化页。

现状

C_SAFE^capability 在检测端没有稳定信号可识别 —— 目前更多是 defense 端的建议,而不是 scanner 能验证的属性。CodeQL 可以检测”是否调了 sandbox 库”,但不能验证”sandbox 是否真的封住了”。

关键洞察

能力门控的关键是策略源不能来自 LLM。比如:

  • ❌ “LLM 说自己需要访问 /etc/passwd” — 不是 capability。
  • ✅ “会话初始化时用户授予只读访问 /workspace/docs” — 才是 capability。

策略必须比模型输出更可信

把 capability 理解成”钥匙串”:模型可以请求开门,但不能自己生成钥匙;系统要检查当前会话是否真的有这把钥匙

type-matched 与组合策略

单一 sanitizer 通常不足以覆盖一个 sink。实际工程里几类要叠加:

推荐组合 1:schema → allowlist → parameterized

LLM 输出
[schema] 用 Pydantic 把输出收敛成 typed 对象,字段用 Literal/Enum
[allowlist] 对关键字段(tool name / 表名 / scheme)跑白名单
[parameterized] 下游调用走参数化接口(prepared SQL / subprocess list / params 分离)
Sink

三者顺序不能颠倒 —— 白名单依赖 schema 给出的字段稳定性,参数化依赖白名单给出的语法位置安全。

推荐组合 2:safe-codec 独立 + 上层组合

Deser sink 的修复不能用 schema 替代:先换解码器,再在解码出的对象上跑 schema / allowlist。

untrusted blob
[safe-codec] yaml.safe_load / json.loads / ast.literal_eval / weights_only
parsed dict
[schema] Pydantic 验证字段
[allowlist] 关键字段白名单
进一步处理

推荐组合 3:capability 在最外层

即便前四类都做对,capability 仍是必须 —— schema 能保证字段是 "read_file",但只有 capability 能保证当前会话被允许read_file

[capability gate] 会话级权限检查(谁、做什么、对什么对象)
[schema] [allowlist] [parameterized] [safe-codec]
Sink

判定时回到 ToLO 谓词

路径上是否存在与 sink 类型匹配的 sanitizer。错配的 sanitizer 在 C_SAFE 谓词上不计入

修复优先级

修复 ToLO 问题时,按风险从高到低处理:

优先级动作何时适用
🚨 P0移除不必要的执行型 sinkexecevalshell=True、不安全反序列化 — 看能不能改设计完全去掉
🔥 P1给 sink 加 capability gate 和最小权限执行环境无法移除时(text-to-SQL 等)
🛠 P2schema + allowlist 收窄 LLM 可表达的动作空间把整条 sink 输入限制到”枚举 + 受约束字段”
🪛 P3参数化调用、safe codec、审计日志、错误处理完成 defense-in-depth

这个顺序避免把低层过滤当作主要防线。尤其是 ToLO-ExecToLO-Shell,如果没有隔离和能力限制,字符串层面的过滤很难可靠。

一条修复示例:从不安全到 defense-in-depth

不安全版本

@tool
def fetch_url(url: str) -> str:
"""抓取 URL 内容并返回"""
return requests.get(url).text # ← ToLO-SSRF,零防御

加 schema:形状收窄

from pydantic import BaseModel
from typing import Literal
class FetchAction(BaseModel):
action: Literal["fetch_docs"] # 只能一种动作
url: str # 仍是自由 str,需要继续 sanitize

加 allowlist:host 白名单

from urllib.parse import urlparse
ALLOWED_HOSTS = {"docs.example.com"}
def check_url(url: str) -> str:
u = urlparse(url)
if u.scheme not in {"https"}:
raise ValueError("only https allowed")
if u.hostname not in ALLOWED_HOSTS:
raise ValueError("host not allowed")
return url

加 capability:网络出口隔离

# 应用层用 proxy,proxy 层做最终允许列表
session = requests.Session()
session.proxies = {"https": "http://egress-proxy:3128"}

加 IP 重检查:防 DNS rebinding

import socket, ipaddress
def resolve_and_check(host: str) -> str:
ip = ipaddress.ip_address(socket.gethostbyname(host))
if ip.is_private or ip.is_loopback or ip.is_link_local:
raise ValueError("resolved to internal IP")
return host

最终组合

@tool
def fetch_url(req: FetchAction) -> str:
url = check_url(req.url)
resolve_and_check(urlparse(url).hostname)
return session.get(url, timeout=5).text # 用 proxy session

这里不是某一个措施单独解决问题,而是多层防御让 LLM 输出无法越权:即使 schema 被绕、即使 allowlist 写错,proxy 层仍然兜底。

读完检查

判断下面措施是否能单独作为 sanitizer:

  1. 对 shell 命令做 html.escape
    • 不能,sink 类型不匹配。HTML escape 只防 XSS,不防 shell metachar。
  2. Pydantic 字段声明为 str
    • 通常不能,只验证类型;str 可装下任何 payload。要 Literal / Enum / Annotated[str, pattern=...] 才算。
  3. 工具名必须在 {"search", "summarize"} 中。
    • 可以作为 allowlist。但参数仍可能受 LLM 影响,需要继续 sanitize。
  4. SQL 使用 cursor.execute(query, params),且 query 由开发者固定。
    • 可以作为 parameterized sanitizer。这是经典 SQL injection 的正解。
  5. subprocess.run(["bash", "-c", llm_cmd], shell=False)
    • 不行。虽然 shell=False,但 bash -c 本身就是把字符串当 shell 解释。等价于 shell=True
  6. Path.resolve() + is_relative_to(ROOT),但 ROOT 不是 resolved。
    • 不够ROOT.resolve() 也要做,否则符号链接可能让 ROOT 自己就指向外部。

下一步阅读