Skip to content

An implementation of reentrancy attack with Foundry simulation. Oracle manipulation, slippage, frontrunning and sandwich attack with in-depth analysis and prevention strategies.

Notifications You must be signed in to change notification settings

monipigr/smart-contract-security-audit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

8 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ›‘οΈ Blockchain Security Vulnerabilities Demo

This project demonstrates three critical blockchain security vulnerabilities through practical implementation and theoretical analysis. The main focus is a reentrancy attack simulation using a vulnerable banking contract, complemented by comprehensive test coverage. Additionally, it provides in-depth theoretical explanations of oracle manipulation and slippage attacks (including front-running and sandwich attacks), complete with prevention strategies and visual diagrams to enhance understanding of these common DeFi exploitation vectors.

πŸ› Reentrancy Attack

What is a Reentrancy Attack?

A reentrancy attack occurs when a function calls another contract, and that second contract gains control of the execution flow. The vulnerability lies in the fact that when we make an external call, we transfer control to the receiving contract before our original function completes.

Understanding the Vulnerability

The key question is: What happens when msg.sender is not an external wallet, but another smart contract?

function withdraw() public {
    require(userBalance[msg.sender] >= 1 ether, "User has not enough balance");
    require(address(this).balance > 0, "Bank is rekt");

    // 🚨 VULNERABLE: Control transfers to msg.sender (could be a contract)
    (bool success, ) = msg.sender.call{value: userBalance[msg.sender]}("");
    require(success, "Fail");

    // State update happens AFTER external call
    userBalance[msg.sender] = 0;
}

When msg.sender is a contract address, the ETH transfer automatically triggers the recipient contract's receive() function.

The Attacker Contract Structure

The attacking contract has four key components:

  1. SimpleBank instance: Set in constructor with target contract address
  2. attack() function: Entry point that deposits ETH and calls withdraw
  3. receive() function: Special Solidity function that auto-executes when receiving ETH
  4. Malicious logic: Inside receive() to call withdraw() again

The receive() Function

receive() external payable {
    if(address(simpleBank).balance >= 1 ether) {
        simpleBank.withdraw();
    }
}

Why receive() and not function?

  • receive() is a reserved Solidity function
  • Must be declared as receive() external payable
  • Automatically invoked when the contract receives ETH
  • No function keyword needed - it's built into the language

Attack Execution Flow

  1. SimpleBank.withdraw() executes requires
  2. ETH transfer to Attacker contract via msg.sender.call
  3. Control transfers to Attacker - receive() auto-executes
  4. Attacker's receive() calls simpleBank.withdraw() again
  5. But Original withdraw() hasn't reached user balance update yet
  6. Loop continues until bank is drained
  7. Only then update user balance userBalance[msg.sender] = 0

πŸ“Š Attack Flow Diagram

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   SimpleBank    β”‚    β”‚    Attacker     β”‚    β”‚  Execution Flow β”‚
β”‚                 β”‚    β”‚   Contract      β”‚    β”‚                 β”‚
β”‚ Balance: 60 ETH β”‚    β”‚ Balance: 10 ETH β”‚    β”‚ 1. Deposit 10ETHβ”‚
β”‚ User: 50 ETH    │◄──►│                 β”‚    β”‚ 2. Call withdrawβ”‚
β”‚ Attacker: 10ETH β”‚    β”‚ receive() {     β”‚    β”‚ 3. receive()runsβ”‚
β”‚                 β”‚    β”‚   withdraw()    β”‚    β”‚ 4. Re-call loop β”‚
β”‚ withdraw() {    β”‚    β”‚ }               β”‚    β”‚ 5. Drain funds  β”‚
β”‚  βœ… requires    β”‚    β”‚                 β”‚    β”‚ 6. Update state β”‚
β”‚  πŸ’Έ send ETH    │──→ β”‚                 β”‚ β”€β”€β”β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚  πŸ”„ LOOP        β”‚    β”‚                 β”‚   β”‚
β”‚  ❌ balance=0   β”‚    β”‚                 β”‚   β”‚
β”‚ }               β”‚    β”‚                 β”‚   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
         β–²                       β”‚           β”‚
         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           Control flows back after receive()

                       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                       β”‚    Result       β”‚
                       β”‚                 β”‚
                       β”‚ Bank: 0 ETH     β”‚
                       β”‚ Attacker: 60ETH β”‚
                       β”‚ User: Lost 50ETHβ”‚
                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Detailed Attack Scenario

Setup:

  • Deploy SimpleBank contract
  • Deploy Attacker contract with SimpleBank address
  • Legitimate user deposits 50 ETH
  • Attacker deposits only 10 ETH

Attack Execution:

  1. Attacker calls attack() with 10 ETH (extra gas to avoid revert)
  2. Attacker's balance: 10 ETH, Bank total: 60 ETH
  3. withdraw() sends 10 ETH to Attacker
  4. receive() triggers, calls withdraw() again
  5. Attacker's balance still shows 1 ETH (not updated yet!)
  6. Loop continues until bank empty
  7. Final result: Attacker gets 60 ETH, should only get 10 ET

πŸ› οΈ Prevention Strategies

CEI Pattern = Checks-Effects-Interactions

function withdraw() public {
    // 1. CHECKS: Validate conditions
    require(userBalance[msg.sender] >= 1 ether, "User has not enough balance");
    require(address(this).balance > 0, "Bank is rekt");

    // 2. EFFECTS: Update state FIRST
    uint256 balance = userBalance[msg.sender];
    userBalance[msg.sender] = 0;

    // 3. INTERACTIONS: External calls LAST
    (bool success, ) = msg.sender.call{value: balance}("");
    require(success, "Fail");
}

Why this works:

  • When receive() calls withdraw() again
  • userBalance[msg.sender] is already 0
  • First require fails: "User has not enough balance"
  • Attack reverts

πŸ§ͺ Test Coverage

The project demonstrates this attack through comprehensive testing:

function test_attack() public {
    // Legitimate user deposits 20 ether
    vm.deal(user, 20 ether);
    vm.prank(user);
    simpleBank.deposit{value: 20 ether}();

    // Attacker executes reentrancy attack with 2 ether
    vm.deal(address(attacker), 2 ether);
    vm.prank(address(attacker));
    attacker.attack{value: 2 ether}();

    // Verify all funds drained: 22 ETH stolen with only 2 ETH deposit
    assert(simpleBank.totalBalance() == 0);
}

Test Results:

  • βœ… Legitimate deposits and withdrawals work
  • βœ… Attack successfully drains all funds
  • βœ… Attacker steals more than they deposited
  • βœ… Bank balance becomes zero

🎭 Oracle Manipulation

What is an Oracle?

An oracle is a bridge between the real world and smart contracts. Smart contracts cannot access external data directly - they only know about their own state and other smart contracts they can interface with. If a smart contract needs real-world data (like ETH price, weather data, or sports results), it must rely on an oracle.

Example: A smart contract handling bets on football matches cannot know the match result by itself. It needs an oracle to provide this external information.

What is Oracle Manipulation?

Oracle manipulation occurs when the oracle provides incorrect data, either accidentally or maliciously. This can have catastrophic consequences since smart contracts make critical decisions based on this data.

Example Scenario:

  • Real ETH price: $3,000
  • Manipulated oracle reports: $5,000
  • Result: All calculations in dependent smart contracts use the wrong price, leading to incorrect token distributions and potential financial losses

Why Do Oracles Get Manipulated?

  1. Technical Malfunction: Oracle may be broken or misconfigured
  2. Malicious Actor: Someone with control over the oracle acts in bad faith for personal gain
  3. DEX Pool Manipulation: Some applications use DEX pools (like Uniswap ETH/USDC) as price oracles

πŸ“Š Attack Flow Diagram

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Real World    β”‚    β”‚   Centralized   β”‚    β”‚   DeFi Protocol β”‚
β”‚                 β”‚    β”‚     Oracle      β”‚    β”‚                 β”‚
β”‚ ETH = $3,000    β”‚    β”‚                 β”‚    β”‚ Betting/Lending β”‚
β”‚                 │───── Reports: $5,000 │───── Contract        β”‚
β”‚ Actual Price    β”‚    β”‚ (Manipulated)   β”‚    β”‚                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β–²                        β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”‚                        β–Ό
β”‚   DEX Pool      β”‚             β”‚              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                 β”‚             β”‚              β”‚     Result      β”‚
β”‚ ETH/USDC Pool   β”‚β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜              β”‚                 β”‚
β”‚ Price manipulated             β”‚              β”‚ Wrong payouts   β”‚
β”‚ by large trade  β”‚                            β”‚ User losses     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                            β”‚ Protocol risk   β”‚
                                               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

DEX Pool Manipulation Example:

  1. Inject large amount of ETH into ETH/USDC pool
  2. Price temporarily changes due to pool imbalance
  3. Smart contract reads manipulated price at that exact moment
  4. Arbitrage bots eventually restore correct price, but damage is done

This manipulation is temporary due to arbitrage, but can be devastating if it occurs during a critical transaction.

πŸ› οΈ Prevention Strategies

The fundamental problem with oracle manipulation is relying on a single centralized entity. This contradicts the core principle of decentralized applications - we shouldn't depend on a central actor who holds all the power.

Solution: Use Decentralized Oracles (Chainlink)

Chainlink solves the centralization problem through a network of independent nodes:

import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

contract PriceConsumer {
    AggregatorV3Interface internal priceFeed;

    constructor() {
        // ETH/USD price feed on Ethereum mainnet
        priceFeed = AggregatorV3Interface(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419);
    }

    // Chainlink oracle to get the current eth/usd price conversion
    function getEtherPrice() public view returns (uint256) {
        (, int256 price,,,) = IAggregator(dataFeedAddress).latestRoundData();
        price = price * 1e10;
        return uint256(price);
    }
}

How Chainlink's Decentralized Network Works

  • Multiple Independent Nodes: Instead of one oracle, Chainlink uses a network of nodes
  • Multiple Data Sources: Each node fetches price data from different external sources
  • Consensus Mechanism: All nodes report their findings, and outliers are filtered out
  • Median Calculation: Final price is calculated from consensus of honest nodes
  • Economic Incentives: Nodes earn LINK tokens for accurate reporting
  • Slashing Mechanism: Malicious or incorrect nodes are removed and lose their stake

Why This Prevents Manipulation

  • No Single Point of Failure: One corrupted node cannot manipulate the entire network
  • Economic Security: Nodes have financial incentive to act honestly (earn LINK rewards)
  • Reputation System: Bad actors are identified and removed from the network
  • Diverse Data Sources: Multiple external sources make coordinated manipulation extremely difficult

βš–οΈ Slippage Attack

Understanding the Mempool

Before understanding slippage, we need to understand how blockchain transactions work:

  1. User submits transaction β†’ Goes to mempool (waiting pool)
  2. Validators select transactions from mempool based on gas fees (not FIFO)
  3. Higher gas = higher priority β†’ Transaction included in next block

What is Slippage?

Slippage is the difference between the expected tokens to receive and the tokens actually received when performing a token swap.

Example Scenario:

  • You want to swap 1 ETH for USDC on Uniswap
  • Frontend shows: "You will receive 3,000 USDC"
  • You click "Execute" β†’ Transaction goes to mempool
  • Before your transaction is included, someone else's transaction modifies the pool
  • Result: You receive 2,998 USDC instead of 3,000 USDC
  • Slippage: $2

How Slippage Attacks Work

Front-Running Attack

  1. Attacker sees your transaction in mempool (1 ETH β†’ USDC swap)
  2. Attacker pays higher gas to get included first
  3. Attacker's transaction manipulates ETH/USDC pool (adds massive USDC)
  4. Pool balance changes β†’ ETH price drops to $500
  5. Your transaction executes β†’ You get 500 USDC instead of 3,000 USDC
  6. Attacker profits from the price manipulation

Sandwich Attack

A sandwich attack combines two front-running attacks:

  1. Front-run: Manipulate price DOWN before your transaction
  2. Your transaction: Executes at manipulated price
  3. Back-run: Restore price balance after your transaction
  4. Your transaction is "sandwiched" between two manipulated transactions

πŸ“Š Attack Flow Diagram

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Mempool       β”‚    β”‚   Sandwich      β”‚    β”‚   DEX Pool      β”‚
β”‚                 β”‚    β”‚   Attacker      β”‚    β”‚   ETH/USDC      β”‚
β”‚ User's TX:      β”‚    β”‚                 β”‚    β”‚                 β”‚
β”‚ 1 ETH β†’ 3000$   │◄──►│ 1. Sees user TX β”‚    β”‚ Balance: Normal β”‚
β”‚                 β”‚    β”‚ 2. Front-run    │◄──►│ Adds USDC       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚    (Higher gas) β”‚    β”‚ ETH = $500      β”‚
          β–²            β”‚ 3. User TX exec β”‚    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
          β”‚            β”‚    (Bad price)  β”‚              β”‚
          β”‚            β”‚ 4. Back-run     β”‚              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚    (Restore)    β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    Result       │◄────                 β”‚    β”‚   Price Impact  β”‚
β”‚                 β”‚    β”‚ 5. Extract MEV  β”‚    β”‚                 β”‚
β”‚ User gets: 500$ β”‚    β”‚                 β”‚    β”‚ 3000$ β†’ 500$    β”‚
β”‚ Expected: 3000$ β”‚    β”‚                 β”‚    β”‚ β†’ 3000$ (after) β”‚
β”‚ Loss: $2500     β”‚    β”‚                 β”‚    β”‚                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ› οΈ Prevention Strategies

The two main parameters to protect against slippage attacks:

1. amountOutMin - Minimum Output Protection

function protectedSwap(
    uint256 amountIn,
    uint256 amountOutMin, // Must be calculated OFF-CHAIN
    address[] memory path,
    uint256 deadline
) external {
    uint256 amountOut = uniswap.swapExactTokensForTokens(
        amountIn,
        amountOutMin, // Minimum tokens you're willing to accept
        path,
        msg.sender,
        deadline
    );

    // If actual output < amountOutMin, transaction reverts
}

Why OFF-CHAIN calculation?

  • You CANNOT calculate amountOutMin inside the smart contract
  • Even calling Uniswap's quote() function happens on-chain
  • Must be calculated in frontend before transaction submission

2. deadline - Time Limit Protection

function swapWithDeadline(
    uint256 amountIn,
    uint256 amountOutMin,
    address[] memory path,
    uint256 deadline // Maximum time willing to wait
) external {
    require(block.timestamp <= deadline, "Transaction expired");

    uniswap.swapExactTokensForTokens(
        amountIn,
        amountOutMin,
        path,
        msg.sender,
        deadline
    );
}

Why deadline matters?

  • If attacker pays higher gas, your transaction waits longer in mempool
  • Longer wait = more time for price manipulation
  • Deadline protects by reverting if transaction takes too long

3. Complete Protection Example

// Frontend calculates: if expecting 3000 USDC, set minimum to 2970 (1% slippage tolerance)
uint256 amountOutMin = expectedAmount * 99 / 100; // 1% tolerance
uint256 deadline = block.timestamp + 300; // 5 minutes max

uniswap.swapExactTokensForTokens(
    1 ether,           // amountIn
    amountOutMin,      // minimum acceptable output
    path,              // [WETH, USDC]
    msg.sender,        // recipient
    deadline           // time limit
);

This project is for educational purposes only. Never deploy vulnerable contracts to mainnet.

About

An implementation of reentrancy attack with Foundry simulation. Oracle manipulation, slippage, frontrunning and sandwich attack with in-depth analysis and prevention strategies.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published