我们要把两件本质不同、但需要配合使用的东西理清楚:
-
污点模型(taint model)
- 解决的是:"到底在找什么风险?"
- 包括:不同抽象层次的「风险模板」
-
正向 / 反向 扫描方向(forward / reverse)
- 解决的是:"让 LLM 用什么姿势来找?"
- 它只是「提问方式」和「搜索风格」,和污点本身是正交的维度
核心观点:
「要找什么」(污点模型)和「怎么问」(正/反向)是两条互不混淆的轴,
扫描方法 = 污点模板 × 提问策略。
无论是哪种类型的漏洞,抽象到足够底层,其实都在问同一件事:
有没有一条路径,把「不可信 / 不受控的东西」传递到「敏感 / 危险的地方」,
并且这条路径中间缺少足够的约束或净化?
形式化一点可以写成:
如果存在:
- 某个 Source(不可信输入 / 影响源)
- 某个 Sink(危险操作 / 危险状态)
- 一条从 Source → Sink 的数据流 / 控制流路径
- 且路径上没有足够的 Sanitizer(验证 / 网关 / 保护措施)
那么:漏洞成立。
这就是「污点分析」的统一内核。
定义
指系统在任何「逻辑上允许的」状态下都应该成立的数据关系。如果某个时刻这些关系不成立,就说明数据已经被破坏,即是逻辑漏洞或状态污染。
污点框架
- Source:任何能「修改状态」的操作(转账、增发、清算等)
- Sink:
Invariant被违反的那个状态(不变量断言失败) - Sanitizer:在关键写操作前/后,对 invariant 的校验,或者通过严密的会计逻辑自动保证该关系绝不会被破坏
典型例子
代币系统中,某合约允许管理员直接修改 balance[user],但没有同步调整 totalSupply,也没有任何地方检查它们是否相等。则存在一条「写状态 → 不变量被破坏」的污点路径,最终导致 totalSupply ≠ Σ balances。
抵押借贷系统中,如果清算函数没有正确更新债务和抵押品的关系,可能导致某用户的 collateral_value < debt_value × 清算系数,违反了系统应始终保持的偿付能力不变量。
定义
开发者在设计业务逻辑时「理所当然」认为始终成立、但代码本身未必能完全强制、或者只能部分强制的前提条件。更关注的是「流程顺序 / 角色 / 场景」,不单是数据等式。
污点框架
- Source:各种外部调用入口、跨交易状态组合、时间 / 区块高度、不同角色的交互
- Sink:
AssumptionViolated的情形,即业务假设被打破但危险操作仍然执行 - Sanitizer:流程网关、访问控制(
onlyOwner/onlyRole)、时间锁 / 队列 / 多签等
典型例子
借贷协议假设「用户必须先抵押,再调用借款函数」。但实际代码中 borrow() 函数没有检查抵押是否足够,直接假设前端会引导用户先抵押。攻击者直接调用 borrow() 就能在抵押不足的情况下成功借款,导致资金被盗。
DAO 治理系统假设「提案执行前必须经过公示期和投票」。但如果某个紧急执行函数绕过了这些检查,管理员或攻击者就能跳过治理流程直接执行危险操作。
定义
在某个特定位置 / 表达式上,把它视为潜在污点锚点,然后问:「这个点会不会在某些输入/状态下,触发危险行为?」面向某一行或某一段具体表达式,例如一次 call、一次 delegatecall、一次敏感除法、一个价格读取。
污点框架
- Source:这条表达式依赖的、可能来源于用户 / 外部系统的变量
- Sink:该表达式本身就是 Sink(例如外部调用 / 敏感写操作 / 权限变更)
- Sanitizer:表达式前后的安全包裹,如 access control、重入锁、白名单检查、数值上界检查等
典型例子
某函数中存在 externalContract.call{value: amount}("");。如果 amount 直接来自用户参数,状态在转账后才更新余额,且没有重入锁,则存在一条从「用户控制变量」到「危险调用」的污点路径,可能导致重入攻击。
某价格计算中存在 price = reserves0 / reserves1。如果 reserves 来自可被闪电贷操纵的池子,且计算前没有 TWAP 或其他价格平滑机制,则该除法表达式就是一个危险的 Sink。
定义
关注系统在不同阶段 / 状态下的转换逻辑。核心问题是:"能否在不该进入某状态时强行进入?能否跳过必要阶段?能否回退到已结束状态?"
污点框架
- Source:触发状态转换的外部调用、时间条件、投票结果
- Sink:状态机进入非法状态 / 跳过关键阶段 / 时间倒流
- Sanitizer:状态转换网关、阶段锁、不可逆标志位、时间检查
典型例子
众筹合约设计为「未达到目标金额前不能提款」。但如果提款函数只检查了时间截止,没有检查是否达标,则攻击者可以在众筹失败的情况下仍然提走资金,跳过了「达标验证」这个必要阶段。
NFT mint 设计为「白名单阶段 → 公售阶段」的两阶段模式。如果公售函数没有检查当前是否已过白名单期,或者白名单期标志位可以被重置,则可能出现时序混乱,提前开放公售或白名单用户重复 mint。
游戏合约中,玩家状态应该是「注册 → 游戏中 → 已结束」的单向流转。如果缺少状态检查,玩家可能在游戏已结束后重新进入游戏中状态,重复领取奖励。
定义
关注多个操作、多个合约、多个协议交互时产生的非预期组合效应。单独看每个组件可能安全,但特定组合顺序/条件下会触发漏洞。核心问题:"A 和 B 各自安全,但 A→B 或在 C 条件下同时执行 A+B 会出问题吗?"
污点框架
- Source:多笔交易的组合、闪电贷 + 正常业务、多个 modifier 叠加、继承链上的函数覆盖
- Sink:组合后产生的意外状态(价格被操纵、权限被绕过、重入窗口被打开)
- Sanitizer:原子性保证、组合前的一致性检查、明确的执行顺序约束
典型例子
闪电贷 + DEX swap 的组合攻击。单独看,闪电贷协议安全,DEX 的 swap 函数也安全。但攻击者在单笔交易内先用闪电贷借入大量代币,操纵 DEX 池子价格,再执行套利操作,最后归还闪电贷。这个组合产生了「瞬时价格操纵」这个单独操作不会出现的问题。
多重继承导致的权限绕过。合约 A 和 B 都有 onlyOwner 修饰的函数,子合约 C 继承 A 和 B。如果 C 中某个函数覆盖了父合约的实现但没有保留 onlyOwner 检查,则原本需要 owner 权限的操作被绕过。
approve + transferFrom 的组合。用户先 approve 给某个合约,该合约在回调中调用 transferFrom。如果这两个操作的组合没有被充分考虑,可能导致重入或授权滥用。
定义
关注极端输入、边界值、溢出、下溢、空集合等边缘情况下的行为。核心问题:"0、MAX、空数组、第一个/最后一个元素时会发生什么?"
污点框架
- Source:用户可控的数值参数、数组长度、循环边界
- Sink:除零、下溢导致巨大数值、数组越界、gas 耗尽
- Sanitizer:边界检查(>0, <MAX)、SafeMath、数组长度验证、gas limit 预估
典型例子
转账函数收取 1% 手续费,计算为 fee = amount * 1 / 100。当 amount = 0 时,fee = 0,可能绕过最小手续费要求,或者在某些业务逻辑中被视为「免费操作」而触发非预期行为。
代币铸造函数没有检查 totalSupply + amount 是否溢出。当 totalSupply 接近 type(uint256).max 时,再铸造会导致溢出回绕到一个很小的值,破坏供应量不变量。
批量操作函数遍历数组 for (uint i = 0; i < users.length; i++)。如果 users 是空数组,循环直接跳过,函数返回成功,可能导致某些必要的初始化或检查被绕过。或者数组过大导致 gas 耗尽,函数无法完成。
基于上面六种类型,我们可以把「要找什么」抽象成一组污点模板:
每个模板都描述:
- 允许哪些东西被当作 Source
- 哪些情况被视为危险 Sink
- 哪些检查 / 控制算作有效 Sanitizer
| 模板类型 | 关注点 | Source 例子 | Sink 例子 | Sanitizer 例子 |
|---|---|---|---|---|
| Invariant | 数据一致性 | 任意状态写操作 | totalSupply != Σbalances 等不变量被破坏 |
require / 自动平衡逻辑 |
| Assumption | 业务流程 / 场景 | 跨交易操作、跨角色调用、时间 / 阶段变化 | 未满足「先A再B」的流程就执行了 B / 危险业务动作 | 访问控制、流程开关、时间锁、多签等 |
| Hypo / Expression | 具体表达式 / 行 | 表达式输入变量,用户参数 / 外部返回值 | 这行本身:call / delegatecall / 写权限 / 分红操作 | 周围的 onlyOwner、锁、额度 / 范围检查等 |
| Temporal | 状态机/时序 | 触发状态转换的外部调用、时间条件、投票结果 | 状态机进入非法状态 / 跳过关键阶段 / 时间倒流 | 状态转换网关、阶段锁、不可逆标志位、时间检查 |
| Composition | 操作组合 | 多笔交易组合、闪电贷 + 正常业务、多个 modifier 叠加 | 组合后产生的意外状态(价格操纵、权限绕过、重入) | 原子性保证、组合前一致性检查、执行顺序约束 |
| Boundary | 边界条件 | 用户可控的数值参数、数组长度、循环边界 | 除零、溢出、下溢、数组越界、gas 耗尽 | 边界检查、SafeMath、数组长度验证、gas limit 预估 |
从实现上,可以想象我们维护的是一个「模板库」,所有模板最后都会被翻译成:
某种「Source → Sink 是否有未净化路径?」的判定问题。
搞清楚污点模型之后,再来看正向 / 反向扫描。
心智模式:
「假设可能有问题,也可能没问题;你帮我从头到尾检查一下,看看是否存在符合此模板的漏洞。」
- 给 LLM 的信号是:你可以说「没有问题」,但要给理由
- 分析路径:从 Source 出发,向前追踪数据 / 控制流,看能否到达 Sink,同时检查路径上是否有合格 Sanitizer
- 适合用途:做「全面体检」,不预设一定有病,只是按模板做检查
举个业务 Assumption 模板上的例子:
模板:假设「用户抵押不足时,不应允许借款操作」。
正向提问:「请基于这个 Assumption,检查代码中所有与借款相关的路径,看是否存在在抵押不足的情况下仍能成功借款的路径。」
LLM 会尝试遍历相关函数,列举调用顺序、条件分支,最后给出「有 / 无」结论。
心智模式:
「我们已经知道这里存在符合某个模板的漏洞;你的任务是:找到具体是哪里,怎么利用,影响多大。」
- 给 LLM 的信号是:不允许"轻易宣称没问题",必须努力去构造一条满足模板的路径
- 分析路径:从预设的目标问题倒推必要条件,再在代码中寻找满足这些条件的路径
- 适合用途:集中精力在某类风险上"打穿",提高检出率;也适合在看到业务文档后,针对某个特定风险做「命题攻击」
同样用上面借贷的例子:
模板还是:「抵押不足的情况下,不应允许借款。」
反向提问:「系统中已经存在一种场景:用户在抵押不足的情况下仍能成功借款。请你在下面的代码中证明这一点,给出:1)涉及的函数与状态变量;2)调用顺序;3)需要满足的前置条件;4)攻击者如何从 0 到完成攻击。」
LLM 会带着「一定有问题」的心理去检索路径,尝试构造尽量完整、一致的攻击流程。
不管是正向还是反向:
- 不改变:污点模板本身(Source、Sink、Sanitizer 的定义)以及这六种抽象层次
- 只改变:LLM 的「搜索动力」和「解释结构」
- 正向:强调「是否存在?」
- 反向:强调「已经存在,请你证明、构造路径」
所以,两者关系可以抽象成:
漏洞扫描方法 = 污点模板(What to find) × 提问方向(How to query LLM)
可以把整个扫描过程理解为「双重循环」:
-
在污点维度上遍历:针对不同抽象层次的模板(Invariant / Assumption / Hypo / Temporal / Composition / Boundary)
-
在方向维度上选择策略:有的模板先用正向做「是否存在」筛查;对某些高危模板,再用反向做「你已经中招了,请给出利用场景」
举个简化版工作流例子:
第一步:用一批表达式级 Hypo 模板(比如外部调用、低级别 call 等),做一轮正向扫描,收集所有「疑似危险行」。
第二步:对每个疑似点,再套上对应的 Assumption / Invariant / Temporal 模板,用反向方式让 LLM 主动构造完整攻击路径。
第三步:对 Composition 和 Boundary 这类容易被忽视的模板,专门设计针对性的正向检查清单,确保覆盖各种组合场景和边界情况。
第四步:对明确的 invariant / assumption 风险,再单独写成「规则 + 解释」作为审计报告的一部分。
这样做的好处是:
- 污点模板层:清晰表达我们到底想要防的是什么(资金安全?权限?数据一致性?业务流程?时序正确性?组合安全?边界健壮性?)
- 正/反向层:只是「在和 LLM 交互的时候,换一种思路提问」,用来提高覆盖率和检出率
可以用一句话概括这整篇文档的结构:
-
底层统一逻辑:所有安全问题都可以视为一种「污点路径」:不可信 Source → 危险 Sink,中间缺失 Sanitizer
-
六种抽象层次的污点模板:
- Invariant:数据恒等式 / 一致性
- Assumption:业务流程假设 / 场景约束
- Hypo/Expression:具体行级危险点
- Temporal:状态机 / 时序正确性
- Composition:多操作组合的非预期效应
- Boundary:极端值 / 边界情况
-
正向 / 反向是正交维度:不改变「污点模板」本身,只改变 LLM 的推理姿势和回答结构