Why ToLO Matters
这一页回答两个问题:ToLO 是什么 和 为什么单独给它命名。
如果你已经读完 背景与问题定位,概念位置已经定好,这里把它展开到可以用一段代码验证的程度。
一句话定义(精确版)
ToLO 指开发者把 LLM 推理 API 的返回字段错误地纳入可信数据域。当 AIMessage.content、tool_call.arguments、retrieved_doc.metadata、OutputParser 解析结果、structured output 字段等在框架代码中作为程序内部数据继续传播,并最终进入反序列化、eval、SQL 拼接、shell 调用、模板渲染、文件路径或 URL 等危险 sink 时,就出现 ToLO。
这个定义有三个关键词:
- 错误纳入(misplaced):开发者意图把它当内部数据,但攻击者实际可影响它。
- 可信数据域(trusted domain):区别于 HTTP 参数、上传文件、外部 API 响应这些”默认不可信”的输入。
- 既有 sink(known dangerous operations):不发明新 sink,复用已有 CWE 体系。
ToLO 的 source 到底长什么样
LLM 输出在不同框架里被包装成不同对象。下面是一张速查表,所有这些都属于 S_LLM:
| 框架 / SDK | 字段路径 | 子集 |
|---|---|---|
| OpenAI SDK | response.choices[0].message.content | S_LLM^direct |
| OpenAI SDK | response.choices[0].message.tool_calls[i].function.arguments (JSON 字符串) | S_LLM^framework |
| Anthropic SDK | response.content[i].text | S_LLM^direct |
| Anthropic SDK | response.content[i].input (tool_use block) | S_LLM^framework |
| LangChain | AIMessage.content | S_LLM^direct |
| LangChain | AIMessage.tool_calls[i]["args"] | S_LLM^framework |
| LangChain | AgentAction.tool_input | S_LLM^framework |
| LangChain | PydanticOutputParser 输出字段 | S_LLM^parsed |
| LlamaIndex | Response.response (str) | S_LLM^direct |
| LlamaIndex | NodeWithScore.node.text (从向量库检索的片段) | S_LLM^rag |
| OpenAI structured output | response_format=YourModel 实例字段 | S_LLM^structured |
| Anthropic Tool Use | tool_use.input | S_LLM^framework |
| MCP | tools/call 返回的 content[].text | S_LLM^framework (从 MCP server 回流) |
| 任意 framework | 任何 Document.page_content / Document.metadata | S_LLM^rag |
它们看起来都像”框架内部对象的字段”。这正是 ToLO 的隐蔽性所在 —— 开发者读代码时眼神扫过这些字段,容易当成”程序内部值”处理。
为什么”看起来像内部数据”是个陷阱
举个对比。看下面两段几乎一样的代码:
# 代码 A:HTTP 后端@app.route("/run")def run(): expr = request.args["expr"] # ← HTTP 参数,任何开发者都会警惕 return str(eval(expr)) # 这里所有人都看得出是 RCE# 代码 B:LLM 后端def run(question: str): resp = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": question}], ) expr = resp.choices[0].message.content # ← 看起来像"程序内部产出" return str(eval(expr)) # 实际上和代码 A 一样危险两段代码的危险性完全一样:eval 都接收攻击者可影响的字符串。但开发者评审时,对代码 A 会立刻警铃大作,对代码 B 往往会”我们让模型生成的,模型不会乱写”地放过。
ToLO 研究的是怎么把代码 B 的 source 标记和代码 A 一样醒目。
它是什么,不是什么
ToLO 不是某一具体漏洞,而是一种信任模型失效(trust model failure)—— 开发者把攻击者可影响的数据错误归入可信域。这种 misplacement 与不同下游 sink 结合后,分化为反序列化注入、命令注入、SQL 注入、SSRF、路径穿越、模板注入等具体 CWE 实例。
因此一个 ToLO 案例不需要发明新的危险 API。危险 API 早就被安全社区认识:exec 危险、shell=True 危险、拼接 SQL 危险、不安全反序列化危险。ToLO 的新意在于,触达这些 API 的值不再是传统意义上的 HTTP 参数或上传文件,而是被框架层层包装后的 LLM 输出。
ToLO 不与 OWASP LLM05 抢同一抽象层级。OWASP LLM05 是行业告知性的 broad category,回答”是否需要关心 LLM 输出处理”;ToLO 是程序信任层面的 trust model 解释,回答”这一现象的根因是什么、如何机械化检测、生态中分布如何、修复模式是什么”。一个 ToLO 实例必然落在 LLM05 的语义外延内,反之不成立。
ToLO 也不是 prompt injection。Prompt injection 研究”如何让 LLM 输出违背预期内容”;ToLO 研究”框架代码如何处理 LLM 输出”。两者是数据流上的前置与后置:即使 prompt injection 被完美防御,攻击者仍可通过 RAG 投毒、tool 响应控制、模型供应链污染等通道触发 ToLO。
一个简单判断是:如果修复方案只改 prompt、系统消息或模型对齐,却没有改变输出进入 sink 前的验证、参数化、隔离或权限控制,那么它通常没有真正修复 ToLO,只是降低了某些通道的触发概率。
为什么初学者容易漏掉 ToLO
第一,LLM 输出看起来不像用户输入。开发者看到 HTTP 参数时会警惕,但看到 AIMessage、ChatResponse、ToolCall 这种框架对象时,容易把它当成”内部值”。
第二,结构化输出会制造安全错觉。{"action": "run_sql", "query": "..."} 看起来比自然语言更规整,但 "query" 仍然可以是任意字符串。结构化的本质只是改变了 表示形式,没有改变 内容来源。
第三,很多 agent 设计本来就鼓励模型决定下一步动作。为了灵活性,开发者会让模型选择工具、填写参数、生成查询或代码。ToLO 研究的正是这种”模型决定动作”与”程序安全边界”之间的冲突。
第四,审计日志通常看不见 LLM 输出。HTTP 参数有 nginx access log、WAF rule;LLM 输出在多数应用里既不签名也不审计,出事后回放都困难。
为什么是新的研究对象
经典污点分析的 source 是 HTTP 请求、文件读取、命令行参数等显式 untrusted input,SAST 工具已识别多年。ToLO 的 source 是 LLM API 返回字段 —— 这些字段在所有现有 SAST 工具的默认规范中都不被标记为 source,因为它们看起来是”程序内部产生的数据”。
ToLO 的技术创新点不是新的污点传播算法,而是 source class 的扩展:把 LLM 推理 API 的返回字段形式化为一个新的 untrusted source family。算法机制刻意保持标准,贡献集中在 source 规范本身。
这个研究对象有三个工程特征。
- 跨框架重复出现。message、generation、agent action、tool input、structured output 在不同框架里名字不同,但角色相似。这意味着同一套 source 规范可以覆盖大量目标。
- 跨 sink 分化。同一个 source 可以进入 code、SQL、shell、URL、path 等不同 sink。这意味着如果只盯 sink 端做规则,会重复劳动且漏掉新 sink。
- 可机械化检测。一旦 source / sink / sanitizer 定义稳定,就可以用标准数据流分析查找路径。CodeQL
TaintTracking::Configuration、Semgreppattern-sources/pattern-sinks都能直接复用。
简单来说:ToLO 是一个 source-anchored 研究问题,sink 是 open taxonomy,算法是 commodity。
与传统污点对照
| 维度 | 传统污点 (Web/Native) | ToLO |
|---|---|---|
| Source 例子 | request.args["x"]、sys.argv[1]、req.body | message.content、tool_call.arguments、Document.page_content |
| 开发者直觉 | ”这是外部输入,要检查" | "这是模型/框架产出,应该可信” |
| SAST 默认覆盖 | 几乎所有商用 SAST 都标 | 几乎都不标(2024-2026 起才陆续支持) |
| 审计可见性 | nginx log / WAF rule | 通常不记录推理 token-level 内容 |
| 签名 / 可验证性 | 客户端无、TLS 仅传输层 | 模型输出无来源签名 |
| Sink | 与 Web 相同 | 复用相同 sink(关键观察) |
| 同字段语义跨度 | 通常单一(整数、字符串) | 同一字段可携带 SQL、shell、URL、code 等多种语义 |
最后一行是关键观察:ToLO 与传统污点共享 sink,差异完全压在 source 端。这就是为什么把 ToLO 称为 source-anchored 问题。
ToLO 的最小判定式
把全章浓缩成一句规则:
LLM-influenceable source ──► dangerous sink 中间无 类型匹配 的 sanitizer = ToLO其中:
- LLM-influenceable 包括通道 C1-C5 任一(详见 Threat Model)。
- dangerous sink 指会影响进程、文件、网络、数据库、凭据或其他用户数据的操作。
- 类型匹配 意味着 guard 必须真的约束该 sink 的语义。
json.loads不是 SQL sanitizer;html.escape不是 shell sanitizer;裸Pydantic str不是路径 sanitizer。
不安全 vs 更安全:一个对照
不安全路径:
# 模型自由决定参数,应用直接执行output = llm.invoke(question)action = json.loads(output.content)# action == {"tool": "read_file", "path": "../../.env"}
handler = TOOLS[action["tool"]] # 任意 tool 名都接受result = handler(action["path"]) # 任意 path 都接受更安全的路径:
# 收窄动作空间 + 强类型枚举 + capability gateALLOWED_TOOLS = {"read_doc"}ALLOWED_DOCS = {"doc-123": "/srv/notes/doc-123.md", "doc-456": "/srv/notes/doc-456.md"}
class ToolCall(BaseModel): tool: Literal["read_doc"] # C_SAFE^allowlist (枚举) doc_id: Literal["doc-123", "doc-456"] # C_SAFE^allowlist
action = ToolCall.model_validate(json.loads(output.content))real_path = ALLOWED_DOCS[action.doc_id] # C_SAFE^capability:固定映射
# 进一步检查:当前用户是否有权读该文档require_capability(session, "read", action.doc_id)return Path(real_path).read_text()差别不在模型是否聪明,而在应用有没有把模型输出限制在可控的动作空间里。
- 不安全版让模型决定”tool 是哪个、参数是什么字符串”。
- 安全版只让模型决定”在 N 个枚举值里选一个”。攻击者就算完全控制 LLM 输出,影响面也限制在这 N 个值之内。
这种”把控制权从字符串收回到枚举”的模式,在所有 ToLO 子类里都适用。后续 Defensive Patterns 会展开讲。
真实证据基础
已有多个公开 CVE / GHSA 共享同一结构性特征。引用前需重新核验 NVD 与厂商 advisory,本站当前标 pending:
- LangChain
LLMMathChain经 LLM 输出代码到exec(ToLO-Exec,CVE-2023-29374) - LangChain
PALChain数学求解的 Python REPL 路径(ToLO-Exec,CVE-2023-36258) - LangChain
GraphCypherQAChain把 LLM 输出 Cypher 执行(ToLO-SQL/Graph,CVE-2024-8309) - LangChain
_evaluate_expression接numexpr(ToLO-Exec,CVE-2023-39631) - Vanna AI text-to-SQL 把 LLM 生成 SQL 直接执行(
ToLO-SQL,CVE-2024-5826)
这些案例目前在 NVD 与 GitHub Advisory 上被分散标注为 CWE-502 / CWE-78 / CWE-89 / CWE-94 等不同条目,但根因都可以用同一个 trust model 失效解释。“现象级聚类但根因被分散归类”说明这里存在一个值得单独命名和形式化的程序信任问题。
详细复盘在 Public Case Studies。
如果不命名 ToLO,工程上会怎样
不命名 ToLO,实践上会发生以下事:
- 修复方向被错位。开发者看到 NVD 上写 CWE-94,直觉是”过滤 eval 参数”,但真正的修复在收窄 LLM 输出能影响的动作空间。
- 检测规则碎片化。命令注入规则、SQL 注入规则、路径穿越规则各自有一套 source/sink,LLM 输出在每套规则里都要单独加,容易漏。
- 报告不可比较。“某项目有 SQL injection” 和 “某项目有 RCE” 听起来不同严重程度,实际可能根因都是同一个 LLM 输出未隔离 —— 但被分散归类后看不出统一模式。
- 教学路径断裂。讲 prompt injection 不能讲清楚怎么修,讲 SQL injection 又解释不了为什么 LLM 应用突然又冒出来 —— 缺一个把它们连起来的中间概念。
ToLO 不是命名癖好,而是让以上四件事各归各位。
读完检查
读完本页,你应该能解释:
- 为什么 ToLO 不是 prompt injection。
- 为什么
AIMessage.content应该被当作 untrusted input。 - 为什么 ToLO 的研究重点是 source class,而不是重新发明 sink。
- 为什么修复 ToLO 不能只靠 prompt 或模型对齐。
- 至少 5 个常见框架字段会落入
S_LLM的某个子集。
下一步阅读
- LLM Application Stack:模型输出会经过哪些组件。
- Core ToLO Patterns:ToLO 按 sink 分化的七子类。
- ToLO 与已有概念边界:澄清与 OWASP LLM05、Prompt Injection、经典 CWE 的关系。