Skip to content

Why ToLO Matters

这一页回答两个问题:ToLO 是什么为什么单独给它命名

如果你已经读完 背景与问题定位,概念位置已经定好,这里把它展开到可以用一段代码验证的程度。

一句话定义(精确版)

ToLO 指开发者把 LLM 推理 API 的返回字段错误地纳入可信数据域。当 AIMessage.contenttool_call.argumentsretrieved_doc.metadataOutputParser 解析结果、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 SDKresponse.choices[0].message.contentS_LLM^direct
OpenAI SDKresponse.choices[0].message.tool_calls[i].function.arguments (JSON 字符串)S_LLM^framework
Anthropic SDKresponse.content[i].textS_LLM^direct
Anthropic SDKresponse.content[i].input (tool_use block)S_LLM^framework
LangChainAIMessage.contentS_LLM^direct
LangChainAIMessage.tool_calls[i]["args"]S_LLM^framework
LangChainAgentAction.tool_inputS_LLM^framework
LangChainPydanticOutputParser 输出字段S_LLM^parsed
LlamaIndexResponse.response (str)S_LLM^direct
LlamaIndexNodeWithScore.node.text (从向量库检索的片段)S_LLM^rag
OpenAI structured outputresponse_format=YourModel 实例字段S_LLM^structured
Anthropic Tool Usetool_use.inputS_LLM^framework
MCPtools/call 返回的 content[].textS_LLM^framework (从 MCP server 回流)
任意 framework任何 Document.page_content / Document.metadataS_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 参数时会警惕,但看到 AIMessageChatResponseToolCall 这种框架对象时,容易把它当成”内部值”。

第二,结构化输出会制造安全错觉{"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 规范本身。

这个研究对象有三个工程特征。

  1. 跨框架重复出现。message、generation、agent action、tool input、structured output 在不同框架里名字不同,但角色相似。这意味着同一套 source 规范可以覆盖大量目标。
  2. 跨 sink 分化。同一个 source 可以进入 code、SQL、shell、URL、path 等不同 sink。这意味着如果只盯 sink 端做规则,会重复劳动且漏掉新 sink。
  3. 可机械化检测。一旦 source / sink / sanitizer 定义稳定,就可以用标准数据流分析查找路径。CodeQL TaintTracking::Configuration、Semgrep pattern-sources / pattern-sinks 都能直接复用。

简单来说:ToLO 是一个 source-anchored 研究问题,sink 是 open taxonomy,算法是 commodity

与传统污点对照

维度传统污点 (Web/Native)ToLO
Source 例子request.args["x"]sys.argv[1]req.bodymessage.contenttool_call.argumentsDocument.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 gate
ALLOWED_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_expressionnumexpr(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,实践上会发生以下事:

  1. 修复方向被错位。开发者看到 NVD 上写 CWE-94,直觉是”过滤 eval 参数”,但真正的修复在收窄 LLM 输出能影响的动作空间。
  2. 检测规则碎片化。命令注入规则、SQL 注入规则、路径穿越规则各自有一套 source/sink,LLM 输出在每套规则里都要单独加,容易漏。
  3. 报告不可比较。“某项目有 SQL injection” 和 “某项目有 RCE” 听起来不同严重程度,实际可能根因都是同一个 LLM 输出未隔离 —— 但被分散归类后看不出统一模式。
  4. 教学路径断裂。讲 prompt injection 不能讲清楚怎么修,讲 SQL injection 又解释不了为什么 LLM 应用突然又冒出来 —— 缺一个把它们连起来的中间概念。

ToLO 不是命名癖好,而是让以上四件事各归各位。

读完检查

读完本页,你应该能解释:

  • 为什么 ToLO 不是 prompt injection。
  • 为什么 AIMessage.content 应该被当作 untrusted input。
  • 为什么 ToLO 的研究重点是 source class,而不是重新发明 sink。
  • 为什么修复 ToLO 不能只靠 prompt 或模型对齐。
  • 至少 5 个常见框架字段会落入 S_LLM 的某个子集。

下一步阅读