Skip to content

ToLO 谓词与传播规则

ToLO 谓词用于判断一条 LLM 影响的数据流是否跨过信任边界:当 S_LLM 到危险 sink 的路径缺少类型匹配的 C_SAFE 时,才报告信任模型失效。

这一页把前一页的集合定义写成规则设计语言。实际实现可以是 CodeQL、Semgrep 或其他 SAST,但判定逻辑应保持一致:source 端是 LLM-output family,sink 端复用传统危险 API,barrier 端只接受类型匹配的 sanitizer

这一页的结构

  1. 先修概念:谓词是什么 + 触发条件
  2. 谓词伪代码
  3. 传播规则:容器 / 字符串变换 / 框架分派
  4. 一个完整路径示例
  5. CodeQL DataFlow vs TaintTracking 选型
  6. 两层 query 设计:core / survey
  7. 边界与盲点
  8. 自测

§1 先修概念:谓词是什么

这里的”谓词”不是复杂数学。你可以把它理解成一个判断函数:输入一个程序点,回答 true 或 false。

例如:

isLLMSource(x): x 是不是 LLM 输出?
isDangerousSink(y): y 是不是危险操作?
isSanitizer(z, sink): z 是不是对 sink 有效的防御?

ToLO 谓词就是把这些判断组合起来:

isToLOPath(src, sink) :=
isLLMSource(src)
AND isDangerousSink(sink)
AND hasFlowPath(src, sink)
AND NOT exists guard:
guard lies on path(src, sink)
AND isTypeMatchedSanitizer(guard, sink)

用白话说:先找到 LLM 输出,再看它是否流到危险操作,最后看中间有没有真正匹配的保护。三件事都满足才报

其中 isLLMSourceToLOScanner 的核心创新点,isDangerousSink 则尽量复用已有规则库。这样设计便于与默认 CodeQL 做对照实验:同一批 sink,不同 source spec

§2 触发谓词(展开版)

对程序点 (p_src, p_sink),存在路径 π: p_src ↝ p_sink 且满足三项:

  1. p_src ∈ S_LLM^{direct, framework, parsed, structured, rag}
  2. p_sink 属于 ToLO-{Deser, Exec, Shell, SQL, Path, SSRF, Template} 对应 sink
  3. 路径上没有对该 sink 有效的 sanitizer

“有效”意味着 sanitizer 与 sink 类型匹配:

  • html.escape 不能清洗 SQL
  • SQL parameterization 不能清洗 path
  • Pydantic(str) 不能清洗 shell

详细 sanitizer-sink 匹配规则见 Sources and Sinks §三个容易混淆的判断

§3 传播规则

3.1 S_LLM^parsed 要显式建 propagation step

data = json.loads(ai.content) # S_LLM^direct → S_LLM^parsed
obj = yaml.safe_load(resp.text) # 同上
parsed_action = parser.parse(message) # 同上
fields = re.findall(r"(\w+)=(\w+)", text) # 字段仍 tainted

在 CodeQL TaintTracking::Configuration 中加 isAdditionalTaintStep:

override predicate isAdditionalTaintStep(DataFlow::Node src, DataFlow::Node tgt) {
// json.loads(x) → return value
exists(Call c |
c.getFunc().(Attribute).getName() = "loads" and
c.getFunc().(Attribute).getObject().(Name).getId() = "json" and
src.asExpr() = c.getArg(0) and
tgt.asExpr() = c)
or
// yaml.safe_load 同上
...
}

3.2 结构化输出的字段默认继承污染

结构化输出的 Pydantic / dataclass 字段默认继承污染;只有 LiteralEnum、正则约束、allowlist、参数化调用、安全 codec 或 capability gate 才能作为候选 guard

class Action(BaseModel):
tool: Literal["a", "b"] # ← 是 guard 候选
path: str # ← 不是,继续 tainted
action = parse_action(ai_response)
action.tool # ← 在 {"a","b"} 内,部分清洗
action.path # ← 仍然 tainted

3.3 容器传播

LLM 输出放入 dictlistdataclass、Pydantic model 后,字段读取仍应保留污染:

tainted = ai.content
# dict
d = {"x": tainted}
d["x"] # tainted
# list
l = [tainted]
l[0] # tainted
# tuple
t = (tainted,)
t[0] # tainted
# dataclass / Pydantic
obj = MyModel(name=tainted)
obj.name # tainted
# kwargs spread
fn(**{"path": tainted}) # path 参数 tainted

CodeQL TaintTracking 库默认覆盖大部分容器传播,但自定义 wrapper 类可能需要手动加 step。

3.4 字符串变换不构成清洗

sql_part = f"WHERE id = {tainted}" # tainted
upper = tainted.upper() # tainted
filled = tpl.format(x=tainted) # tainted
joined = ",".join([safe, tainted]) # tainted
encoded = tainted.encode("utf-8") # bytes 仍 tainted
b64 = base64.b64encode(encoded) # tainted
fixed_padding = tainted.ljust(10) # tainted

唯一例外:真正改变语义的转换才算 sanitizer。例如 int(tainted) 把字符串转整数,后续作为 int 使用就部分安全(但要确保下游不再当字符串)。

3.5 框架分派(tool registry)

agent action 通过 registry 找到 tool 函数时,tool_input 到函数参数的映射需要建模,否则会漏掉真实 sink:

TOOLS = {
"read_file": read_file_impl,
"run_cmd": run_cmd_impl,
}
def dispatch(action: AgentAction):
fn = TOOLS[action.tool] # ← 动态查找
return fn(**action.tool_input) # ← 参数展开
# sink 不在 dispatch 这里,在 TOOLS 的某个函数体里
def run_cmd_impl(cmd: str) -> str:
return subprocess.run(cmd, shell=True, ...) # ← 真实 sink

CodeQL 建模:为 TOOLS[x](**args) 这种动态分派建立流向 — 每个被注册的 tool 函数的参数都成为 sink。可以:

  1. 找所有 TOOLS = {...: fn, ...} 字面字典中作为 value 的函数。
  2. 把这些函数的参数标为 sink source(注意:这里 source 和 sink 都成立)。
  3. 让 dataflow 追踪 action.tool_input → fn 参数 → fn 体内 sink

LangChain @tool 装饰器和 LangChain Agent registry 都需要类似建模。

§4 一个完整路径示例

msg = chain.invoke(user_input) # S_LLM^framework
action = parser.parse(msg.content) # S_LLM^parsed (经 OutputParser)
tool_name = action.tool # 字段读取,tainted
tool_args = action.args # 字段读取,tainted
tools[tool_name](**tool_args) # 动态分派 → sink 在 tools 某个函数体内

人工标注:

msg.content → action.args → tool_args → selected tool → sink inside tool
| | | | |
S_LLM S_LLM S_LLM dispatch dangerous call

然后再决定 CodeQL 是否需要额外 propagation step。真正的 sink 不在当前文件里,可能藏在 tools[tool_name] 指向的函数中。规则需要理解 tool registry,否则会只看到 parser,看不到执行点

§5 解析器是否是 barrier

取决于语义:

解析器对什么 sink 算 barrier?对其他 sink?
json.loadsToLO-Deser(对替代 pickle 而言)不算 sanitizer。字段内容仍 tainted
yaml.safe_loadToLO-Deser不算 sanitizer 给其他 sink
ast.literal_evalToLO-Exec(对替代 eval 而言);ToLO-Deser不算 给其他 sink
numexpr.evaluate(expr, global_dict={}, local_dict={...})ToLO-Exec(数学子集)不算 给其他 sink
pickle.loads永远不算 sanitizer,本身是 sink
re.findall(pattern, x)不算 sanitizer。可能配合 allowlist 用
自定义 OutputParser默认不算。除非内部做了 schema/allowlist 校验

实用判定:

解析器是否是 sanitizer,看它是否拒绝执行 / 拒绝实例化危险对象,而不是看它”看起来比 eval 安全”。

§6 CodeQL 选型:DataFlow vs TaintTracking

DataFlow::Configuration

值保持传播(value-preserving):

  • y = x
  • y = obj.attr(如果 obj 含 x) ✅
  • y = x.replace("a","b") ❌(值变了,默认不传)
  • y = json.loads(x) ❌(对象类型变了)

误报低、漏报多。适合”高置信路径”。

TaintTracking::Configuration

污点保持传播(taint-preserving):

  • y = x
  • y = x.replace("a","b")
  • y = json.loads(x) ✅(配合 isAdditionalTaintStep)
  • y = some_dict[x]
  • y = f"prefix {x}"

召回高、误报多

ToLO 通常需要哪种

ToLO 因为常有字符串拼接、容器读写、解析派生值等非值保持传播,覆盖 ToLO 更完整需要 TaintTracking

实际规则可以先用 DataFlow 固定高置信路径,再用 TaintTracking 扩展召回

初学阶段可以理解成:DataFlow 更保守,适合少误报;TaintTracking 更宽,适合找更多可疑路径

§7 两层 query 设计

工程上分两层查询:

名字目标误报容忍适用场景
1core queries高 precision公开 benchmark、CVE 复盘、官方安全 advisory
2survey queries高 recall生态扫描、人工 triage、研究统计

core queries(精确)

只报高置信路径:

  • source = 明确的 SDK 字段(openai.OpenAI / anthropic.Anthropic 等的 message.content / tool_calls)
  • sink = 标准库的危险函数(eval / subprocess.run(shell=True) 等)
  • 无任何 sanitizer

survey queries(广覆盖)

扩大 parser、container、framework callback 的传播范围:

  • 包含 S_LLM^parsedS_LLM^structuredS_LLM^rag 完整建模
  • 跨 tool registry 分派
  • 反射 / 动态 import 的保守上界
  • 自定义 parser 默认不算 sanitizer

两层结果不要混在一起报。core queries 支撑 precision,survey queries 支撑覆盖面,二者面向不同问题

§8 边界与盲点

容易做的

  • 跨函数:CodeQL 全局数据流默认覆盖。
  • 跨对象:field / attribute step 默认覆盖(可能要手动加 wrapper 类)。
  • 跨模块:依赖库模型(需要先建 library type model)。

容易漏报的

  • 反射:getattr(obj, name)(args) 取决于 name 来源,如果 name 来自 LLM,sink 不确定。保守起见全部标 sink
  • 动态 import:importlib.import_module(name) 同上。
  • 框架回调:LangChain BaseCallbackHandler.on_* 钩子被框架内部调用,跨 callsite。
  • agent tool registry:动态字典分派(见 §3.5)。
  • 自定义 sanitizer:看似有校验逻辑但实现不正确(例如 ”..” 字符串替换),isBarrier 误判。

处理盲点的姿势

标为保守盲点,而不是用过宽 source / sink 规则硬凑召回。

// 例:对动态分派,把所有 callable[x] 形式标 sink (over-approx)
predicate isDynamicDispatchSink(DataFlow::Node n) {
exists(Call c |
c.getFunc() instanceof Subscript and
n.asExpr() = c.getArg(_))
}

报告里要写 limitation:“动态分派情况未精确建模,部分路径可能误报。“

JS / TS 端额外注意

  • 动态属性:obj[name] 同 Python 的反射。
  • destructuring:const { foo } = bar 字段传播。
  • async callback:Promise.then(cb) 跨 callsite。
  • object spread:{ ...args } 容器展开。

Python 端额外注意

  • monkey patch:module.func = my_replacement 改变函数行为。
  • decorator:@tool 改变函数 metadata 但通常不改 body。
  • dynamic import:__import__(name) 同 importlib。
  • 框架注册表:registry[name] = fn 风格分派。

§9 自测

下面哪条应该报告 ToLO?

A. LLM output -> print(...)
B. LLM output -> json.loads(...) -> open(path)
C. LLM output -> allowlisted tool name -> safe fixed function
D. LLM output -> Pydantic str field -> cursor.execute(sql_template, (field,))
E. LLM output -> Pydantic str field -> f"SELECT {field}" -> cursor.execute(...)
F. LLM output -> Literal["a","b"] -> if x == "a": handle_a(); else: handle_b()
选项应该报告吗原因
A没有危险 sink,只是展示
B典型 ToLO-Path,json.loads 不算 sanitizer
C🟡取决于 safe fixed function 内部是否真的安全;allowlist 切断了 tool name 选择,但参数仍可能 tainted
DSQL 参数化已经类型匹配,field 作为值进入,不算结构
Ef-string 拼接进入 SQL,field 决定结构,参数化无效,ToLO-SQL
FLiteral 已收窄到 2 个值,然后分派到不同 handler,没有 tainted 数据真正流到 sink

§10 设计取舍

ToLOScanner 的规则应偏向可解释。每条告警最好能展示:

source location → transform path → sink location → missing guard

只报告”这里有 eval”不够 — 它无法证明 LLM 输出参与其中。 只报告”这里有 LLM output”也不够 — 它未必触达敏感操作

第一版规则可以刻意保守:优先覆盖高置信 source 与高危 sink,避免把普通自然语言展示路径报成漏洞。随后再扩展。

下一步阅读