Foundry 静态检查详解:calls-loop 规则如何揪出循环中的外部调用
2026/9/17 6:14:33 网站建设 项目流程

Foundry 静态检查详解:calls-loop 规则如何揪出循环中的外部调用

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

导读

calls-loop是 Foundry 内置 Solidity 静态检查(lint)规则之一,用于报告循环体内发生的外部合约调用、低级call/delegatecall/staticcall、ETH 转账send/transfer、通过this的合约自调用以及合约创建。本文以 crates/lint/docs/calls-loop.md 为骨架,结合 Foundry 源码实现与测试用例,完整讲解该规则的检测范围、分类原理、风险成因、修复模式与配置方法,帮助你在forge工作流中落地"循环内禁外部调用"这一关键安全实践。

规则概览

属性
规则 IDcalls-loop
严重级别Low
检测阶段late(语义分析后)
源码位置crates/lint/src/sol/low/calls_loop.rs
配套测试crates/lint/testdata/CallsLoop.sol

从源码注册处可以看到(crates/lint/src/sol/low/mod.rs),该规则通过register_lints!late阶段挂载,即它运行在 Solar 完成语义分析、类型解析之后,因此能够精确区分外部调用与内部调用:

calls_loop: (CallsLoop, late, (CALLS_LOOP));

检测范围:什么算"循环内的外部调用"

根据文档定义,以下交互出现在循环体(for/while/do-while)内时会被报告:

  • 高层级合约调用IReceiver(target).ping(...)receivers[i].ping(...)等;
  • 低级调用addr.call("")addr.delegatecall(...)addr.staticcall(...)
  • ETH 转账addr.send(...)addr.transfer(...)
  • 通过this的外部自调用this.someExternalFn(...)
  • 合约创建new Child()(包括带saltnew Child{salt: ...}())。

内部调用与super分发(如私有/内部库函数调用、super.foo())不属于外部调用,不会被报告。

源码中的分类逻辑

规则核心是classify函数(crates/lint/src/sol/low/calls_loop.rs),它将调用按被调方类型分为三类:

enum ExternalCall { Opaque, // 合约创建或 mutating 的 address 内建函数(send/transfer/call/delegatecall/creation) Static, // 地址上的 .staticcall Member(StateMutability), // 高层级外部调用或库调用,含外部函数指针 }

其判定顺序为:

  1. 优先检查内建函数:若被调方解析为AddressPayableSendAddressPayableTransfer,直接归类为Opaque(外部交互);
  2. 类型检查:取被调表达式的函数类型TyKind::Fn,按函数种类分类:
    • TyFnKind::External/TyFnKind::DelegateCallMember(含状态可变性信息);
    • TyFnKind::BareStaticCallStatic
    • TyFnKind::BareCall/TyFnKind::BareDelegateCall/TyFnKind::CreationOpaque
    • 其余(内部函数、super分发等)→ 不视为外部调用。

需要强调的是,库中的public函数在循环内被调用也会被报告:因为public库函数在外部调用语义上等价于一次外部交互(见测试中ReceiverLib.notify(receiver, i)的警告)。而internal库函数(如LocalLib.pingLocalLib.call)即使名字与低级调用相同,也因其被解析为内部调用而不会被报告——这正是"基于语义而非关键字匹配"的体现。

为什么这是坏味道:单点失败放大为全循环 DoS

文档明确指出其风险本质:循环中的外部调用会把一个 revert 或 gas 超额的被调方,放大成整个循环的拒绝服务(DoS)

以经典的 push-payment(推送式付款)模式为例:

contract Payouts { address payable[] recipients; function payAll() external payable { for (uint256 i; i < recipients.length; ++i) { recipients[i].transfer(1 ether); } } }

只要数组中有一个地址是合约且其receive函数耗尽 gas 或 revert,整笔payAll事务都会回滚——所有收款人全部失败。在极端场景下,攻击者可以刻意塞入一个恶意合约地址,使合法用户永远无法批量提款。

transfer固定 2300 gas 的限制虽然缓解了重入风险,但 2300 gas 对某些接收逻辑依然不够,且 EIP-1884 之后SLOAD成本上升进一步压缩了其可用空间,这让"循环内逐个transfer"变得既昂贵又脆弱。

推荐的修复模式:从 push 改为 pull

文档给出的替代方案是经典的拉取式付款(pull-payment):先记账,由每个收款人自行领取:

contract Payouts { mapping(address recipient => uint256 amount) public claimable; function claim() external { uint256 amount = claimable[msg.sender]; claimable[msg.sender] = 0; payable(msg.sender).transfer(amount); } }

这样做的收益:

  • 失败隔离:单个收款人 revert 只影响他自己,不影响他人;
  • 无循环交互:函数体内不再存在循环外部调用,calls-loop不再告警;
  • 天然抗 DoS:攻击者无法通过注入恶意地址阻塞所有人的提款。

如果你的业务确实无法完全避免循环外部调用,也应当至少:将循环次数设为可控上限、使用try/catch捕获单个失败、或先批量记录状态再统一结算,并配合下一节的配置把该警告升级为构建阻断。

记忆体分配不是外部调用(但长度计算是)

文档特别澄清了一个容易混淆的点:内存分配不是外部调用new LedgerRow[](n)new bytes(n)new string(n)等只是 EVM 内存操作,不会与任何外部合约交互——即使被分配的元素类型是合约(如new Child[](n))。

测试用例 crates/lint/testdata/CallsLoopAllocations.sol 完整覆盖了这一边界:allocateInLoop中循环内的各种new(数组、bytes、string、嵌套数组)均不产生calls-loop警告。

但文档补充了关键例外:用于计算分配长度的外部调用仍然会被报告。例如:

uint256[] memory numbers = new uint256[](source.nextLength()); // 此处 nextLength() 是外部调用

对应的.stderr期望输出精确标注了source.nextLength()为警告点(CallsLoopAllocations.stderr),而view性质的source.length()同样会被报告——staticcallview/pure高层调用仍属于外部调用,只是它们无法改变日志顺序或可观察状态(这正是is_state_mutating_external_call被单独抽出的原因,供reentrancy-events等共享分类器复用)。

另外值得注意:new Child()new Child{salt: bytes32(i)}()合约创建属于外部交互,在循环内会同时触发calls-loop警告。

检测器的"穿透"能力:修饰器与内部辅助函数

calls-loop并不是只在字面循环体内查找调用。它复用for_each_loop_item这个循环上下文遍历器(crates/lint/src/sol/low/payable_loop.rs),具备两项"穿透"能力:

  1. 穿透修饰器链:若修饰器内部包含循环(如modifier loopedPlaceholder() { for (...) { _; } }),则被该修饰器包裹的函数体中的外部调用也会被报告——因为_占位符会被替换为函数体继续遍历。测试中callsThroughLoopedModifier即验证了这一点。
  2. 内联内部辅助函数:循环内调用的internal辅助函数会被内联展开继续分析。测试中callsThroughInternalHelper_notify内部函数内调用receiver.ping)与callsThroughPublicHelper均被正确标记;即使是internal pure辅助函数内部调用外部pure函数(_pureNotifytarget_.purePing)也会被报告——纯函数调用依然是外部调用

遍历器通过维护loop_depth计数与递归栈(stack)防止无限递归,并以函数入口所在合约解析super目标(dispatch字段),保证多级继承下的判定正确。

循环内不同外部调用形态的判定速查

以下行为均来自测试文件 CallsLoop.sol 与其期望输出 CallsLoop.stderr,可作为日常开发对照表:

循环内写法是否报告说明
targets[i].call("")低级 call
recipients[i].transfer(1 wei)ETH 转账
receivers[i].ping(i)高层级外部调用
IReceiver(addr).ping(i)接口显式转换后调用
getReceiver().ping(i)/factory.getReceiver().ping(i)调用返回值链式调用
receiverByAddress[a].ping(i)/target.receiver.ping(i)映射/结构体字段取值后调用
try receivers[i].ping(i) ... catchtry/catch 包裹的外部调用
this.externalOnly(i)通过this的自调用(仍是外部消息调用)
callback(i)(external 函数指针)外部函数指针
ReceiverLib.notify(receiver, i)库的 public 函数(外部语义)
boxes[i].ping(i)(internal 库函数)内部库调用
receiver.remember(i)(internal pure 扩展)using for内部绑定
_local(i)(内部函数)内部调用
scratchTargets.push(...)/targetLists[k].push(...)数组内建操作
函数体内(无循环)的receiver.ping(0)无循环上下文

在 forge 中配置与使用

calls-loop默认启用并作为Low级别警告输出。Foundry 的 lint 体系还提供了配置入口(crates/config/src/lib.rs),常用做法包括:

  • 只运行指定规则:在测试文件头部加编译指令//@compile-flags: --only-lint calls-loop(测试用例即如此),便于单独聚焦验证;
  • 将警告升级为错误:在 foundry.toml 中设置deny = "warnings"(旧字段deny_warnings = true已废弃,配置层会自动归一化迁移),使任何calls-loop警告直接导致forge build/forge test失败;
  • 行内豁免:确认某个循环外部调用可接受时,使用// forge-lint: disable-next-line(calls-loop)精确豁免单行(测试的internalLibraryCallsAreIgnored中即展示了这一语法),而非全局关闭规则。

与其他 loop 系列规则的协同

calls-loop并非孤立存在,它属于 Foundry lint 中一整套"循环上下文"检测家族,共享同一个循环遍历基础设施:

  • delegatecall-loop:循环内delegatecall(更严重的代理攻击面);
  • msg-value-loop:循环内使用msg.value(每轮迭代语义错误);
  • costly-loop(crates/lint/src/sol/gas/costly_loop.rs):循环内存储写入的 gas 问题;
  • require-revert-in-loop:循环内require/revert(同样放大为 DoS);
  • reentrancy-events(crates/lint/src/sol/low/reentrancy_events.rs):与calls-loop共享is_state_mutating_external_call分类器,在CallsLoopAllocations.sol中可见两者对同一代码的联动输出。

实际审计中,"循环内外部调用 + 事件顺序 + 存储写入"往往是同一段危险代码的三个侧面,建议一并检查。

总结

calls-loop用一次语义级扫描,替你拦截了 Solidity 开发中最常见也最隐蔽的 DoS 隐患之一。它的价值在于:不是用关键字匹配碰运气,而是基于 Solar 的类型系统精确区分外部/内部调用,并能穿透修饰器与内部辅助函数,覆盖真实的调用形态(低级调用、函数指针、this自调用、库 public 调用、合约创建),同时正确豁免内存分配与内部调用。配合deny = "warnings"将其纳入 CI 阻断,可有效防止"推送式付款逐个转账"这类反模式进入生产合约。

【免费下载链接】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),仅供参考

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

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

立即咨询