Intro to Smart Contract Security Audit — Front Running

SlowMist
8 min readMay 13, 2023

--

Background Overview

In our previous article, we delved into the hidden malicious code within contracts. This time, let’s explore a very common attack method — front-running.

Preliminary Knowledge

When we mention front-running, the first thing that probably comes to mind is a track and field race. In athletics, the competitors’ physical fitness is almost identical; the sooner one starts, the greater their chance of winning. So, how does front-running occur in the Ethereum network?

Understanding front-running attacks requires a solid understanding of Ethereum’s transaction process. Let’s use the diagram below, illustrating the transaction flow, to understand what happens when a transaction is initiated on the Ethereum network.

As shown in the diagram, a transaction goes through seven stages from signing to being packaged:

  1. Signing the transaction content with a private key;
  2. Choosing the Gas Price;
  3. Sending the signed transaction;
  4. Broadcasting the transaction among various nodes;
  5. The transaction enters the transaction pool;
  6. Miners extract transactions with high Gas Prices;
  7. Miners package transactions and mine a new block.

After a transaction is sent, it is thrown into the transaction pool to await packaging by the miners. Miners extract transactions from the pool for packaging and block mining. According to Etherscan data, the current block’s Gas limit is around 30 million, a dynamically adjusted value. If we base our calculations on a standard transaction costing 21,000 Gas, then an Ethereum block can currently accommodate about 1428 transactions. Therefore, when the transaction volume in the pool is high, many transactions can’t be packaged immediately and remain in the pool waiting. This leads to a question: with so many transactions in the pool, which one does the miner package first?

Miner nodes can set their parameters, but most miners sort transactions based on the transaction fee amount. Transactions with higher fees are given priority for packaging and block mining, while those with lower fees must wait until all transactions with higher fees are packaged. Of course, transactions continuously enter the pool. Regardless of the order in which they enter, transactions with higher fees will always be packaged first. Transactions with excessively low fees might never get packaged.

So, how are transaction fees derived?

Let’s first look at the Ethereum transaction fee calculation formula:

Tx Fee (Transaction Fee) = Gas Used * Gas Price

Here, Gas Used is calculated by the system, while Gas Price can be customized. Hence, the ultimate transaction fee depends on the Gas Price set.

Example:

Suppose the Gas Price is set at 10 GWEI, and Gas Used is 21,000 (WEI is the smallest unit on Ethereum, where 1 WEI = 10^-18 Ether, and GWEI is 1 billion WEI, i.e., 1 GWEI = 10^-9 Ether). Therefore, according to the transaction fee calculation formula, the transaction fee can be calculated as:

10 GWEI (Gas Price) * 21,000 (Gas Used) = 0.00021 Ether (Transaction Fee)

In contracts, we often see the Call function setting a Gas Limit. Let’s look at what it means:

The term “Gas Limit” can be understood literally — it signifies the maximum amount of Gas you’re willing to spend on a transaction. When the transaction involves complex contract interactions and it’s uncertain how much Gas will be used, the Gas Limit can be set. When the transaction is packaged, only the actual Gas Used will be charged as a fee, and any excess Gas is refunded. Of course, if the actual Gas Used exceeds the Gas Limit during the operation, it will result in an “Out of gas” event, causing the transaction to revert.

Of course, choosing an appropriate Gas Price in actual transactions is also crucial. On ETH GAS STATION, we can see the real-time Gas Prices corresponding to the speed of packaging:

As shown in the diagram above, the fastest packaging speed currently corresponds to a Gas Price of 2. Therefore, we can ensure our transaction is processed as quickly as possible by setting the Gas Price to a value greater than or equal to 2 when sending the transaction.

At this point, you can probably guess the front-running attack method — it involves increasing the Gas Price when sending a transaction to ensure it’s prioritized by miners for packaging. Next, let’s understand how front-running attacks are carried out by examining a contract code.

Contract Example


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

contract FindThisHash {
bytes32 public constant hash =
0x564ccaf7594d66b1eaaea24fe01f0585bf52ee70852af4eac0cc4b04711cd0e2;

constructor() payable {}

function solve(string memory solution) public {
require(hash == keccak256(abi.encodePacked(solution)), "Incorrect answer");

(bool sent, ) = msg.sender.call{value: 10 ether}("");
require(sent, "Failed to send Ether");
}
}

Attack Analysis

Through the contract code, we can see that the deployer of the FindThisHash contract has provided a hash value. Anyone can submit an answer through the solve() function. If the hash value of the solution matches the deployer’s hash value, they can receive a reward of 10 Ether. For the purpose of our example, let’s exclude the possibility of the deployer taking the reward themselves.

Let’s bring back our old friend Eve (the attacker) to see how she uses a front-running attack to take the reward that should have belong to Bob (the victim):

  1. Alice (contract deployer) deploys the FindThisHash contract with 10 Ether;
  2. Bob finds the correct string whose hash value is the target hash value;
  3. Bob calls solve(“Ethereum”) and sets the Gas Price to 15 Gwei;
  4. Eve is monitoring the transaction pool, waiting for someone to submit the correct answer;
  5. Eve sees Bob’s transaction, sets a higher Gas Price (100 Gwei) than Bob, and calls solve(“Ethereum”);
  6. Eve’s transaction is packaged by the miners before Bob’s;
  7. Eve wins the reward of 10 Ether.

Here, Eve’s series of actions is a standard front-running attack. We can thus define front-running in Ethereum as influencing the order of transaction packaging by setting a higher Gas Price to carry out the attack.

So, how can we avoid such attacks?

Suggested Fixes

When writing contracts, you can use the Commit-Reveal scheme:

https://medium.com/swlh/exploring-commit-reveal-schemes-on-ethereum-c4ff5a777db8

Solidity by Example provides the following fix in the form of code. Let’s see if it can perfectly defend against front-running attacks.


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

import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/Strings.sol";

contract SecuredFindThisHash {
// Struct is used to store the commit details
struct Commit {
bytes32 solutionHash;
uint commitTime;
bool revealed;
}

// The hash that is needed to be solved
bytes32 public hash =
0x564ccaf7594d66b1eaaea24fe01f0585bf52ee70852af4eac0cc4b04711cd0e2;

// Address of the winner
address public winner;

// Price to be rewarded
uint public reward;

// Status of game
bool public ended;

// Mapping to store the commit details with address
mapping(address => Commit) commits;

// Modifier to check if the game is active
modifier gameActive() {
require(!ended, "Already ended");
_;
}

constructor() payable {
reward = msg.value;
}

/*
Commit function to store the hash calculated using keccak256(address in lowercase + solution + secret).
Users can only commit once and if the game is active.
*/
function commitSolution(bytes32 _solutionHash) public gameActive {
Commit storage commit = commits[msg.sender];
require(commit.commitTime == 0, "Already committed");
commit.solutionHash = _solutionHash;
commit.commitTime = block.timestamp;
commit.revealed = false;
}

/*
Function to get the commit details. It returns a tuple of (solutionHash, commitTime, revealStatus);
Users can get solution only if the game is active and they have committed a solutionHash
*/
function getMySolution() public view gameActive returns (bytes32, uint, bool) {
Commit storage commit = commits[msg.sender];
require(commit.commitTime != 0, "Not committed yet");
return (commit.solutionHash, commit.commitTime, commit.revealed);
}

/*
Function to reveal the commit and get the reward.
Users can get reveal solution only if the game is active and they have committed a solutionHash before this block and not revealed yet.
It generates an keccak256(msg.sender + solution + secret) and checks it with the previously commited hash.
Front runners will not be able to pass this check since the msg.sender is different.
Then the actual solution is checked using keccak256(solution), if the solution matches, the winner is declared,
the game is ended and the reward amount is sent to the winner.
*/
function revealSolution(
string memory _solution,
string memory _secret
) public gameActive {
Commit storage commit = commits[msg.sender];
require(commit.commitTime != 0, "Not committed yet");
require(commit.commitTime < block.timestamp, "Cannot reveal in the same block");
require(!commit.revealed, "Already commited and revealed");

bytes32 solutionHash = keccak256(
abi.encodePacked(Strings.toHexString(msg.sender), _solution, _secret)
);
require(solutionHash == commit.solutionHash, "Hash doesn't match");

require(keccak256(abi.encodePacked(_solution)) == hash, "Incorrect answer");

winner = msg.sender;
ended = true;

(bool sent, ) = payable(msg.sender).call{value: reward}("");
if (!sent) {
winner = address(0);
ended = false;
revert("Failed to send ether.");
}
}
}

First, we can see that the fixed code uses the Commit struct to record the information submitted by the player, where:

  • commit.solutionHash = _solutionHash = keccak256(player address + answer + password) — this records the hash of the answer submitted by the player
  • commit.commitTime = block.timestamp — this records the submission time
  • commit.revealed = false — this records the status

Next, let’s see how this contract operates:

  1. Alice deploys the SecuredFindThisHash contract using ten Ether;
  2. Bob finds the correct string whose hash value is the target hash value;
  3. Bob calculates solutionHash = keccak256 (Bob’s Address + “Ethereum” + Bob’s secret);
  4. Bob calls commitSolution(_solutionHash), submitting the solutionHash just calculated;
  5. In the next block, Bob calls the revealSolution(“Ethereum”,Bob’s secret) function, inputs the answer and his chosen password, and claims the reward.

Here, let’s look at how this contract avoids front-running. Firstly, in the fourth step, Bob submits the hash of these three values: (Bob’s Address + “Ethereum” + Bob’s secret). Hence, nobody knows what Bob has submitted. This step also records the block timestamp and checks it first in the revealSolution() of the fifth step. This is done to prevent front-running during the unveiling in the same block, as the plaintext answer needs to be passed when calling revealSolution(). Finally, it verifies whether the hash of the answer and password input by Bob matches the previously submitted solutionHash. This step prevents anyone from directly calling revealSolution() without going through commitSolution(). After successful verification, it checks whether the answer is correct and finally issues the reward.

So, does this contract truly prevent Eve from copying answers perfectly?

Of course not! So what’s going on?

We see that in revealSolution(), the only restriction is commit.commitTime < block.timestamp. So, suppose Bob submits an answer in the first block and immediately calls revealSolution(“Ethereum”,Bob’s secret) in the second block, setting Gas Price = 15 Gwei. Eve, through monitoring the transaction pool, gets the answer. After getting the answer, she immediately sets Gas Price = 100 Gwei, calls commitSolution() in the second block, submits the answer, and constructs multiple high Gas Price transactions to fill the second block, thereby squeezing Bob’s transaction into the third block. In the third block, she calls revealSolution(“Ethereum”,Eve’s secret) with a Gas Price = 100 Gwei, and gets the reward.

So, the question is, how can we effectively prevent such attacks?

It’s simple, you just need to set a uint256 revealSpan value and check in commitSolution() with require(commit.commitTime + revealSpan >= block.timestamp, “Cannot commit in this block”);. This can prevent Eve from copying the answer. However, during the reward distribution, it is still impossible to prevent the person who has submitted the answer from claiming the reward first.

Additionally, in the spirit of code rigor, the revealSolution() function in the fixed code does not set commit.revealed to True after execution. Although this does not affect anything, it is recommended to develop good coding habits when writing code, setting the switch to the correct state after executing the function logic.

Reference links:

Solidity by Example

https://solidity-by-example.org/hacks/front-running/

What is Gas in Ethereum? Ethereum Transaction Fees

https://2miners.com/blog/what-is-gas-in-ethereum-ethereum-transaction-fees/

--

--

SlowMist

SlowMist is a Blockchain security firm established in 2018, providing services such as security audits, security consultants, red teaming, and more.