Foundry 安全 lint 实战:用 msg-value-loop 规则根治循环内读取 `msg.value` 的记账错误

Foundry 安全 lint 实战:用 msg-value-loop 规则根治循环内读取 `msg.value` 的记账错误 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/foundryFoundry 自带的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 测试用例讲清规则行为、触发原理、修复方案与工程配置帮助你彻底消除批量转账、批量空投类合约中一次付款被当成多次的资金记账隐患。规则速览属性值规则 IDmsg-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.valuemsg-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也不是Modifierstate_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_functionusing 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.valuepayableMsgValueOutsideLoop、先缓存再在循环内使用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),仅供参考