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工作流中落地"循环内禁外部调用"这一关键安全实践。
规则概览
| 属性 | 值 |
|---|---|
| 规则 ID | calls-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()(包括带salt的new 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), // 高层级外部调用或库调用,含外部函数指针 }其判定顺序为:
- 优先检查内建函数:若被调方解析为
AddressPayableSend或AddressPayableTransfer,直接归类为Opaque(外部交互); - 类型检查:取被调表达式的函数类型
TyKind::Fn,按函数种类分类:TyFnKind::External/TyFnKind::DelegateCall→Member(含状态可变性信息);TyFnKind::BareStaticCall→Static;TyFnKind::BareCall/TyFnKind::BareDelegateCall/TyFnKind::Creation→Opaque;- 其余(内部函数、
super分发等)→ 不视为外部调用。
需要强调的是,库中的public函数在循环内被调用也会被报告:因为public库函数在外部调用语义上等价于一次外部交互(见测试中ReceiverLib.notify(receiver, i)的警告)。而internal库函数(如LocalLib.ping、LocalLib.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()同样会被报告——staticcall与view/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),具备两项"穿透"能力:
- 穿透修饰器链:若修饰器内部包含循环(如
modifier loopedPlaceholder() { for (...) { _; } }),则被该修饰器包裹的函数体中的外部调用也会被报告——因为_占位符会被替换为函数体继续遍历。测试中callsThroughLoopedModifier即验证了这一点。 - 内联内部辅助函数:循环内调用的
internal辅助函数会被内联展开继续分析。测试中callsThroughInternalHelper(_notify内部函数内调用receiver.ping)与callsThroughPublicHelper均被正确标记;即使是internal pure辅助函数内部调用外部pure函数(_pureNotify→target_.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) ... catch | ✅ | try/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),仅供参考