Foundry 安全 lint 实战:用 msg-value-loop 规则根治循环内读取msg.value的记账错误
【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry
Foundry 自带的forge lint内置了msg-value-loop规则(严重级别 Low),专门检测从payable入口可达的for/while/do while循环体内读取msg.value的代码模式。本文以该规则的权威文档 crates/lint/docs/msg-value-loop.md 为主体,结合 msg_value_loop.rs 与共享遍历器 payable_loop.rs 的实现,以及 MsgValueLoop.sol 测试用例,讲清规则行为、触发原理、修复方案与工程配置,帮助你彻底消除批量转账、批量空投类合约中"一次付款被当成多次"的资金记账隐患。
规则速览
| 属性 | 值 |
|---|---|
| 规则 ID | msg-value-loop |
| 严重级别 | Low |
| 诊断消息 | payable function uses msg.value inside a loop |
| 类型 | LateLintPass(基于 HIR 的晚阶段 lint) |
规则注册于 crates/lint/src/sol/low/mod.rs 的declare_forge_lint!宏中,与delegatecall-loop规则共享同一套"可付款循环表达式遍历"基础设施。
规则检测什么:循环上下文中的msg.value
msg-value-loop报告的是从public payable或external payable入口可达的for、while、do while循环体内执行的msg.value表达式。规则文档明确了以下几点边界行为:
- 构造函数的处理:
payable构造函数中的循环即使读取msg.value也不会被报告(构造函数场景下循环累计的意义通常不同); receive()与fallback():当二者为payable时同样会被检查;- 修饰器与内部辅助函数:循环位于修饰器(modifier)中、或
msg.value出现在被调用的 internal helper 内部,都会被纳入检查范围(即"跨函数追踪"); - 不追踪的边界:内联汇编(inline assembly)以及通过函数指针(function pointer)发起的调用不会被追踪。
从源码看,核心判定逻辑非常简洁,见 msg_value_loop.rs:对每个函数调用for_each_payable_loop_expr,凡是在循环上下文中解析出的内建表达式为Builtin::MsgValue,即在对应位置ctx.emit一条MSG_VALUE_LOOP诊断。
为什么这是坏的:一次付款被当成 N 次
规则文档给出的核心论据如下:
msg.value在一个调用帧(call frame)内是固定的。嵌套调用可以携带不同的 value,而delegatecall会保留调用方帧内的 value。在循环中读取它,可能错误地把同一笔 Ether 支付当作"每次迭代各支付一次"。
这带来的直接后果包括:记账错误、重复入账(repeated credits)、资金损失——当循环迭代基于msg.value进行发送、记录或其他消费时尤其危险。
结合 EVM 语义可以进一步理解:msg.value是当前消息调用的随附 ETH 数量,与本次调用绑定而非与循环迭代绑定。若在循环中直接使用它:
function batch(address[] calldata receivers) external payable { for (uint256 i; i < receivers.length; ++i) { credits[receivers[i]] += msg.value; // 每一轮都加上同一笔钱 } }调用者只付了一笔msg.value,但每个接收者都被记上全额,总账目放大了receivers.length倍;若后续按credits提现,合约将被抽干。
触发示例与推荐修复
规则文档给出如下触发示例:
function batch(address[] calldata receivers) external payable { for (uint256 i; i < receivers.length; ++i) { credits[receivers[i]] += msg.value; } }推荐修复(等额分摊)——先拒绝空列表并处理余数。该示例只接受可被整除的支付,其他 API 设计可以显式退款或单独记录余数:
function batch(address[] calldata receivers) external payable { require(receivers.length != 0, "no receivers"); require(msg.value % receivers.length == 0, "unequal split"); uint256 share = msg.value / receivers.length; for (uint256 i; i < receivers.length; ++i) { credits[receivers[i]] += share; } }核心思想:在循环外把msg.value先算出单份份额share,循环内只引用循环无关的既定值。这样既保留"等额分账"的语义,又让每次迭代的入账量固定为msg.value / receivers.length。如果业务需要逐接收者不同金额,则应在循环外显式校验"份额之和 == msg.value",而不是在循环内累加msg.value。
源码级原理:共享的"可付款循环"遍历器
规则背后依赖的是 payable_loop.rs 中导出的for_each_payable_loop_expr。其入口筛选条件(payable_loop.rs)为:
- 函数种类不是
Constructor也不是Modifier; state_mutability == Payable;- 可见性为
Public或External。
满足条件后,LoopWalker会对函数体做一次深度遍历,其关键行为包括:
- 维护
loop_depth计数器:遇到StmtKind::Loop时加一,遍历完该循环的全部语句后减一(payable_loop.rs),从而精确标记"处于循环内"的语句与表达式; - 跨函数内联追踪:循环内调用的 internal helper 会被内联展开,helper 内部若再出现
msg.value同样命中;即使调用发生在循环外,只要被调用方内部自带循环(follow_calls_outside_loop),其循环内的msg.value也会被报告(对应payableInternalLoopWithMsgValue测试场景); - 修饰器链展开:通过
visit_modifiers沿修饰器链逐级进入,并在_占位符(StmtKind::Placeholder)处续接被包装的函数体,因此"修饰器内是循环、函数体读msg.value"的场景(payableModifierLoopPlaceholder)也能命中; super与using for解析:callee函数会根据当前合约的线性化(linearization)解析super目标(resolve_super_function),using MsgValueExtension for uint256这类扩展库调用也会被解析为可内联目标(MsgValueExtension.extensionRead命中);- 外部调用不被跟随:对于契约类型值上的调用(包括
this),判定为外部调用而不再内联(payable_loop.rs),这正是文档中"通过函数指针的调用不追踪"的实现依据。
需要说明的是:该遍历器同样被 delegatecall_loop.rs 复用,用来检测循环内的delegatecall,属于同一族规则。
测试用例印证:15 个命中场景全覆盖
规则配套的测试文件 MsgValueLoop.sol 以//@compile-flags: --only-lint msg-value-loop开头,逐行用//~WARN注释标注预期诊断位置,对应的期望输出记录在 MsgValueLoop.stderr 中。测试覆盖了以下命中场景:
- 三种循环形态:
for、while、do while内直接读取msg.value; - 循环更新表达式(update expression)中读取:
for (uint256 i; i < iterations; value = msg.value + i++) {}; payable的receive()与fallback()内的循环读取;- 修饰器内循环 + 函数体读取(
loopPlaceholder场景); - 内部 helper 在循环内被调用、以及 helper 自身含循环两种内联路径;
super调用链(super.superRead()、super.overloaded(i))下的读取;using for扩展库函数内的读取(value.extensionRead());- 内部虚函数重写(override)场景下的读取(
duplicateReadValue被两个循环入口共用)。
同时,测试也固化了不应触发的场景:构造函数中的循环读取(constructor被显式忽略)、循环外读取msg.value(payableMsgValueOutsideLoop)、先缓存再在循环内使用(payableCachedValueInLoop,缓存后的value不再等于msg.value表达式,是正确的写法)、非payable入口(nonPayableLoop)、以及未被调用的扩展库(MsgValueUnusedExtension)。
在项目中启用与配置
forge lint默认会按项目配置的严重级别运行全部已注册 lint。针对该规则常用配置方式:
1. 仅运行指定规则(排查单个问题)
forge lint --only-lint msg-value-loop该参数定义于 crates/forge/src/cmd/lint.rs,支持num_args(1..)传入多个规则 ID。当使用--only-lint时,lint 过滤器会绕过severity限制(见 lint.rs 的注释与实现),只检查列出的规则。
2. 按严重级别过滤
forge lint的--severity参数可覆盖项目foundry.toml中[lint] severity的配置;由于msg-value-loop的严重级别为Low,若项目只关注Med及以上,默认会被跳过,需要显式把Low纳入范围。
3. 提升为构建阻断
在foundry.toml中将该规则加入deny列表,使其从警告升级为报错,配合 CI 强制约束:
[lint] deny = ["msg-value-loop"]deny 逻辑体现在 lint.rs 的linter.lint(&input, config.deny, ...)调用中。也可以使用exclude_lints反选,或仅通过--only-lint白名单放行。
小结
msg-value-loop是 Foundry 静态 lint 体系中针对资金记账正确性的低成本防线:它不依赖运行环境,纯静态即可把"循环内直接读msg.value"这类高危写法挡在代码评审之前。理解其"入口需 payable、构造函数豁免、跨 internal/修饰器/super/using-for 内联追踪、外部调用不跟随"的边界,有助于在批量支付、批量空投、多接收者分账等真实场景中写出既满足业务语义又不触发规则的 Solidity 代码——核心口诀就是:msg.value在循环外先算好份额,循环内只使用与迭代无关的既定值。更多规则编写与文档规范可参考 crates/lint/docs/README.md,完整规则列表见 lint 规则文档目录。
【免费下载链接】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),仅供参考