WTF-Solidity 合约安全实战:S13 未检查的低级调用(Unchecked Low-Level Call)漏洞解析与修复

WTF-Solidity 合约安全实战:S13 未检查的低级调用(Unchecked Low-Level Call)漏洞解析与修复 WTF-Solidity 合约安全实战S13 未检查的低级调用Unchecked Low-Level Call漏洞解析与修复【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity本篇是 WTF-Solidity 合约安全系列的第十三讲聚焦以太坊低级调用call()、delegatecall()、staticcall()、send()在失败时只返回布尔值false、却不会回滚交易这一特性并完整演示它在银行合约中的真实利用路径与三种修复方案。读完本篇你将掌握低级调用返回值检查的底层原理、可复现的攻击合约与 Remix 演练步骤以及基于require检查、call()加重入保护、OpenZeppelinAddress库三种可靠的防御写法。低级调用失败不抛异常只返回falseSolidity 中的常规函数调用在发生异常时会沿调用链向上传播导致整个交易回滚。但以太坊的低级调用low-level call行为完全不同call()、delegatecall()、staticcall()和send()出现异常时不会把异常传递给上层也不会导致交易整体回滚只会返回一个布尔值false来传递失败信息。因此如果上层函数没有检查低级调用的返回值那么无论低级调用成功与否上层代码都会继续执行。这正是未检查的低级调用漏洞的根源。调用方式默认 gas失败行为调用语法send()2300返回false不抛异常addr.send(amount)transfer()2300抛出异常交易回滚addr.transfer(amount)call()可自定义转发全部 gas返回(bool, bytes)失败为falseaddr.call{value: amount}()其中最容易出错的是send()它硬性限制转发 gas 不得超过 2300否则调用失败。当目标地址的回调函数receive()/fallback()逻辑较复杂时消耗的 gas 会超过 2300导致send()失败。如果上层函数此时没有检查返回值交易会继续执行从而引发意想不到的结果。历史上2016 年的链游 King of Ether 正是因该漏洞导致退款无法正常发送。关于低级调用的更多细节call、delegatecall、staticcall的完整语义可延伸阅读仓库中的 20_SendETH、22_Call 与 23_Delegatecall 教程。漏洞合约UncheckedBank银行合约下面的漏洞合约是在 S01 重入攻击 教程银行合约的基础上修改而来完整代码见 UncheckedCall.sol。它包含 1 个状态变量balanceOf记录所有用户的以太坊余额和 3 个函数deposit()存款函数将ETH存入银行合约并更新用户余额withdraw()提款函数流程为查询余额 → 清零余额 → 转账。注意它没有检查send()的返回值——提款失败但余额会被清零getBalance()获取银行合约中的ETH余额。// SPDX-License-Identifier: MIT // by 0xAA pragma solidity ^0.8.34; contract UncheckedBank { mapping (address uint256) public balanceOf; // 余额mapping // 存入ether并更新余额 function deposit() external payable { balanceOf[msg.sender] msg.value; } // 提取msg.sender的全部ether function withdraw() external { // 获取余额 uint256 balance balanceOf[msg.sender]; require(balance 0, Insufficient balance); balanceOf[msg.sender] 0; // Unchecked low-level call bool success payable(msg.sender).send(balance); } // 获取银行合约的余额 function getBalance() external view returns (uint256) { return address(this).balance; } }问题出在withdraw()的第 56 行send()的返回值被赋值给了局部变量success但从未被使用。由于send()失败不抛异常withdraw()会看起来成功地结束——但用户的余额已经被提前清零钱却没有真正转到用户手里。攻击合约一个取款失败却余额清零的倒霉储户攻击合约刻画了一个无法接收ETH的储户它的回调函数receive()中直接revert()因此任何向其转账ETH的操作包括send()都会失败但它仍然可以正常调用bank.withdraw()来清空自己在银行中的余额记录。contract Attack { UncheckedBank public bank; // Bank合约地址 // 初始化Bank合约地址 constructor(UncheckedBank _bank) { bank _bank; } // 回调函数转账ETH时会失败 receive() external payable { revert(); } // 存款函数调用时 msg.value 设为存款数量 function deposit() external payable { bank.deposit{value: msg.value}(); } // 取款函数虽然调用成功但实际上取款失败 function withdraw() external payable { bank.withdraw(); } // 获取本合约的余额 function getBalance() external view returns (uint256) { return address(this).balance; } }攻击链路是攻击合约先通过deposit()正常存款 1 ETH银行记录balanceOf[Attack] 1 ETH再调用withdraw()触发银行提款。银行在执行payable(msg.sender).send(balance)时会调用攻击合约的receive()而receive()中revert()使转账失败send()返回false但银行合约并未检查该返回值于是余额被清零、交易照常完成——攻击合约丢掉了银行账面上的 1 ETH 存款银行却永远收不到这笔提款对应的真实转账在真实业务场景中这通常表现为用户资金被锁定或项目方向用户退款失败。Remix 复现步骤按照以下 5 步即可在 Remix IDE 中完整复现该漏洞部署UncheckedBank合约。部署Attack合约构造函数填入UncheckedBank合约地址。调用Attack合约的deposit()存款函数存入1 ETH。调用Attack合约的withdraw()提款函数调用显示成功不报错。分别调用UncheckedBank合约的balanceOf()与Attack合约的getBalance()尽管上一步调用成功且储户的银行余额被清零但攻击合约地址上实际并未收到ETH——提款实际上失败了。预防办法针对未检查低级调用漏洞有以下三种主流修复方案。1. 检查低级调用的返回值最直接的修复方式是对send()的返回值做require断言一旦转账失败整个提款交易立即回滚余额也不会被错误清零。将银行合约的withdraw()修正如下function withdraw() external { uint256 balance balanceOf[msg.sender]; require(balance 0, Insufficient balance); balanceOf[msg.sender] 0; bool success payable(msg.sender).send(balance); require(success, Failed Sending ETH!); }2. 使用call()并做好重入保护合约间转账ETH时优先使用call()而不是send()/transfer()因为call()会转发全部可用 gas可显式指定不会因为 2300 gas 上限导致意外失败但call()会完整触发目标合约的receive()/fallback()因此必须配合重入保护使用。推荐结合检查-影响-交互checks-effects-interactions模式先更新状态再交互或使用nonReentrant重入锁。这两类防护的完整代码示例见 S01_ReentrancyAttack/ReentrancyAttack.sol其中的GoodBank与ProtectedBank分别是两种写法的可运行版本。3. 使用 OpenZeppelinAddress库OpenZeppelin 的Address库封装了检查返回值的低级调用开发者无需手写require(success)逻辑。仓库内置的 lib/openzeppelin-contracts/contracts/utils/Address.sol 中sendValue()的实现可以印证其防御思路function sendValue(address payable recipient, uint256 amount) internal { if (address(this).balance amount) { revert Errors.InsufficientBalance(address(this).balance, amount); } if (LowLevelCall.callNoReturn(recipient, amount, )) { // call successful, nothing to do return; } else if (LowLevelCall.returnDataSize() 0) { LowLevelCall.bubbleRevert(); } else { revert Errors.FailedCall(); } }从源码可以看到sendValue()先用callNoReturn即底层call转账并检查其返回值失败时若携带 revert 原因则向上冒泡bubbleRevert()否则统一以FailedCall错误回滚从而保证转账失败必然回滚从根本上杜绝未检查低级调用的问题。这正是对检查返回值这一原则的工程化封装。总结这一讲介绍了未检查低级调用的漏洞及其预防方法。核心要点以太坊低级调用call、delegatecall、staticcall、send失败时返回布尔值false但不会导致整个交易回滚开发者一旦漏掉对其返回值的检查上层代码就会在调用失败后继续执行产生资金冻结、账目错乱等严重后果。防御手段包括逐处检查返回值、改用call()并配合重入防护、或直接复用 OpenZeppelinAddress库的封装实现。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考