ARTICLE DETAIL

资讯详情

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

WTF Solidity 合约安全:S01 重入攻击(Reentrancy Attack)原理、复现与防御

WTF Solidity 合约安全:S01 重入攻击(Reentrancy Attack)原理、复现与防御 WTF Solidity 合约安全S01 重入攻击Reentrancy Attack原理、复现与防御【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity重入攻击Reentrancy Attack是智能合约世界中最经典、损失最惨重的漏洞类型它曾在 2016 年击穿 The DAO 合约并直接导致以太坊分叉为 ETH 与 ETC。本篇文章以 WTF-Solidity 仓库 S01_ReentrancyAttack 的教程为骨架结合仓库中的 ReentrancyAttack.sol 完整源码逐步拆解漏洞成因、攻击合约的构造逻辑、Remix 环境下的复现步骤以及检查-影响-交互CEI模式与重入锁Reentrancy Guard两种主流防御方案。读完本文你将能够独立识别合约中的重入风险点并写出可防御此类攻击的 Solidity 代码。一、什么是重入攻击重入攻击是智能合约中最常见的一类攻击方式攻击者利用合约中的漏洞典型如fallback/receive回调函数在外部调用尚未结束时再次进入同一合约循环执行提款或铸币逻辑从而把合约中的资产搬空或铸造出大量代币。其根本原因在于当合约通过call向外部地址转账 ETH 时如果接收方是合约对方的fallback()或receive()会被同步触发。若回调中再次调用原合约的关键函数而原合约尚未更新内部状态如余额就会形成转账 → 回调 → 再转账 → 再回调的无限循环。历史重大重入攻击事件重入攻击并非理论威胁而是反复造成千万美元级损失的实战漏洞2016 年The DAO 合约遭重入攻击黑客盗走合约中 3,600,000 枚ETH最终导致以太坊分叉为ETH链与ETC以太经典链。2019 年合成资产平台 Synthetix 遭重入攻击被盗 3,700,000 枚sETH。2020 年借贷平台 Lendf.me 遭重入攻击损失约 $25,000,000。2021 年借贷平台 CREAM FINANCE 遭重入攻击损失约 $18,800,000。2022 年算法稳定币项目 Fei 遭重入攻击损失约 $80,000,000。距离 The DAO 事件已过去数年但每年仍会出现因重入漏洞损失千万美元级资金的项目因此深入理解该漏洞对合约开发者至关重要。二、黑客 0xAA 抢银行的故事为便于理解教程中用黑客 0xAA 抢银行的寓言来具象化重入攻击的全过程以太坊银行的柜员是机器人Robot由智能合约控制。正常用户User取钱时服务流程为查询用户的ETH余额若大于 0 则进行下一步将用户的ETH余额从银行转给用户并询问用户是否收到将用户名下的余额更新为 0。某天黑客 0xAA 走进银行与机器人柜员展开如下对话0xAA我要取钱1 ETH。Robot正在查询您的余额1 ETH。正在转账1 ETH到您的账户。您收到钱了吗0xAA等等我要取钱1 ETH。Robot正在查询您的余额1 ETH。正在转账1 ETH到您的账户。您收到钱了吗0xAA等等我要取钱1 ETH。Robot再次查询、转账、询问……0xAA等等我要取钱1 ETH。……关键点在于银行在转账与更新余额之间没有完成状态更新黑客抓住是否收到钱的询问时机不断要求再次取款最终把银行资产搬空。仓库中的示意图 S01-1.png 用左右对比方式直观展示了普通交互与重入攻击两种流程的差异。三、漏洞合约示例银行合约与攻击合约仓库 S01_ReentrancyAttack/ReentrancyAttack.sol 完整实现了漏洞合约、攻击合约及两类防御方案。下面逐一拆解。3.1 银行合约存在漏洞银行合约非常简单包含 1 个状态变量balanceOf记录所有用户的 ETH 余额以及 3 个函数deposit()存款函数将ETH存入银行合约并更新用户余额withdraw()提款函数将调用者的余额转给它。其执行步骤与故事一致——查询余额 → 转账 → 更新余额。注意这个函数有重入漏洞getBalance()获取银行合约里的ETH余额。contract Bank { 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); // 转账 ether !!! 可能激活恶意合约的fallback/receive函数有重入风险 (bool success, ) msg.sender.call{value: balance}(); require(success, Failed to send Ether); // 更新余额 balanceOf[msg.sender] 0; } // 获取银行合约的余额 function getBalance() external view returns (uint256) { return address(this).balance; } }3.2 漏洞根因call转账触发回调重入攻击的攻击点正是合约转账ETH的地方(bool success, ) msg.sender.call{value: balance}();call是最底层的转账方式不会限制调用者的gas。如果转账目标地址是合约会同步触发对方的fallback或receive函数。若对该机制不熟悉可先阅读仓库中 19_Fallback 关于接收 ETH 与回退函数的讲解。由于Bank合约在withdraw()中是先转账、后更新余额恶意合约的回调就有机会在余额清零之前再次调用withdraw()形成循环提款。3.3 攻击合约攻击合约的逻辑非常简单通过receive()回调函数循环调用Bank合约的withdraw()函数。它包含 1 个状态变量bank记录Bank合约地址以及 4 个函数构造函数初始化Bank合约地址receive()回调函数在接收ETH时被触发并再次调用Bank合约的withdraw()函数循环提款attack()攻击函数先调用Bank合约的deposit()存款再调用withdraw()发起第一次提款此后Bank.withdraw()与Attack.receive()会循环互调直到把Bank合约的ETH提空getBalance()获取攻击合约里的ETH余额。contract Attack { Bank public bank; // Bank合约地址 // 初始化Bank合约地址 constructor(Bank _bank) { bank _bank; } // 回调函数用于重入攻击Bank合约反复的调用目标的withdraw函数 receive() external payable { if (bank.getBalance() 1 ether) { bank.withdraw(); } } // 攻击函数调用时 msg.value 设为 1 ether function attack() external payable { require(msg.value 1 ether, Require 1 Ether to attack); bank.deposit{value: 1 ether}(); bank.withdraw(); } // 获取本合约的余额 function getBalance() external view returns (uint256) { return address(this).balance; } }攻击时序可以总结为attack()先向Bank存入1 ETH使balanceOf[攻击者] 1 ETH紧接着调用withdraw()Bank查询余额为1 ETH开始向攻击合约转账转账触发Attack.receive()回调中判断Bank合约仍有足够余额后再次调用withdraw()此时Bank尚未执行balanceOf[msg.sender] 0攻击者的余额仍是1 ETH于是再次通过校验并再次转账循环往复直到Bank合约余额不足receive()中的if (bank.getBalance() 1 ether)条件终止循环。注意Attack合约中bank是public状态变量会隐式生成同名 getter因而attack()中可直接使用bank.deposit/bank.withdraw调用receive()中bank.getBalance()也可等价地写成address(bank).balance。四、Remix 复现演示在 Remix IDE 中可完整复现上述攻击具体步骤如下部署Bank合约调用deposit()函数转入20 ETH切换到攻击者钱包部署Attack合约构造函数传入Bank合约地址调用Attack合约的attack()函数发动攻击调用时需附带转账1 ETH调用Bank合约的getBalance()函数发现余额已被提空变为 0调用Attack合约的getBalance()函数可以看到余额变为21 ETH原 20 ETH 攻击者投入的 1 ETH重入攻击成功。不止 ETH 转账更广泛的触发面需要强调的是重入攻击并不局限于ETH转账ERC721与ERC1155的safeTransfer()/safeTransferFrom()安全转账函数会回调接收方合约的onERC721Received/onERC1155Received接口ERC777的tokensReceived回调函数同样可能被利用。因此重入本质上是一个宏观的设计问题先交互、后更新状态而不仅限于 ETH 转账本身。五、防御方案一检查-影响-交互模式Checks-Effects-Interactions检查-影响-交互模式强调编写函数时的顺序纪律先检查状态变量是否符合要求紧接着更新状态变量例如余额最后再与其他合约交互。若将Bank合约withdraw()中的余额更新提前到转账ETH之前即可修复漏洞function withdraw() external { uint256 balance balanceOf[msg.sender]; require(balance 0, Insufficient balance); // 检查-效果-交互模式checks-effect-interaction先更新余额变化再发送ETH // 重入攻击的时候balanceOf[msg.sender]已经被更新为0了不能通过上面的检查。 balanceOf[msg.sender] 0; (bool success, ) msg.sender.call{value: balance}(); require(success, Failed to send Ether); }修复后的执行顺序变为校验余额 → 余额清零 → 转账。当攻击合约的回调再次调用withdraw()时require(balance 0)会因余额已为 0 而直接失败循环被切断。仓库中 ReentrancyAttack.sol 的GoodBank合约即为此方案的完整实现。六、防御方案二重入锁Reentrancy Guard重入锁是一种用于防止重入调用的修饰器modifier它包含一个默认为0的状态变量_status。被nonReentrant修饰的函数在第一次调用时会检查_status是否为0紧接着将_status改为1调用结束后再恢复为0。这样当攻击合约在调用结束前发起第二次调用时require(_status 0)会直接报错重入攻击失败。如果对修饰器不熟悉可阅读仓库中 11_Modifier 的讲解。uint256 private _status; // 重入锁 // 重入锁 modifier nonReentrant() { // 在第一次调用 nonReentrant 时_status 将是 0 require(_status 0, ReentrancyGuard: reentrant call); // 在此之后对 nonReentrant 的任何调用都将失败 _status 1; _; // 调用结束将 _status 恢复为0 _status 0; }只需用nonReentrant重入锁修饰withdraw()函数即可预防重入攻击// 用重入锁保护有漏洞的函数 function withdraw() external nonReentrant{ uint256 balance balanceOf[msg.sender]; require(balance 0, Insufficient balance); (bool success, ) msg.sender.call{value: balance}(); require(success, Failed to send Ether); balanceOf[msg.sender] 0; }仓库中 ReentrancyAttack.sol 的ProtectedBank合约即为此方案的完整实现。从源码看 OpenZeppelin 的工程化实现仓库依赖的 OpenZeppelin 库提供了工业级实现路径为 lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.solv5.5.0。与教程中的教学简化版相比它做了几处关键强化使用NOT_ENTERED 1与ENTERED 2两个常量代替0/1初始状态为1进入保护区域置为2退出恢复1。这样可避免一些利用初始值即锁定位的边界绕过通过固定哈希槽位REENTRANCY_GUARD_STORAGE定位存储避免与代理合约升级场景下的存储布局冲突在nonReentrant()修饰器中调用_nonReentrantBefore()与_nonReentrantAfter()进入时校验并置位、退出时复位_nonReentrantAfter()的写回还会因 EIP-2200 触发 gas 退款额外提供只读的nonReentrantView()修饰器用于拦截在重入过程中读取不一致状态的 view 函数声明自定义错误ReentrancyGuardReentrantCall()替代字符串 revert降低 gas 开销。OpenZeppelin 官方还提示如果部署链支持 EIP-1153 瞬态存储transient storage可考虑使用ReentrancyGuardTransient变体同时注意同一合约中被nonReentrant修饰的函数之间不可互相调用会误报重入需要拆分private内部实现与external入口来规避。七、防御方案三拉取支付模式PullPayment除上述两种方案外OpenZeppelin 还提倡遵循**拉取支付PullPayment**模式来从设计上规避重入风险。其原理是引入第三方托管escrow将原先的主动转账分解为转账者发起转账加上接受者主动拉取两个阶段当想要发起一笔转账时通过_asyncTransfer(address dest, uint256 amount)将待转账金额存储到第三方合约中从而避免因重入导致的自身资产损失当接受者想要接收转账时需要主动调用withdrawPayments(address payable payee)进行资产的主动获取。由于接收方只有在主动调用withdrawPayments时才真正收到资产而该调用本身发生在状态更新之后或由外部交易触发重入窗口被大幅压缩。这种拉取而非推送的思想也是许多安全代币、保险库与支付分配合约的通用设计范式仓库中 42_PaymentSplit 等主题也体现了类似的资金管理模式。八、总结与实战建议本讲介绍了以太坊上最常见的一类攻击——重入攻击并用0xAA 抢银行的故事直观呈现了漏洞成因合约在转账与状态更新之间存在时间差攻击者通过receive()/fallback()回调循环调用提款逻辑最终搬空资产。我们随后给出了两种主流防御方案检查-影响-交互模式CEI先校验、再更新状态、最后外部交互从函数写法上杜绝漏洞重入锁Reentrancy Guard用nonReentrant修饰器保证同一时刻函数只能进入一次实现简单、覆盖面广OpenZeppelin 的工业级实现还兼顾了存储布局、gas 优化与瞬态存储等工程细节。另外需要注意ERC721/ERC1155的safeTransfer系列函数以及ERC777的回调机制都可能成为重入载体防御时应从宏观设计层面审视状态更新的时机。给新手的建议用重入锁保护所有可能改变合约状态的external函数虽然会消耗更多gas但相比可能造成的资产损失这笔开销是完全值得的。同时将 CEI 模式作为编写函数的默认纪律双管齐下才能最大限度降低重入风险。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表