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, Fieldfrom typing import Literal, Annotatedfrom 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.parsedAnthropic 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 PathROOT = Path("/srv/notes").resolve()
target = (ROOT / user_path).resolve() # ← 先 resolveif 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 / queryrequests.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)
# ✗ 不安全:整段模板来自 LLMenv.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 dockerclient = 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 | 移除不必要的执行型 sink | exec、eval、shell=True、不安全反序列化 — 看能不能改设计完全去掉 |
| 🔥 P1 | 给 sink 加 capability gate 和最小权限执行环境 | 无法移除时(text-to-SQL 等) |
| 🛠 P2 | 用 schema + allowlist 收窄 LLM 可表达的动作空间 | 把整条 sink 输入限制到”枚举 + 受约束字段” |
| 🪛 P3 | 补 参数化调用、safe codec、审计日志、错误处理 | 完成 defense-in-depth |
这个顺序避免把低层过滤当作主要防线。尤其是 ToLO-Exec 与 ToLO-Shell,如果没有隔离和能力限制,字符串层面的过滤很难可靠。
一条修复示例:从不安全到 defense-in-depth
不安全版本
@tooldef fetch_url(url: str) -> str: """抓取 URL 内容并返回""" return requests.get(url).text # ← ToLO-SSRF,零防御加 schema:形状收窄
from pydantic import BaseModelfrom typing import Literal
class FetchAction(BaseModel): action: Literal["fetch_docs"] # 只能一种动作 url: str # 仍是自由 str,需要继续 sanitize加 allowlist:host 白名单
from urllib.parse import urlparseALLOWED_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最终组合
@tooldef 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:
- 对 shell 命令做
html.escape。- 不能,sink 类型不匹配。HTML escape 只防 XSS,不防 shell metachar。
- Pydantic 字段声明为
str。- 通常不能,只验证类型;
str可装下任何 payload。要Literal/Enum/Annotated[str, pattern=...]才算。
- 通常不能,只验证类型;
- 工具名必须在
{"search", "summarize"}中。- 可以作为 allowlist。但参数仍可能受 LLM 影响,需要继续 sanitize。
- SQL 使用
cursor.execute(query, params),且 query 由开发者固定。- 可以作为 parameterized sanitizer。这是经典 SQL injection 的正解。
subprocess.run(["bash", "-c", llm_cmd], shell=False)。- 不行。虽然
shell=False,但bash -c本身就是把字符串当 shell 解释。等价于shell=True。
- 不行。虽然
Path.resolve() + is_relative_to(ROOT),但 ROOT 不是 resolved。- 不够。
ROOT.resolve()也要做,否则符号链接可能让 ROOT 自己就指向外部。
- 不够。
下一步阅读
- Core ToLO Patterns:七个 sink 子类,决定每条路径需要哪类 sanitizer。
- Sources and Sinks:sanitizer 集合在静态分析谓词里的精确定义与 ToLO 触发条件。
- CodeQL and Semgrep:把这五类写成
isBarrier/pattern-sanitizers。