Foundry forge lint 规则详解:delegatecall-loop 与可支付循环中的 delegatecall 风险
【免费下载链接】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的delegatecall-loop规则(文档位于 crates/lint/docs/delegatecall-loop.md),系统讲解该规则检测的代码模式、背后的安全动机、官方推荐的修复写法,并结合仓库内的 Rust 实现与测试用例(delegatecall_loop.rs、DelegatecallLoop.sol)还原其检测原理。读完本文,你将掌握:在什么场景下delegatecall与循环的组合是危险的、如何写出既安全又能通过 lint 检查的批量分发代码,以及如何用forge lint在项目中启用或排除这条规则。
规则速览
| 项目 | 内容 |
|---|---|
| 规则 ID | delegatecall-loop |
| 严重级别 | Low(低) |
| 所属类别 | 潜在安全风险(Payable 函数相关) |
| 检测对象 | for、while、do while循环体中出现的delegatecall表达式 |
| 附加条件 | 包裹该循环的外层入口函数必须是public payable或external payable |
| 诊断消息 | payable function usesdelegatecallinside a loop |
在 Foundry 的 linter 分类中(见 crates/lint/README.md),该规则与calls-loop、block-timestamp等同属 Low 严重级别,且属于默认启用范围(默认severity为 High、Med、Low 三档,见下文配置说明)。
规则检测什么:payable 循环体内的 delegatecall
规则文档对检测范围的表述非常精确:
Reports
delegatecallexpressions that appear in the body of afor,while, ordo whileloop when the enclosing function ispublic payableorexternal payable.
即同时满足以下两个条件的代码会被报告:
delegatecall出现在for、while或do while的循环体内(包括for的更新表达式等每次迭代都会执行的位置);- 该循环最终位于一个
public payable或external payable入口函数的执行路径上。
需要注意的是,条件 2 中的“入口函数”判定并不仅限于字面意义上的“同一函数体内”。从实现看(详见下文源码解析),规则会沿着函数调用链与 modifier 链追踪,只要循环最终在 payable 入口函数内执行,无论是写在 helper 内部函数中、modifier 中,还是通过super派发到达,都会被覆盖。
官方给出的触发示例
function batch(address[] calldata receivers) external payable { for (uint256 i; i < receivers.length; ++i) { address(this).delegatecall(abi.encodeWithSignature("credit(address)", receivers[i])); } }这段代码把一次msg.value在循环中重复传递给多个delegatecall,正是规则要拦截的典型模式。
为什么这是危险的
规则文档给出的安全动机如下:
delegatecall会在调用方的存储上下文中执行另一份合约的代码,并原样保留调用时的msg.sender与msg.value;- 在一个 payable 函数中,如果循环体内多次执行
delegatecall,即使 Ether 只被转入一次,循环中的每一次委托调用看到的都是同一个msg.value; - 如果被委托的代码逻辑依赖
msg.value记账,那么一笔交易就可能把同一笔付款重复入账多次,或者以非预期的方式反复改写调用方的存储状态。
简言之,风险本质是「一次入账、多次消费」:msg.value是调用级语义而非单次调用语义,循环会放大这种语义错配,造成重复记账或状态被反复变更。
值得补充的是,delegatecall自身的信任模型(被委托代码可任意读写调用方存储)在 Foundry 的另一条规则controlled-delegatecall(High 级别,见 crates/lint/README.md)中单独处理;delegatecall-loop聚焦的是「循环 + payable + delegatecall」这一组合模式。
官方推荐的修复写法
规则文档给出了修复建议的核心思路:在进入循环前一次性完成金额分配计算,循环内改用普通内部调用,而不是在循环体内反复delegatecall。
官方示例(等额均分场景):
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) { _credit(receivers[i], share); } }文档特别提醒了几个边界细节,这些是实际落地时必须考虑的点:
- 拒绝空列表:
require(receivers.length != 0)避免除零(msg.value / 0会回滚,但显式检查语义更清晰,也避免无意义的空转); - 整除校验:
require(msg.value % receivers.length == 0)确保每份金额严格相等,不会因取整产生余数; - 余数处理策略:该示例只接受恰好整除的支付;其他 API 设计可以显式退款,或把余数单独记账,需要在业务层明确选择一种策略并实现;
- 循环内调用
_credit这类内部函数:内部调用运行在同一个执行上下文中,传递的是显式计算的share参数,msg.value不再被隐式重复消费。
源码级实现:这条规则是如何检测的
规则的 Rust 实现位于 crates/lint/src/sol/low/delegatecall_loop.rs,核心逻辑非常精简:
impl<'gcx> LateLintPass<'gcx> for DelegatecallLoop { fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { for_each_payable_loop_expr(gcx, func, |expr| { if let ExprKind::Call(callee, ..) = &expr.kind && gcx.resolved_builtin(callee) == Some(Builtin::AddressDelegatecall) { ctx.emit(&DELEGATECALL_LOOP, expr.span); } }); } }它通过declare_forge_lint!宏声明了规则元数据(ID 为delegatecall-loop、Severity 为Low),并实现LateLintPass的check_function回调。该规则在 crates/lint/src/sol/low/mod.rs 中以late阶段注册。
检测逻辑可分为两层:
第一层:遍历「payable 函数循环内的所有表达式」
for_each_payable_loop_expr定义在共享工具模块 crates/lint/src/sol/low/payable_loop.rs 中(该模块同时服务所有*-loop系列 lint)。入口过滤条件如下:
if !matches!(func.kind, FunctionKind::Constructor | FunctionKind::Modifier) && func.state_mutability == StateMutability::Payable && matches!(func.visibility, Visibility::Public | Visibility::External)即:被检查的函数必须是payable且为public / external的普通函数(构造函数与 modifier 本身不作为入口判定)。从源码结构看,LoopWalker通过维护loop_depth计数来识别循环上下文,并做三件关键的事情:
- 展开 modifier 链:沿 modifier 的
_占位符(StmtKind::Placeholder)递归进入被包裹的函数体,因此「modifier 内含循环 + 函数体含 delegatecall」或「modifier 循环包裹函数体的 delegatecall」都能被追踪(对应测试中的payableModifierLoop与payableModifierLoopPlaceholder用例); - 内联内部 helper 调用:对可解析的内部函数调用(直接调用、库/基类限定调用、
using for绑定、super派发)递归展开其函数体,因此「payable 函数在循环内调用内部函数、而 delegatecall 藏在内部函数里」也能被捕获; - 正确解析
super与虚函数分派:LoopWalker记录dispatch(入口函数所属合约)与current(当前代码所在合约),通过合约线性化(linearization)解析super.next(...)最终落到哪个实现,避免多级继承下漏报或误报。
同时,callee()方法只内联is_internal()或is_delegate_call()的函数,对「合约类型值上的外部调用」(包括this上的调用)一律不展开,从而与外部delegatecall区分开。
第二层:识别真正的 delegatecall 内建调用
回到delegatecall_loop.rs,对于遍历到的每个循环内表达式,只有满足:
- 表达式是调用(
ExprKind::Call); - 且调用目标解析为 Solidity 内建函数
address.delegatecall(Builtin::AddressDelegatecall,通过gcx.resolved_builtin(callee)判定);
才会触发诊断。这意味着自定义接口里恰好名为delegatecall的普通函数不会被误报——测试用例OrdinaryDelegatecall接口与payableLoopCallsOrdinaryDelegatecall函数验证了这一点。同样,call/staticcall、非 payable 函数中的循环 delegatecall,都不会命中该规则。
测试用例:覆盖矩阵与阴性对照
规则配套的测试文件 crates/lint/testdata/DelegatecallLoop.sol 以//@compile-flags: --only-lint delegatecall-loop声明只运行该规则,并在每个期望命中的位置以//~WARN: payable function usesdelegatecallinside a loop标注预期输出(对应 DelegatecallLoop.stderr)。这份用例本身也是一份很好的「触发条件速查表」:
应当命中(WARN)的场景:
| 场景 | 说明 |
|---|---|
payableForLoop/payableWhileLoop/payableDoWhileLoop | 三种循环形态全覆盖 |
payableNestedDelegatecall | delegatecall 嵌套在循环内的if分支中 |
payableForUpdateExpression | delegatecall 出现在for的更新表达式里(每次迭代都会执行) |
payableModifierLoop | modifier 内含循环,函数体使用该 modifier |
payableModifierLoopPlaceholder | modifier 用_循环包裹函数体,函数体内有 delegatecall |
payableLoopWithInternalDelegatecall | 循环调用内部函数,delegatecall 藏在内部函数内(helper 内联) |
payableInternalLoopWithDelegatecall | 内部函数自带循环 + delegatecall,被 payable 入口调用 |
payableLoopWithSuperInternalDelegatecall/payableLoopWithLinearizedSuperDelegatecall | super派发与多级继承线性化场景 |
DelegatecallLoopLib.helper库内 helper | 库函数中的循环 delegatecall 被入口函数循环调用 |
payableLoopCallsConditionalDelegatecall(WithBinaryCondition) | 三元表达式选择 delegatecall 目标 |
payableLoopCallsThisDelegatecall/payableLoopCallsSuperDelegatecall | this.与super.形式的 delegatecall 调用 |
不应命中(阴性对照)的场景:
nonPayableLoop:非 payable 函数中循环内 delegatecall——不报;payableLoopWithCallAndStaticcall:循环内call/staticcall——不报(仅delegatecall内建触发);payableLoopCallsOrdinaryDelegatecall:调用接口中自定义的、非内建的delegatecall函数——不报;payableDelegatecallOutsideLoop:payable 函数中但不在循环内的 delegatecall——不报;payableLoopCallsSafeOverload与overloaded重载解析:循环调用被重载的内部函数,需确认解析到不含 delegatecall 的重载——不报。
这些用例同时验证了检测器的两个关键工程细节:跨函数/跨 modifier 的追踪必须依赖可靠的调用图与继承解析,而识别必须严格限定在内建delegatecall语义上,二者缺一不可。
在项目中使用与配置该规则
delegatecall-loop作为默认启用的 Low 级别规则,开箱即用。相关配置入口如下:
1.forge lint命令行
命令实现见 crates/forge/src/cmd/lint.rs:
# 全项目默认检查(Low 在默认 severity 范围内,会自动包含本规则) forge lint # 只看某条规则(--only-lint 会绕过 severity 过滤,只运行指定 ID) forge lint --only-lint delegatecall-loop # 按严重级别筛选 forge lint --severity low要点:--only-lint指定规则 ID 列表并覆盖exclude_lints配置;--severity覆盖项目配置中的severity(合法值包括high、med、low、info、gas,见 crates/config/src/lint.rs 中的Severity枚举与解析)。
2.foundry.toml项目配置
对应配置结构定义在 crates/config/src/lint.rs 的LinterConfig:
[lint] # 运行哪些严重级别的规则;默认 high/med/low,本规则属于 low severity = ["high", "med", "low"] # 排除指定 ID 的规则 exclude_lints = ["delegatecall-loop"] # 忽略匹配 glob 的文件 ignore = ["test/**", "script/**"] # 是否在 forge build 时自动执行 lint;默认 true lint_on_build = true另外,根据 crates/lint/README.md 的说明,test与script目录下的文件默认不参与除unsafe-cheatcode和environment-read-across-mutation之外的所有规则(包括显式指定时),生产源码始终会被检查;此例外仍受 severity 过滤、排除项与行内抑制(inline suppression)约束。
3. 诊断输出示例
命中时输出形如(取自 DelegatecallLoop.stderr):
warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop ╭▸ src/YourContract.sol:LL:CC │ LL │ (bool ok,) = target.delegatecall(payloads[i]); │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ╰ help: forge lint 规则文档 delegatecall-loop如需在 CI 或脚本中以机器可读格式消费诊断,可配合 JSON 输出;forge lint还提供--report-unused-suppressions用于检查未被实际使用的行内抑制注释。
与其他相关规则的边界
calls-loop(Low):循环中的外部调用可能因单次 revert 或 gas 耗尽导致批量操作整体 DoS。它与delegatecall-loop的关注点不同——前者是「循环内任意外部调用」的可用性问题,后者是「payable + delegatecall」的资金语义问题;controlled-delegatecall(High):delegatecall的目标必须是可证明受信任的地址,关注「调谁」;delegatecall-loop关注「在什么上下文里调」(payable 循环);low-level-calls(Info/Style):主张尽量避免直接使用底层调用,属于风格层面的通用建议。
三者可以叠加使用:controlled-delegatecall保证目标可信、delegatecall-loop阻止 payable 循环中滥用、low-level-calls引导整体减少底层调用面。
小结:一条可执行的检查清单
结合规则文档、源码实现与测试矩阵,接入该规则时的实践要点可归纳为:
- 识别高危模式:
public/external payable函数中,循环体内出现address(...).delegatecall(...)(无论直接书写,还是藏在内部函数、modifier、super派发、库函数中); - 理解风险本质:
delegatecall保留msg.sender与msg.value,循环会让「一次转账、多次消费」成为可能,造成重复记账或存储被反复改写; - 优先重构而非抑制:进入循环前完成均分计算(显式拒绝空列表与不整除情形、明确余数处理策略),循环内改用传递显式参数的内部函数调用;
- 善用工具链:
forge lint --only-lint delegatecall-loop单独验证,foundry.toml的[lint]段统一管理 severity 与排除项,测试目录天然豁免,生产源码始终在检查范围内; - 保持规则边界认知:
delegatecall-loop只针对「payable 循环 + 内建 delegatecall」这一特定组合,call/staticcall、非 payable 函数、自定义同名接口函数均不属于其职责范围。
【免费下载链接】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),仅供参考