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

发布时间:2026/9/15 12:52:32

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),仅供参考
延伸阅读

更多相关文章

2026/9/15 12:52:32

CANalyzer入门:汽车总线分析与报文抓取实战指南

做汽车电子测试的人第一次打开 CANalyzer,大概率都是同一种反应:界面上密密麻麻全是窗口,左边是工程面板,中间是 Measurement Setup,底下还挂着一排 Trace、Graphics、Statistics,第一次想找“开始测量”那…

2026/9/15 12:52:32

语言模型能自己决定该看哪里吗?

你有没有想过一件事:一个能读懂百万字小说的AI,回答"男主角小时候养的那只猫叫什么名字"这种问题时,其实是把整本书从头到尾又"扫"了一遍才找到答案的?这听起来有点荒谬。你自己回忆一件事的时候,…

2026/9/15 12:52:32

LabVIEW for STM32:面向ARM Cortex-M的图形化裸机开发范式

1. 这不是“LabVIEW STM32”简单拼凑,而是工程思维的重构LabVIEW做上位机控制界面、LabVIEW串口通信、LabVIEW下载——这些词在搜索框里高频出现,但它们指向的只是LabVIEW在嵌入式系统里的“外围角色”。真正值得深挖的,是标题里那个被很多人…

2026/9/15 13:07:34

Typecho+宝塔部署实战:轻量博客的稳定架构与生产级配置

1. 为什么Typecho配宝塔,是中小站点最稳的“开箱即用”组合我最早接触Typecho是在2015年,那会儿还在用纯命令行搭LNMP:手写nginx配置、手动编译PHP扩展、改php.ini调upload_max_filesize——一套流程跑下来,光环境就折腾掉大半天。…

2026/9/15 13:07:34

2026年AI写作工具TOP5评测与选择指南

1. 为什么我们需要AI写作工具榜单?三年前我刚开始接触AI写作时,面对市面上几十种工具完全无从下手。花了两周时间测试了15款工具,浪费了2000多元订阅费后才发现,真正好用的就那么3-5款。现在每个月都有新工具问世,普通…

2026/9/15 13:07:34

Unity ScrollRect实现3D轮播缩放效果:距离映射与通用组件

做Unity客户端开发的朋友,应该都遇到过这种需求:一个横向滚动的列表,希望滑动停稳后,正中间那个Item能自动放大突出显示,旁边的逐渐缩小,形成一种“3D轮播”的视觉层次感。最常见的就是商店的皮肤展示、关卡…

2026/9/15 13:07:34

AstronRPA:AI Agent原生集成的企业级智能自动化系统

1. 项目概述:这不是又一个“点点点”RPA,而是一套能自己思考、拆解、试错的企业级自动化操作系统你有没有遇到过这样的场景:财务同事每天花两小时把几十张PDF发票里的金额、税号、开票日期手动抄进Excel;运营同学反复登录五个不同…

2026/9/15 4:54:30

拯救者Y7000黑屏故障排查与维修实战指南

1. 项目概述:一台黑屏的拯救者Y7000,到底卡在哪一步? 联想拯救者Y7000系列笔记本,从2018年第一代搭载i5-8300H开始,到后来的i7-9750H、i7-10750H、i5-11400H,再到2023年款的R7-7840HS,它始终是学…

2026/9/15 0:01:16

AI英语单词APP开发:自适应学习算法与移动端优化实践

1. 项目概述 作为一名在移动应用开发领域摸爬滚打多年的老手,我最近完成了一个AI英语单词APP的开发项目。这个项目将传统单词记忆方法与现代AI技术相结合,打造了一款能够智能适应不同用户学习习惯的英语学习工具。 市面上大多数单词APP都存在一个通病&a…

2026/9/15 0:01:16

Flutter与OpenHarmony结合开发手语学习APP实战

1. 项目背景与核心价值作为一名同时接触过Flutter和OpenHarmony的开发者,最近我完成了一个基于Flutter for OpenHarmony的手语学习APP实战项目。这个项目最大的特点在于实现了跨平台框架与国产操作系统深度结合的创新实践——用Flutter开发的应用能完美运行在OpenHa…

2026/9/15 0:01:16

六个月成为机器人工程师:从ROS2到SLAM的实战路径

1. 六个月的紧迫感从哪来:先搞清楚你要成为哪种机器人工程师说实话,六个月的期限并不是一个宽松的时间线。市面上任何一本正经的机器人学教材都超过五百页,ROS2的官方文档可以翻到你怀疑人生,再加上ABB、KUKA这些工业机器人厂家动…

2026/9/14 11:59:31

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/14 13:53:59

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/15 11:42:23

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码