Foundry `forge lint` 零地址检查规则 `missing-zero-check` 深度解析:从检测原理到“假守卫“谓词识别修复
2026/9/16 21:34:11 网站建设 项目流程

Foundryforge lint零地址检查规则missing-zero-check深度解析:从检测原理到"假守卫"谓词识别修复

【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry

导读

本文围绕 Foundry 内建 Solidity 静态检查器forge lint的 Low 级别规则missing-zero-check展开,完整讲解该规则的检测范围、触发条件、修复写法,并深入 规则实现源码 剖析其污点分析原理,以及一次针对"引用了地址参数却未证明其非零的谓词"被误当作守卫的关键修复(见 变更记录)。读完本文,你将理解missing-zero-check的判定边界:什么样的谓词才算有效的零地址守卫,什么样的"形似守卫"会被识破并继续告警。

规则概览

项目内容
规则 IDmissing-zero-check
严重级别Low(默认启用,forge lint默认运行 High / Med / Low 三档规则)
诊断消息address parameter is used in a state write or value transfer without a zero-address check
官方文档crates/lint/docs/missing-zero-check.md
实现源码crates/lint/src/sol/low/missing_zero_check.rs

该规则在 crates/lint/README.md 中被归入 Low Severity 类别,与calls-loopdeprecated-oz-functionreentrancy-events等规则并列。

规则检测什么

根据官方规则文档,missing-zero-check会在外部可调用的状态变更函数或构造函数中,报告那些被用于状态写入价值转移、却没有针对address(0)进行检查address类型参数。

从实现源码看(missing_zero_check.rs),判定入口点(entry point)需要同时满足:

  • 函数状态可变性不是pureview
  • 且满足以下之一:
    • 是构造函数(func.is_constructor());
    • 是普通函数且可见性为publicexternal

也就是说,internal/private的辅助函数不在检测范围内,view/pure函数即使引用了地址参数也不会触发(如测试中的viewerpureFnnoSinkJustPassthrough均通过)。测试用例可见 crates/lint/testdata/MissingZeroCheck.sol。

该规则识别的"风险操作"(sink)主要有两类:

  1. 状态写入:将地址写入一个address类型的状态变量(例如owner = newOwner);
  2. 价值/调用转移:作为transfersendcalldelegatecall的接收者(源码通过address_call_receiver识别,见 crates/lint/src/sol/analysis/exprs.rs)。staticcall接收者不算,因为不涉及状态写入或价值转移。

为什么这是一个值得关注的问题

忘记零地址检查是价值损失的常见来源:

  • 代币被永久锁死在零地址,无法找回;
  • 所有权被意外放弃,合约陷入"无人管理"状态;
  • 升级逻辑被永久卡死(如 proxy 的实现地址被置零)。

显式添加一个守卫成本极低,却能消除一整类操作性失误。

示例:错误写法与正确写法

错误写法(规则文档原始示例):

function setOwner(address newOwner) external onlyOwner { owner = newOwner; // no zero-address check }

正确写法:

function setOwner(address newOwner) external onlyOwner { require(newOwner != address(0), "zero address"); owner = newOwner; }

require外,assertif (...) revert();、自定义error等显式退出方式同样被认可为守卫(详见下文"哪些谓词算数")。

源码级原理:基于污点追踪的分析器

MissingZeroCheck是一个LateLintPass(延迟 lint pass,在 HIR 语义分析完成后运行),核心由一个Analyzer结构体承载(missing_zero_check.rs),维护三张关键集合:

  • taint:从候选地址参数传递导出的变量集合,映射到其源头参数。初始时每个参数映射到自身;
  • sinks:已经到达风险操作(状态写入 / 转移)的源头参数;
  • guarded:截至目前已被证明非零的源头参数。

分析流程可以概括为"污染传播 + 守卫记录 + 汇点命中"三步:

1. 候选参数收集

只收集address类型(含address payable)的函数参数(L48-L52)。没有地址参数则直接返回。

2. 修饰器(modifier)预处理

函数上的修饰器如果直接以标识符形式传入地址参数,则把修饰器体当作函数前缀进行分析(L55-L74)。例如:

modifier nonZero(address a) { require(a != address(0), "zero"); _; } function setOwner(address newOwner) external nonZero(newOwner) { owner = newOwner; }

nonZero(newOwner)中实参是直接标识符,映射成立,修饰器内的守卫会作用于newOwner,因此该函数通过检查。反之,如果实参是表达式(如nonZero(addrIdentity(a))),映射无法建立,守卫不被认可,仍然会告警(见测试modifierWithExpr)。

3. 守卫识别:nonzero_facts

这是本次修复的核心所在。nonzero_facts从谓词中提取"非零事实"(L126-L155),其规则是:

  • 形如x != 0(或取反后的x == 0)的比较,且另一侧必须是零值常量is_zero_value),才能证明x非零;
  • !取反递归处理;
  • &&/||复合条件,按布尔逻辑合并两侧事实:A && B在肯定分支上取并集,A || B只有两侧同时成立才能确认……(源码中(And, false) | (Or, true)取并集,其余取交集,对应短路求值语义)。

is_zero_value(crates/lint/src/sol/analysis/exprs.rs)只认可:

  • 数字字面量0
  • 地址字面量address(0)(零地址);
  • 布尔false
  • 以及包裹零值的单参数 cast。

关键点:函数调用(如lookup(0))、状态变量(如owner)、address(this)、mapping 查找(如whitelisted[a])、~uint160(0)这类位运算表达式都不是零值常量。因此形如require(a != address(this))require(a != owner)require(whitelisted[a])的谓词虽然"引用了地址参数",却没有证明参数非零——这正是本次补丁(.changelog/missing-zero-check-content.md,标注forge: patch)修复的内容:

Fixedmissing-zero-checkaccepting predicates that referenced an address parameter without proving it non-zero.

修复前,这类"引用了参数但并未证明其非零"的谓词可能被错误地当作有效守卫,导致漏报;修复后它们不再被认可,参数依旧会被标记告警。

4. 汇点命中与守卫作用域

visit_expr中,当表达式是地址状态变量的赋值、或transfer/send/call/delegatecall的接收者时进入"汇点深度"(sink_depth),此时命中的标识符如果其污点源src不在guarded集合中,就被记入sinks(L275-L281)。

守卫的控制流作用域也经过精心处理:

  • if 语句:then / else 分支各自的守卫通过scoped_guards隔离;若某分支必然退出(branch_always_exits,见 crates/lint/src/sol/analysis/stmts.rs,覆盖returnrevert、退出型调用、assert(false)等),则该分支守卫可延续到 if 之后,否则要求两个分支都守卫才成立(L189-L212);
  • 循环体:循环可能执行零次,循环内守卫不延续到循环外(guardInForLoopguardInWhileLoop因此告警);
  • try/catch:各子句只走一条路径,子句内守卫同样被丢弃(guardInTryClause告警);
  • 顺序性:守卫必须出现在 sink之前guardAfterSink告警)。

哪些谓词算数:PASS 与 FAIL 边界(测试用例对照)

完整用例见 crates/lint/testdata/MissingZeroCheck.sol 与期望输出 crates/lint/testdata/MissingZeroCheck.stderr。

会被告警(FAIL /~WARN标记)的典型场景:

场景原因
owner = newOwner(无任何检查)直接缺省
构造函数constructor(address initialOwner)构造函数同样需要检查
to.transfer(1)/to.send(1)/to.call(data)/a.delegatecall("")价值/调用转移 sink
无实际作用的修饰器doesNothing(a)修饰器内无守卫
别名传播address tmp = a; owner = tmp;污点经局部变量传播
重新赋值tmp = a;同上
类型转换owner = address(uint160(a));cast 不丢失源头
三目表达式flag ? a : address(0)取别名后仍无法保证非零
payable(a).transfer(1)包裹转换不影响
修饰器实参是表达式nonZero(addrIdentity(a))无法证明守卫适用于参数
同一参数喂两个 sink只产生一条诊断(去重)
多跳传播x = a; y = x; owner = y;多级污染传播
守卫在 sink 之后顺序无效
守卫只在单分支/只在循环内/只在 try 子句内作用域不覆盖
require(a != address(this))谓词引用参数但未证明非零(本次修复重点)
require(whitelisted[a])同上(mapping 查找不是零值比较)
require(a != owner)同上(状态变量不是零值常量)
require(amt > 0 && to != msg.sender)未与零值比较
require(a != lookup(0))lookup是函数调用)调用结果不是零值常量
require(a != address(~uint160(0)))位运算不是零值字面量
require(a == address(0))要求等于零,恰好相反
require(a != address(0) || flag)析取一侧可假,不能保证
if (a == address(0)) owner = a;sink 在"为零"分支内,必然使用零地址

通过检查(PASS)的场景:

require(newOwner != address(0), "zero"); // 标准 require assert(newOwner != address(0)); // assert if (newOwner == address(0)) revert(); // if + revert if (a == address(0)) revert ZeroAddress(); // 自定义 error function f(address newOwner) external nonZero(newOwner) // 修饰器直接传参

此外还包括:双分支对称守卫(guardOnBothBranches)、分支内嵌套守卫配合提前revertnestedGuardWithRevert)、then 分支必然退出(require(false)/assert(false)/return)而 else 分支守卫(setOwnerRequireFalseInThen等)、取反合取require(!(a == address(0) || b == address(0)))、无关合取 + 真实检查require(amt > 0 && newOwner != address(0))、以及if (a != address(0)) owner = a;(sink 在非零分支内,天然安全)。

如何运行与配置

在项目根目录执行:

forge lint

只运行本规则(测试文件也使用该方式,见 MissingZeroCheck.sol 的//@compile-flags: --only-lint missing-zero-check):

forge lint --only-lint missing-zero-check

--only-lint会绕过严重级别过滤(实现见 crates/forge/src/cmd/lint.rs)。此外forge geiger本质上就是forge lint --only-lint unsafe-cheatcode的别名(crates/forge/src/cmd/geiger.rs)。

foundry.toml中可配置[lint]段(配置结构见 crates/config/src/lint.rs):

[lint] # 运行哪些严重级别,默认 ["high", "medium", "low"] severity = ["high", "medium", "low"] # 排除指定规则 ID exclude_lints = ["missing-zero-check"] # 忽略的 glob ignore = ["test/**"] # 是否在 forge build 时自动执行 lint,默认 true lint_on_build = true

注意:missing-zero-check属于Low级别,只要默认的severity配置包含low(默认即如此)就会被运行;若自定义severity时漏掉low,该规则会被跳过。

局限性

  • 只针对函数参数(含修饰器直接传参),不追踪状态变量本身、外部调用返回值等来源;
  • 守卫只认可与零值常量的直接比较,address(this)、其他状态变量、函数调用结果等均不算数——这是刻意的保守设计,宁可多报也不漏报;
  • internal/private辅助函数、view/pure函数不做检测;
  • 规则为启发式静态分析,无法覆盖动态数据流(如 storage 读入后再写入的场景)。

小结

missing-zero-checkforge lint中低成本、高回报的防御性规则:通过"污点传播 + 零值守卫识别 + 汇点命中"的三段式分析,捕捉地址参数未经address(0)校验即进入状态写入或价值转移的路径。而本次补丁进一步收紧了"守卫"的判定口径——只有与零值常量的比较才能证明非零,杜绝了"谓词引用了参数、却并未证明其非零"造成的漏报,使规则在require(a != address(this))require(whitelisted[a])这类写法面前依然保持告警,帮助开发者把零地址风险真正扼杀在代码审查阶段。

【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询