Dex
Dex
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 oftoken2
. 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.
What is a dex?
A DEX(decentralized exchange) enables the exchange of digital assets in a decentralized way, without the need of intermediaries such as centralized exchanges.
Vulnerable Contract
- Expand to see
Dex.sol
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/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; } //Owner set's liquidity for the tokens //1. From the token address + amount get's send to the DEX contract (this) function addLiquidity(address token_address, uint amount) public onlyOwner { IERC20(token_address).transferFrom(msg.sender, address(this), amount); } //People can swap the tokens one addresss to the other function swap(address from, address to, uint amount) public { require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens"); //The swap has to be the same amount require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap"); //Requires the token balance of the msg.sender to be => than amount uint swapAmount = getSwapPrice(from, to, amount); //price get's fetched and set to swapAmount 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); } }
AMM (Automated Market Makers)
- In AMM there are no ‘order books’
X
* Y
= K
=
amount token1
* amount token2
= Total Amount
This is the formula that drives decentralized exchanges. Where X
= token1
, Y
= token2
and K
= price
of the pair.
In this case only the owner can add liquidity into the smartcontract. In UniswapV2 people can add liquidity to earn money.
Swapping:
In this smartcontract people don’t trade with eachother, they trade with the DEX. They swap token1
with token2
. So if you have in our case 10 token1
, and in the current liquidity there are 100 tokens of token2
If we want to swap 10 of our token2
for token1
we use the swap()
function:
- address from = address token2
- address to = address token1
- uint amount = 10
function swap(address from, address to, uint amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens"); //The swap has to be the same amount
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap"); //Requires the token balance of the msg.sender to be => than amount
uint swapAmount = getSwapPrice(from, to, amount); //price get's fetched and set to swapAmount
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}
the swapAmount get’s fetched from the getSwapPrice()
function.
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 formula will be:
(amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this));
( 10 * DEX balance of token1) / Dex balance of token2 ==
( 10 * 100 ) / 100 = 10
At this moment we get 10 tokens1 for 10 tokens2
The vulnerability
The problem is that the price is determined on the / IERC20.balanceOf
, which can be manipulated, at the beginning is only a small difference but over a couple of transactions the difference is huge.
function getSwapPrice(address from, address to, uint amount) public view returns(uint){
return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}
POC (Proof of Exploit)
address from = token2
address to = token1
amount = 20
(token2
’s)
swapPrice() = (20 * 110) / 90 = 24,4 → = 24 (tokens1)
So we get in this swap 24 (token1
’s) for only 20 (token2
’s)
Do it again:
address from = token1
address to = token2
amount = 24
(token1
’s)
swapPrice() = (24 * 110) / 86 = 30.7 → = 30 (tokens2)
Do this again and again until you eventually have 65 tokens of one of the two:
Now you can do the last swap to get everything out of the contract.
Now we have a balance of 110 and we completed the challenge.