Back to blogs
Written by
Hans
Published on
April 9, 2025

Solodit Checklist Explained (4): Front-Running Attacks

Learn how front-running attacks exploit blockchain transparency and how to defend your smart contracts with secure patterns and best practices.

Table of Contents

In the last edition of the "Solodit Checklist Explained" series, we explored Donation Attacks. We saw how seemingly innocent token transfers can be weaponized against your smart contracts.

Today, we're shifting gears to tackle a different, equally insidious vulnerability: Front-Running Attacks.

To illustrate, imagine you're at a busy farmers market where prices change based on demand. You spot an amazing deal on rare truffles for $50 that you know are worth $100 elsewhere. As you walk up to buy, a market insider who can see all incoming customer orders notices your intention. They quickly jump ahead of you in line, buy the truffles for $50, and then immediately offer to sell them to you for $90. You still get the truffles, but now this middleman has pocketed $40 of value that would have been yours.

That's front-running in a nutshell - seeing someone else's pending transaction in a public system and then inserting your own transaction ahead of it to profit from the price change you know is coming.

In blockchain, this happens when attackers exploit the transparent nature of the mempool (where pending transactions wait to be mined, like packages waiting for delivery) to see the upcoming transactions. They then craft their own transactions with higher gas fees, ensuring theirs are executed first. This can lead to devastating consequences, from manipulated prices in decentralized exchanges (DEX) to stolen non-fungible tokens (NFTs).

In this article, we'll be dissecting four crucial checklist items related to front-running, giving you the knowledge and skills to write more secure and robust code. 

Here's what we'll be covering:

  • SOL-AM-FrA-1: 'Get-or-Create' Patterns: Are your 'get-or-create' patterns protected against front-running attacks?
  • SOL-AM-FrA-2: Two-Transaction Actions: Are two-transaction actions designed to be safe from frontrunning?
  • SOL-AM-FrA-3: Dust Transactions: Can users maliciously cause others' transactions to revert by preempting with dust?
  • SOL-AM-FrA-4: Commit-Reveal Schemes: Is the protocol using a properly user-bound commit-reveal scheme?

For the best experience, open a tab with the Solodit checklist to refer to it.

Understanding front-running attacks: definitions

Before we dive into the checklist itself, let's start with some essential definitions.

  • Front-running attack: A malicious actor observes a pending transaction in the mempool. To exploit the information revealed in that transaction, they quickly execute their own transaction ahead of it. Typically, they do this by offering a higher gas price to ensure their transaction is processed first.

  • Get-or-create pattern: A smart contract design pattern that involves either retrieving an existing resource (like an existing trading pair on a DEX) or creating a new one if it doesn't exist. This pattern can be vulnerable to front-running if not properly protected.

  • Two-transaction actions front-running: This vulnerability arises when a critical action in a smart contract is split into two or more separate transactions. An attacker can exploit the time gap between these transactions to insert their own operation, potentially disrupting the intended flow or stealing assets.

  • Dusting attack (reversion via dust): A specific form of front-running where an attacker sends a transaction with a negligible amount of value ("dust") before a legitimate transaction. This causes the legitimate transaction to fail (revert) due to a modified state or unmet preconditions.

  • Commit-reveal scheme: A cryptographic protocol designed to ensure fairness and secrecy in situations where participants must submit information without immediately disclosing it. It consists of two distinct phases: the commit phase, where participants submit a cryptographic commitment (typically a hash of their intended action combined with a random secret, or nonce), and the reveal phase, where they disclose the original action and secret after a designated period. This process ensures that no participant can alter their submission or react to others’ actions until all commitments are finalized.  Some versions of the protocol ensure that only the person who made the commitment can reveal it later. This prevents others from interfering and keeps participants accountable.

SOL-AM-FrA-1: Are 'get-or-create' patterns protected against front-running attacks?

Let's examine a scenario involving a factory contract (ExploitableContract) designed to manage VulnerablePool instances. The intended use is for a user (identified by _poolCreator) to call getOrCreatePool specifying an _initialPrice. If a pool for that creator doesn't exist, the contract creates it. Otherwise, it returns the existing pool. This 'get-or-create' pattern is a common design choice in smart contracts. Imagine a scenario where a pool needs to be created for a swap, and someone wants to add liquidity.

The process could look like this:

  • Check if the pool already exists.
  • If the pool exists, proceed to interact (e.g., add liquidity).
  • If the pool does not exist, create it with the desired parameters and then interact.

And this is where the trouble begins! When implemented naively in a single transaction, the "get-or-create" pattern can be vulnerable to front-running. An attacker can monitor the mempool for transactions attempting to create a specific resource (like our VulnerablePool linked to a _poolCreator address). 

Seeing the victim's intended parameters, the attacker can quickly submit their own transaction invoking the same function (getOrCreatePool) but with different, malicious parameters (e.g., a manipulated _initialPrice). By paying a higher gas fee, the attacker ensures their transaction executes first. This creates the resource under the victim's identifier but with the attacker's parameters, hijacking the creation step.

When the victim's original transaction finally executes, the check (address(pools[_poolCreator]) == address(0)) finds that the resource already exists. The creation step is skipped, and the function returns the attacker-created resource with manipulated parameters. The unaware victim then interacts with this compromised resource. 

It's like reserving a specific meeting room, only for someone to sneak in before you and replace the projector with a faulty one. You end up using the room but with manipulated equipment.

Here’s the pattern in code:

pragma solidity ^0.8.0;


import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";


contract VulnerablePool {
    address public poolCreator;
    uint256 public initialPrice;


    constructor(address _poolCreator, uint256 _initialPrice) {
        poolCreator = _poolCreator;
        initialPrice = _initialPrice;
    }
}


contract ExploitableContract {
    mapping(address => VulnerablePool) public pools;


    function getOrCreatePool(address _poolCreator, uint256 _initialPrice)
      public returns (VulnerablePool)
    {
        if (address(pools[_poolCreator]) == address(0)) {
            // An attacker can front-run this transaction with different initialPrice
            pools[_poolCreator] = new VulnerablePool(_poolCreator, _initialPrice);
        }
        return pools[_poolCreator];
    }


    function viewPoolInitialPrice(address _poolCreator) public view returns (uint256) {
       if(address(pools[_poolCreator]) != address(0)) {
        return pools[_poolCreator].initialPrice();
       } else {
        return 0;
       }
    }
}


Below is a test case that demonstrates this vulnerability. The test simulates the front-running attack by creating a pool with an initial price of 50, while the victim intended to create it with an initial price of 100. The attacker successfully front-runs the victim's transaction, and the victim ends up interacting with the attacker's pool instead of their own.

function testFrontRunning() public {
        uint256 intendedPrice = 100;
        uint256 attackPrice = 50;


        // 1. Victim intends to create a pool with initialPrice = intendedPrice
        vm.startPrank(victim);
        // exploitableContract.getOrCreatePool(victim, intendedPrice); //Simulate that the victim is about to call this.


        // 2. Attacker observes this transaction and front-runs it with attackPrice
        vm.stopPrank();
        vm.startPrank(attacker);
        exploitableContract.getOrCreatePool(victim, attackPrice);
        vm.stopPrank();


        // 3. Victim's transaction now executes
        vm.startPrank(victim);
        exploitableContract.getOrCreatePool(victim, intendedPrice); // This call should not change the price because the pool already exists


        // 4. Assert that the pool's initialPrice is now the attacker's price, NOT the victim's intended price
        assertEq(exploitableContract.viewPoolInitialPrice(victim), attackPrice, "The pool's initial price should be the attacker's price.");
        vm.stopPrank();
    }

How can we avoid this vulnerability?

  1. Separate creation and interaction: Split the creation of the resource and interaction with the resource into two distinct transactions.
  2. Parameter validation: Check the parameters of the target resource and revert if they aren't right.

SOL-AM-FrA-2: "Are two-transaction actions designed to be safe from frontrunning?"

This checklist item highlights a common vulnerability in multi-step processes.

In many smart contracts, certain actions require multiple transactions to complete. For example, a user might first approve a contract to spend their tokens and then call a function to execute the transaction. This two-step process can be vulnerable to front-running attacks if not designed carefully.

The NFTRefinanceMarket contract in the example below is designed to act as a marketplace or custodian where NFT owners can deposit their NFTs, perhaps as collateral for a loan or participation in another decentralized finance (DeFi) activity. The intended workflow leverages the standard ERC-721 approval mechanism and requires two distinct transactions from the NFT owner:

  1. Approval transaction: The NFT owner first calls the approve() function on the NFT contract itself, granting permission to the NFTRefinanceMarket contract address to manage a specific tokenId.

  2. Refinance transaction: The NFT owner then calls the refinance() function on the NFTRefinanceMarket contract, specifying the same tokenId. This function is intended to:

    • Verify that the necessary approval from step 1 exists.
    • Transfer the NFT from the owner into the custody of the NFTRefinanceMarket contract.
    • Record the address that called refinance() (presumed to be the original owner) in the tokenCreditors mapping, associating them with the deposited NFT.

Essentially, it's meant to be a secure two-step process for an owner to lock their NFT into the market contract and have their ownership/creditorship tracked by that contract. Here’s how it looks:

pragma solidity ^0.8.0;


import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";


interface INFT is IERC721 {
    function mint(address to, uint256 tokenId) external;
}


contract NFTRefinanceMarket is Ownable {
    INFT public nft;
    mapping(uint256 => address) public tokenCreditors;


    constructor(INFT _nft) Ownable(msg.sender) {
        nft = _nft;
    }


    function refinance(uint256 _tokenId) external {
        // Check if this contract has approval to transfer the NFT
        require(nft.getApproved(_tokenId) == address(this), "Not approved");
        address originalOwner = nft.ownerOf(_tokenId);


        // Pull the NFT into this contract as collateral
        nft.transferFrom(originalOwner, address(this), _tokenId);


        // Record the caller as the creditor for this NFT
        tokenCreditors[_tokenId] = msg.sender;
    }
}


However, an attacker can front-run the transaction, effectively stealing the NFT, as shown below:

function testFrontRunRefinance() public {
        // 1. User approves the contract to refinance their NFT
        assertEq(nft.ownerOf(tokenId), user);
        vm.startPrank(user);
        nft.approve(address(nftRefinanceMarket), tokenId);
        assertEq(nft.getApproved(tokenId), address(nftRefinanceMarket));
        vm.stopPrank();


        // 2. Attacker monitors the mempool, and before the user calls refinance, the attacker calls refinance
        vm.startPrank(attacker);


        // Simulate the attacker front-running the transaction
        // In a real scenario, the attacker would increase the gas price to get their transaction included first
        nftRefinanceMarket.refinance(tokenId);


        // Verify that the attacker is now marked as the creditor for this NFT
        assertEq(nftRefinanceMarket.tokenCreditors(tokenId), attacker);


        // Verify that the NFT now belongs to the contract
        assertEq(nft.ownerOf(tokenId), address(nftRefinanceMarket));
        vm.stopPrank();


        // 3. User tries to refinance the NFT, but it has already been refinanced by the attacker
        vm.startPrank(user);
        vm.expectRevert("Not approved");
        nftRefinanceMarket.refinance(tokenId);
        vm.stopPrank();
    }


This type of front-running attack happens because there is a gap between the two transactions, where the attacker can intercede between these calls.

So, how can we avoid it?

Implement checks to ensure the first transaction belongs to the same user
who is calling the second transaction. For example, require that the msg.sender of the refinance function is the same address that approved the contract in the first place.

SOL-AM-FrA-3: "Can users maliciously cause others' transactions to revert by preempting with dust?"

Dust attacks involve attackers front-running legitimate transactions with a tiny amount of tokens, potentially causing the original transaction to fail. By changing the on-chain state, the attacker's transaction invalidates the assumptions of the victim's transaction, ultimately causing it to fail. It's like subtly shifting a puzzle piece just enough to prevent someone from completing the picture.

Here's a simplified scenario:

// Contract with a function that requires zero balance
contract Auction {
    IERC20 public token;
    event AuctionStarted(uint256 id);


    constructor(address _token) {
        token = IERC20(_token);
    }


    // Function that requires zero balance to execute
    function startAuction() external returns (uint256) {
        // Vulnerable check that can be exploited
        require(token.balanceOf(address(this)) == 0, "Balance must be zero");


        // Start auction logic would go here
        uint256 id = 1;
        emit AuctionStarted(id);
        return id;
    }
}


The vulnerability in this Auction contract is that an attacker can prevent anyone from starting an auction through a dust attack.

If Alice wants to start an auction and submits a transaction calling startAuction(), an attacker Bob can front-run her transaction by sending a minimal amount of tokens (dust) to the contract address. When Alice's transaction is processed, the check require(token.balanceOf(address(this)) == 0, "Balance must be zero") will fail because the contract's balance is no longer zero, causing her transaction to revert.

To protect against dust attacks, we can implement tolerance thresholds rather than exact balance checks and use access controls to limit who can trigger sensitive functions.

SOL-AM-FrA-4: "Is the protocol using a properly user-bound commit-reveal scheme?"

Commit-reveal schemes aim to protect sensitive on-chain actions, like bids in an auction, from information leakage and front-running during the transaction confirmation process. In a typical scheme, users first submit a commitment (a cryptographic hash of their intended action and a secret salt) to the contract. After a designated commit period ends, a reveal period begins, during which users submit their original action and salt. The contract verifies that the hash of the submitted action and salt matches the previously stored commitment. It's like sealing your bid in an envelope, submitting it, and only opening it once all bids are in.

However, the scheme could be vulnerable if the commitment itself isn't uniquely tied to the user submitting it. A poorly designed scheme might allow an attacker who observes a user's commitment transaction (e.g., in the mempool) to replicate that commitment or interfere with the reveal process. This specific vulnerability focuses on how the lack of user identity within the commitment hash allows an attacker to disrupt the auction. It's like someone perfectly copying your sealed envelope's contents and submitting their identical copy under their name.

Let's look at an example:

pragma solidity ^0.8.0;


import "forge-std/Test.sol";


/**
 * Overview:
 * Checklist Item ID: SOL-AM-FrA-4
 *
 * This test demonstrates a front-running vulnerability in a commit-reveal auction contract.
 * The vulnerability is that the protocol doesn't include the committer's address in the commitment,
 * allowing anyone to reveal another user's commitment and claim the reward.
 */


contract Auction {
    mapping(address => bytes32) public commitments;
    address public winner;
    uint256 public winningBid;
    bool public revealed;
    uint256 public endTime;


    constructor(uint256 _duration) {
        endTime = block.timestamp + _duration;
    }


    // Users commit their bids with a salt
    function commit(bytes32 commitment) public {
        require(block.timestamp < endTime, "Auction ended");
        commitments[msg.sender] = commitment;
    }


    // Vulnerable reveal function - doesn't include committer address in the commitment
    function reveal(uint256 bid, bytes32 salt) public {
        require(block.timestamp > endTime, "Reveal time not reached");
        require(!revealed, "Already revealed");


        // Vulnerability: commitment doesn't include the committer's address
        // This allows anyone to create the same commitment with the same bid and salt
        bytes32 expectedCommitment = keccak256(abi.encode(bid, salt));


        // The attacker can commit the same value and then reveal it
        require(commitments[msg.sender] == expectedCommitment, "Invalid commitment");


        // The revealer becomes the winner
        if (bid > winningBid) {
            winningBid = bid;
            winner = msg.sender;
        }
        revealed = true;
    }


    function claimReward() public view returns (address) {
        require(block.timestamp > endTime && revealed, "Auction not ended or not revealed");
        return winner;
    }
}


Here's the breakdown of the vulnerability demonstrated by the code:

  • The commitment hash (keccak256(abi.encode(bid, salt))) is calculated without including the committer's address (msg.sender).

  • Because the committer's address isn't part of the hash, anyone who discovers the bid and salt used by an honest bidder can compute the exact same commitment hash.

  • The attacker observes the honest bidder's transaction and calls commit() with the identical hash. Now, both the honest bidder's address and the attacker's address map to the same bytes32 value in the commitments mapping.

  • The reveal() function checks commitments[msg.sender] == expectedCommitment. This means it verifies if the caller of reveal (msg.sender) previously committed the hash matching the provided bid and salt, like this:
contract AuctionTest is Test {
    Auction public auction;
    address public bidder1;
    address public bidder2;
    uint256 public auctionDuration = 1 days;


    function setUp() public {
        bidder1 = address(1);
        bidder2 = address(2);
        auction = new Auction(auctionDuration);
        vm.warp(block.timestamp + 1 minutes);
    }


    function testFrontRunningReveal() public {
        uint256 bid1 = 1 ether;
        bytes32 salt1 = keccak256("secret1");


        // Both bidders create the same commitment with the same bid and salt
        // This is possible because the commitment doesn't include the committer's address
        bytes32 commitment = keccak256(abi.encode(bid1, salt1));


        // Bidder1 commits first
        vm.prank(bidder1);
        auction.commit(commitment);


        // Attacker sees the bid and salt values (e.g., from mempool or other side channel)
        // and commits the same values
        vm.prank(bidder2);
        auction.commit(commitment);


        vm.warp(block.timestamp + auctionDuration);


        // Attacker front-runs by revealing first
        vm.prank(bidder2);
        auction.reveal(bid1, salt1);


        // Attacker becomes the winner by front-running
        assertEq(auction.winner(), bidder2);
        assertEq(auction.winningBid(), bid1);
    }
}


Since the attacker did commit the identical hash, they can successfully call the reveal(bid, salt). If they do this before the honest bidder (e.g., by paying a higher gas fee to front-run), the attacker passes the check commitments[attacker] == expectedCommitment. The attacker's address (msg.sender within that reveal call) is then set as the winner.

How do we ensure the commitment is uniquely bound to the committer?

  • Include the committer's address within the data being hashed when the commitment is created (typically off-chain by the user). The commitment should be calculated like this: commitment = keccak256(abi.encode(msg.sender, bid, salt)) (there are other possible additional fields e.g., chainId, nonce, etc., that can be added according to the use case).

  • The reveal function must then reconstruct the hash using the same structure, incorporating the msg.sender of the reveal call: bytes32 expectedCommitment = keccak256(abi.encode(msg.sender, bid, salt));.

All examples are available on my GitHub here

Conclusion

Front-running attacks, like those potentially enabled by flawed commit-reveal schemes, are significant threats inherent to transparent blockchain environments. Because pending transactions often reside in a public mempool before confirmation, malicious actors can observe intentions and strategically submit their own transactions, often with higher gas fees, to execute before the target transaction for their own gain. This ability to observe and react before final execution is a unique challenge presented by the nature of public blockchains.

Therefore, when developing or auditing any smart contract system, it is absolutely critical to adopt an adversarial mindset, particularly regarding timing and information availability:

  • Always ask: "Can any function or interaction within this protocol be profitably front-run?"

  • Consider: What potentially sensitive information (like bids, trade details, price data, governance votes) is revealed before an action is immutably settled on-chain?

  • Analyze: What specific benefit could an attacker get by executing a transaction immediately before or after a specific user action? Could they capture arbitrage, manipulate prices, steal rewards, or censor others?

Implement appropriate countermeasures, such as using secure commit-reveal patterns where necessary or minimizing dependencies on predictable external events. This is how developers build more resilient and trustworthy systems. Thinking like an attacker to anticipate vulnerabilities is fundamental to designing robust and secure smart contracts. 

Think of exploring these security concepts not as the end of your training but as the beginning of your smart-contract security adventure!

Stay tuned for the next installment of "Solodit Checklist Explained".

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.