Prevent Front-Running on ERC20 Smart Contracts

What will you find here?

This article explores the critical issue of front-running vulnerabilities in ERC20 tokens on the Ethereum blockchain, focusing on how these security gaps can be exploited through the token allowance mechanism.

We provide a detailed examination of the vulnerability, illustrate it using a specific contract example, and showcase a proof-of-concept attack to highlight the risks.

Read on to discover how to safeguard your digital assets with enhanced security measures tailored for the ERC20 standard.

Introduction

Front-running attacks exploit the fact that transactions on the Ethereum network are processed in the order determined by the miners, based on the gas price offered. This vulnerability can lead to situations where an attacker can front-run a legitimate transaction by offering a higher gas price, thereby manipulating the state of the contract before the original transaction is processed.

One common instance of this vulnerability is the ERC20 token standard's approve function, which allows a user to approve another address to spend a certain amount of tokens on their behalf. If not implemented correctly, an attacker can front-run the approval transaction and drain the user's tokens.

Vulnerability

1. Explanation of the Front-Running Exploit

  • Definition: Front running in cryptocurrencies typically occurs when someone with knowledge of an upcoming transaction (from the public mempool) uses this information to their advantage by placing a transaction in such a way that it is confirmed before the known transaction.

  • ERC20 Context: For ERC20 tokens, front running often involves seeing an approve transaction pending and quickly using the current allowance before the new approve transaction that modifies this allowance is processed.

2. Vulnerable Contract Implementation

Here is a simplified example of an existing vulnerable contract. The complete implementation you can find it in etherscan.

pragma solidity 0.6.4;
//ERC20 Interface
interface ERC20 {
    [...]
}
interface VETH {
    [...]
}
library SafeMath {
    [...]
}
    //======================================VETHER=========================================//
contract Vether4 is ERC20 {
    using SafeMath for uint;
    // ERC-20 Parameters
    string public name; 
    string public symbol;
    uint public decimals; 
    uint public override totalSupply;
    // ERC-20 Mappings
    mapping(address => uint) private _balances;
    mapping(address => mapping(address => uint)) private _allowances;
    // Rest of Parameters
    [...]
    //=====================================CREATION=========================================//
    // Constructor
    constructor() public {
       [...]                                                        
    }
    function _setMappings() internal {
        [...]
    }

    //========================================ERC20=========================================//
    function balanceOf(address account) public view override returns (uint256) {
        return _balances[account];
    }
    function allowance(address owner, address spender) public view virtual override returns (uint256) {
        return _allowances[owner][spender];
    }
    // ERC20 Transfer function
    function transfer(address to, uint value) public override returns (bool success) {
        _transfer(msg.sender, to, value);
        return true;
    }
    // ERC20 Approve function
    function approve(address spender, uint value) public override returns (bool success) {
        _allowances[msg.sender][spender] = value;
        emit Approval(msg.sender, spender, value);
        return true;
    }
    // ERC20 TransferFrom function
    function transferFrom(address from, address to, uint value) public override returns (bool success) {
        require(value <= _allowances[from][msg.sender], 'Must not send more than allowance');
        _allowances[from][msg.sender] = _allowances[from][msg.sender].sub(value);
        _transfer(from, to, value);
        return true;
    }
    // Internal transfer function which includes the Fee
    function _transfer(address _from, address _to, uint _value) private {
        require(_balances[_from] >= _value, 'Must not send more than balance');
        require(_balances[_to] + _value >= _balances[_to], 'Balance overflow');
        _balances[_from] =_balances[_from].sub(_value);
        uint _fee = _getFee(_from, _to, _value);                                            // Get fee amount
        _balances[_to] += (_value.sub(_fee));                                               // Add to receiver
        _balances[address(this)] += _fee;                                                   // Add fee to self
        totalFees += _fee;                                                                  // Track fees collected
        emit Transfer(_from, _to, (_value.sub(_fee)));                                      // Transfer event
        if (!mapAddress_Excluded[_from] && !mapAddress_Excluded[_to]) {
            emit Transfer(_from, address(this), _fee);                                      // Fee Transfer event
        }
    }
    // Calculate Fee amount
    function _getFee(address _from, address _to, uint _value) private view returns (uint) {
        if (mapAddress_Excluded[_from] || mapAddress_Excluded[_to]) {
           return 0;                                                                        // No fee if excluded
        } else {
            return (_value / 1000);                                                         // Fee amount = 0.1%
        }
    }

    //================================REST OF THE CONTRACT======================================//
    [...]
}

Specific Vulnerability Points inVether4

  • approveandtransferFromMechanism:

    • Scenario: Suppose Alice decides to change an approval for Bob from 5000 VETH to 2500 VETH. She submits an approve transaction setting Bob's allowance to 2500 VETH.

    • Front-Running Opportunity: A malicious actor (or Bob himself) can watch the pending transactions and issue a transferFrom transaction to move the originally approved 5000 VETH before Alice's new approve transaction is confirmed. If the front-runner’s transaction is mined first, they can exploit the old allowance fully before it’s reduced.

  • Lack of Checks inapproveFunction:

    • The approve function directly sets the allowance of a spender without considering any previously set allowances. This can lead to a situation known as a "race condition" where swiftly executed transactions can manipulate allowances to transfer more than intended by the token owner.
  • No Protection Against Double-Spending Allowance:

    • The approve function does not protect against the double-spending problem. If a user lowers the allowance of a spender who already has a certain allowance, the spender can quickly spend the existing allowance and still access the newly set allowance if the approve transaction is mined afterward.

3. PoC with Foundry

  1. Alice approves an allowance of 5000 VETH to Bob.

  2. Alice attempts to lower the allowance to 2500 VETH.

  3. Bob notices the transaction in the mempool and front-runs it by using up the full allowance with a transferFrom call.

  4. Alice's lowered allowance is confirmed and Bob now has an allowance of 2500 VETH, which can be spent further for a total of 7500 VETH.

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

import {Test, console} from "forge-std/Test.sol";

interface IVether4 {
    function transfer(address to, uint amount) external returns (bool);
    function balanceOf(address account) external view returns (uint);
    function approve(address spender, uint amount) external returns (bool);
    function transferFrom(address from, address to, uint amount) external returns (bool);
}

contract AllowanceRaceConditionTest is Test {
    IVether4 vether;

    address constant vetherAddress = 0x4Ba6dDd7b89ed838FEd25d208D4f644106E34279; // Mainnet address of Vether4

    address alice = 0x273BA31C706b9D9FdAe1FD999183Bfa865895bE9;
    address bob = 0x159e4E57eD13176A69693c987917651C552d2575;

    function setUp() public {
        vether = IVether4(vetherAddress);
    }

    function testAllowanceRaceCondition() public {
        uint256 bobInitialBalance = vether.balanceOf(bob);
        uint256 aliceInitialBalance = vether.balanceOf(alice);

        console.log("Bob's initial balance: ", bobInitialBalance);
        console.log("Alice's initial balance: ", aliceInitialBalance);

        // Alice approves Bob to spend 5000 VETH
        vm.prank(alice);
        vether.approve(bob, 5000 ether); // Using 'ether' to convert to correct token units if necessary

        // Bob tries to front-run Alice's next approval by transferring 5000 VETH to himself
        vm.prank(bob);
        vether.transferFrom(alice, bob, 5000 ether);

        // Alice lowers the allowance to 2500 VETH
        vm.prank(alice);
        vether.approve(bob, 2500 ether);

        // Bob attempts to transfer the newly approved 2500 VETH
        vm.prank(bob);
        bool txSuccess = vether.transferFrom(alice, bob, 2500 ether);

        uint256 bobFinalBalance = vether.balanceOf(bob);
        uint256 expectedBobBalance = bobInitialBalance + 7500 ether; // Sum of first and second transfer

        assertEq(bobFinalBalance, expectedBobBalance, "Bob's final balance should reflect the transfers");
        assertTrue(txSuccess, "Bob's second transfer should succeed within allowance");
    }
}

1. Contract Interface and Setup:

  • IVether4 Interface: This defines the expected functions of the Vether4 contract, allowing the test contract to interact with Vether4 as if it were a local Solidity contract.

  • setUp Function: This function initializes the Vether4 contract interface by pointing it to the deployed address of Vether4 on mainnet. This setup occurs before each test is run.

2. Test Functionality (testAllowanceRaceCondition):

  • Initial Balance Checks: The test first logs the initial Ethereum balances of Bob and Alice. It's important to note that if bob.balance and alice.balance refer to their ETH balances, they may not reflect changes from Vether token transfers. For token balance checks, vether.balanceOf(address) should be used instead.

  • Approve and TransferFrom: The test simulates a common scenario where Alice approves Bob to spend a certain amount of Vether tokens, and then attempts to change this allowance. However, before the new allowance is set, Bob tries to transfer the initially approved amount.

    • Alice first approves Bob to spend 5000 VETH.

    • Bob, potentially acting maliciously or taking advantage of the timing, transfers 5000 VETH to himself before Alice can lower the allowance.

    • Alice then lowers the allowance to 2500 VETH.

    • Bob attempts to transfer again under the new allowance.

  • Balance Validation: After the operations, the test checks if Bob's final token balance matches the expected value, considering both transfers. This is crucial to validate that the token transfer operations respect the allowances set and changed over time.

3. Security Implications:

  • This test effectively demonstrates a race condition where Bob can use the full initial allowance before it's successfully reduced. It showcases the importance of handling allowances carefully, especially in multi-user environments where timing differences can lead to unexpected or exploitative behaviors.

Running the Test with Forge Test Fork

Environment Setup:

  • Forge Test Fork: You are running these tests on a fork of the main Ethereum network. This allows you to interact with the current state of the Ethereum mainnet in a controlled, isolated environment. The --fork-url parameter points to an Alchemy node that provides access to this forked version of the network.

Command to Run:

forge test --fork-urlhttps://eth-mainnet.g.alchemy.com/v2/your-api-key

How to prevent it?

Among various strategies to prevent front-running vulnerabilities in ERC20 tokens, using the increaseAllowance and decreaseAllowance functions stands out as particularly effective. This approach addresses the core issue of the traditional approve function, which can expose users to front-running attacks during the allowance adjustment period.

WhyincreaseAllowanceanddecreaseAllowanceAre Effective:

  1. Minimizes the Race Condition Window: These methods reduce the risk of front-running by minimizing the window of time during which the allowances are vulnerable. By adjusting allowances incrementally, it ensures that any front-running transaction cannot exploit a large, suddenly granted allowance.

  2. Enhances Control Over Allowance Changes: By allowing token holders to specify exactly how much to increase or decrease the allowance, these functions offer more precise control over the changes, reducing the likelihood of unintentionally setting incorrect allowances.

  3. Prevents the Zero-Reset Vulnerability: The traditional method of resetting an allowance to zero before setting a new higher value can be risky if the approve transaction is front-run after the zero reset but before the new value is set. The increaseAllowance and decreaseAllowance functions inherently avoid this pitfall by directly adjusting the existing value.

function increaseAllowance(address spender, uint256 addedValue) public returns (bool) {
    _approve(msg.sender, spender, allowance(msg.sender, spender).add(addedValue));
    return true;
}

function decreaseAllowance(address spender, uint256 subtractedValue) public returns (bool) {
    _approve(msg.sender, spender, allowance(msg.sender, spender).sub(subtractedValue, "ERC20: decreased allowance below zero"));
    return true;
}

Conclusion

In conclusion, this exploration of front-running vulnerabilities within the ERC20 token standard has highlighted the crucial need for improved security measures. By detailing how attackers exploit transaction order on the Ethereum network, particularly through the approve function, we've emphasized the importance of safeguarding digital assets. Implementing solutions like the safeApprove method and adopting the ERC20 with Permit standard can significantly reduce risks, making smart contracts more robust against such exploits.

For developers and participants in the cryptocurrency space, prioritizing these enhancements is not just about protecting investments; it's about fostering trust and stability in blockchain technologies. As we continue to innovate, the integration of rigorous security practices will be key to sustaining growth and ensuring that blockchain fulfills its promise of secure, decentralized transactions. These improvements are essential for anyone looking to enhance the integrity and functionality of their digital assets on the Ethereum platform.


More about Zealynx Security:

GitHub
Twitter
LinkedIn


References:

https://solodit.xyz/issues/l-18-erc20-race-condition-for-allowances-code4rena-vader-protocol-vader-protocol-contest-git

1
Subscribe to my newsletter

Read articles from Zealynx Security directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Zealynx Security
Zealynx Security

At Zealynx wea re providing Smart Contract Security Reviews with the highly efficient security testing tools used by the top companies in Web3. An Audit with Zealynx keeps your current code safe now and after any changes you implement later on. That's accomplished by providing with each audit a test suite of Fuzz tests and Formal Verification.