ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

Foundry 静态检查详解:calls-loop 规则如何揪出循环中的外部调用 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()包括带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→StaticTyFnKind::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()✅低级 callrecipients[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-loopcrates/lint/src/sol/gas/costly_loop.rs循环内存储写入的 gas 问题require-revert-in-loop循环内require/revert同样放大为 DoSreentrancy-eventscrates/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),仅供参考
返回列表