[Ethernaut] 22. Dex

1. 문제

The goal of this level is for you to hack the basic [DEX](https://en.wikipedia.org/wiki/Decentralized_exchange)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:

- How is the price of the token calculated?
- How does the `swap` method work?
- How do you `approve` a transaction of an ERC20?
- Theres more than one way to interact with a contract!
- Remix might help
- What does “At Address” do?

// 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 Dex is Ownable {
  address public token1;
  address public token2;
  constructor() {}

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

2. 풀이

 컨트랙트 작성 없이 콘솔창을 사용해서 swap 함수를 반복해서 호출해야 하므로 먼저 아주 큰 수로 approve 함수를 호출하여 번거로움을 덜어줍니다.

await contract.approve(instance, BigInt(2^256 - 1))

 그리고 다음 과정을 반복해 줍니다.

playerinstance
token1token2token1token2
1010100100
token1 10개를 token2로 교환
02011090
token2 20개를 token1로 교환
24086110
token1 24개를 token2로 교환
03011080
token2 30개를 token1로 교환
41069110
token1 41개를 token2로 교환
06511045
token2 45개를 token1로 교환
11020090

 인스턴스가 가진 모든 token1을 탈취했습니다. 반대로 token2 10개를 token1로 먼저 교환한 경우는 인스턴스가 가진 모든 token2를 탈취할 수 있습니다.

 인스턴스를 제출하고 마무리합니다.


3. 가격 결정 알고리즘이 잘못됐다

 대부분의 탈중앙화 거래소는 유니스왑으로부터 기인하여 AMM(Automated Market Maker) 방식을 사용하는데, 이 방식을 사용할 때는 가격 결정 알고리즘이 굉장히 중요합니다. 이 문제의 컨트랙트의 경우는 다음 함수를 통해 토큰의 가격을 구합니다.

function getSwapPrice(address from, address to, uint amount) public view returns(uint){
  return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}

 문제는 가격만 구하는 것이 아니라 amount를 곱해서 그만큼의 to 토큰으로 교환을 해주기 때문에 한 번에 많은 from 토큰을 교환하면 그만큼 많은 양의 to 토큰을 가져갈 수 있습니다. 문제에서도 이를 활용해 컨트랙트의 자금을 탈취할 수 있었습니다.

 문제가 뭔지는 얼추 알겠는데 사실 DEX에 익숙하지 않은 경우에는 어떤 알고리즘이 필요한지 바로 알아맞히기란 여간 쉬운 일은 아닙니다. 저도 그러한 이유로 이 문제를 처음 마주하고 DEX에 대한 공부의 필요성을 느껴서 유니스왑 V2 컨트랙트를 살펴보고 있습니다. 유니스왑에서는 대표적으로 CPMM이라는 가격 결정 알고리즘을 사용합니다. 이와 관련해서는 아래 게시글에서 추가적인 내용을 살펴보실 수 있습니다.