Damn Vulnerable DeFi Wargame Challenge — Free rider Contract Analysis 🥢

Challenge #10 - Free rider

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.

Contract Audit

Setup Flow

Summary

  • The attacker owns 0.5 ether.

  • To send an NFT in a normal way, 45 ether must be provided.

  • The final attack must be greater than 45 ether, which must be 0 in actual value in the FreeRiderBuyer.sol contract.

  • After deploying the Uniswap Pair, 9000 WETH and 15000 DVT liquidity is provided.

  • The FreeRiderNFTMarketPlace Contract is deployed with 90 ether, which is not valuable.

  • The FreeRiderNFTMarketPlace deploys the DV-NFT contract and creates 6 NFTs, all of which are owned by the deployer account.

  • All tokens are approved and offered at a price of 15 ether each to be able to be processed in the market.

State Variable

// NFT MarketPlace (6 tokens, 15 ETH)
uint256 NFT_PRICE = 15 ether;
uint8 AMOUNT_OF_NFTS = 6;
uint256 MARKETPLACE_INITIAL_ETH_BALANCE = 90 ether;

// Buyer Payout => 45 ETH
uint256 BUYER_PAYOUT = 45 ether;

// UniswapV2Pool INIT
uint256 UNISWAP_INITIAL_TOKEN_RESERVE = 15_000 ether;
uint256 UNISWAP_INITIAL_WETH_RESERVE = 9_000 ether;
  • Six tokens and 15 ether are set in the market place

  • Buyer provides 45 ether as payment for the day

  • The initial reserve for UniswapV2Pool is as follows. 15000, 9000 (Token, WETH) ether

Uniswap setup


address _weth = deployCode("artifacts/WETH9.json");
weth = IWETH(_weth);
// (deploy) DVT
token = new DamnValuableToken();
// (deploy) uniswap Factory, Router
address _uniswapFactory = deployCode(
    "node_modules/@uniswap/v2-core/build/UniswapV2Factory.json",
    abi.encode(address(0))
);
uniswapFactory = IUniswapV2Factory(_uniswapFactory);
address _uniswapRouter = deployCode(
    "node_modules/@uniswap/v2-periphery/build/UniswapV2Router02.json",
    abi.encode(_uniswapFactory, _weth)
);

// <approve> token + Create Uniswap PAIR Liquidity
token.approve(_uniswapRouter, UNISWAP_INITIAL_WETH_RESERVE);
IUniswapV2Router02(_uniswapRouter).addLiquidityETH{
    value: UNISWAP_INITIAL_WETH_RESERVE
}(
    address(token), // WETH =(trade)> DVT
    UNISWAP_INITIAL_TOKEN_RESERVE, // amountTokenDesired
    0, // amountTokenMin
    0, // amountETHMin
    address(this), // to
    block.timestamp * 2 // deadline
);

// craete uniswap Pair <= getPair based
uniswapPair = IUniswapV2Pair(
    uniswapFactory.getPair(address(token), _weth)
);
assertEq(uniswapPair.token0(), _weth);
assertEq(uniswapPair.token1(), address(token));
assertGt(uniswapPair.balanceOf(address(this)), 0);
  • The balance of UNISWAP_INITIAL_TOKEN_RESERVE is approved to be added to Uniswap liquidity pool

  • The liquidity token, DVT, is set up for exchange with WETH through addLiquidity

  • The Uniswap pair is configured by referencing the data of the tokens in the liquidity pool

  • The process of creating the Uniswap liquidity pool is completed after going through the verification process.

Config NFT MarketPlace, FreeRiderBuyer

// (deploy) NFT
nft = DamnValuableNFT(marketplace.token());
vm.label(address(nft), "DamnValuableNFT");

// <approve>+<minted>
uint256[] memory tokenIds = new uint256[](AMOUNT_OF_NFTS);
uint256[] memory offers = new uint256[](AMOUNT_OF_NFTS);
for (uint8 i = 0; i < AMOUNT_OF_NFTS; i++) {
    tokenIds[i] = i;
    offers[i] = NFT_PRICE;
    assertEq(nft.ownerOf(tokenIds[i]), address(this));
}
nft.setApprovalForAll(address(marketplace), true);
marketplace.offerMany(tokenIds, offers);
assertEq(marketplace.amountOfOffers(), AMOUNT_OF_NFTS);
// [buyer] <= BUYER_PAYOUT
vm.deal(buyer, BUYER_PAYOUT);
// (deploy) Buyer Contract + attacker
vm.prank(buyer);
buyerContract = new FreeRiderBuyer{value: BUYER_PAYOUT}(
    attacker,
    address(nft)
);
vm.label(address(buyerContract), "FreeRiderBuyer");
  • The FreeRiderNFTMarketplace contract is deployed and connected to the ERC721 token. A total of 6 NFT tokens and 90 ether are set with the msg.value value.

  • After the NFT contract DamnValuableNFT is deployed, the marketPlace contract is connected.

FreeRiderBuyer.sol

FreeRiderBuyer.sol
FreeRiderBuyer.sol

Functions

constructor

  • In order to pass, msg.value must always possess the value of JOB_PAYOUT.

  • The instance based on _partner address is set.

  • The IERC721 nft contract based on nft address is assigned.

  • The setApprovalForAll function is used to grant permission to msg.sender.

function onERC721Received(address, address, uint256 _tokenId, bytes memory) external override nonReentrant returns (bytes4)

✅ The msg.sender address must be the same as the nft contract.

✅ The tx.origin, the address of the account that called the call, must be the same as the partner address.

✅ The _tokenId must be greater than or equal to 0 and less than or equal to 5.

✅ The access control of this contract is identified based on the ownerOf function of the nft contract, and then passed.

  • The safeTransfer function is called and the received state variable is counted. In the case of the integer 6, JOB_PAYOUT is sent to partner using sendVaule based on the address.

  • The return processing is the IERC721Receiver.onERC721Received.selector function selector.

FreeRiderNFTMarketplace.sol

FreeRiderNFTMarketplace.sol ~
FreeRiderNFTMarketplace.sol ~
FreeRiderNFTMarketplace.sol
FreeRiderNFTMarketplace.sol

Functions

constructor

✅ The value of amountToMint must be less than 256.

  • Assign an instance of the DamnValuableNFT contract

  • Loop through the parameters set at the time of deployment and perform the safeMint operation on msg.sender.

function _offerOne(uint256 tokenId, uint256 price) private

✅ The price parameter value must be greater than 0.

✅ The msg.sender address must be the owner of the NFT token, i.e. the same value as the tokenId.

✅ Based on the ERC721 contract, the getApproved function is used to check whether this contract has been approved for the given tokenId, and then determines whether the msg.sender address has been approved.

  • Check if the mapping value of the state variable offers has been assigned. Assign the price value.

  • When the amountOfOffers state variable value is triggered in the calling logic order, perform the role of incrementing, and when calling this function, perform the event processing of each function.

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

✅ The length of the nft tokenIds array must always be greater than or equal to 0 and must be equal to the length of the calldata prices array.

  • Perform the logic for the overall loop operation of the _offerOne function, and perform the loop operation for the actual length of the nft tokenIds array.

function _buyOne(uint256 tokenIds) private

  • The state variable "offers" is a mapping data with the key values of nft tokenIds, and manages local variables for the payment amount.

✅ The local variable value of priceToPay is the same as the payment amount for the nft tokenId, and checks whether the token is not provided.

✅ The condition that the user's payment amount value must be greater than or equal to priceToPayand the payment amount must be sufficient is not met.

  • The counting value of amountOffers is decreased to control branching.

  • Based on the previous _offerOne function, the pricing and mapping work of nftTokens was performed and approved. In _buyOne, the logic for transferring nftTokens to the user who purchased them is responsible for the transfer logic based on safeTransferFrom.

  • Then, the user who sold it passes through the process of transferring the value of the nftTokens.

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

  • The logic of the "_buyOne" function is processed based on the data of the array for the amount of nftTokens to be purchased.

Vulnerabilities

The _buyOne function is used within the nft marketplace contract to purchase NFT tokens. It is prohibited from being used externally due to the private directive, and checks whether it is in a state where it can be purchased by comparing the designated NFT selling price of the offers state variable with msg.value. However, the buyManyfunction can be called externally in the form of an interface of the _buyOne function, and anyone can trigger it by assigning the value of msg.value with the payable directive. It also repeatedly calls this function for the length value of tokenIds, but a vulnerable problem occurs here. Themsg.value value used in the first call is continuously used without any restrictions, causing a reuse problem and allowing more NFT to be purchased at the initial amount.

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--;
    token.safeTransferFrom(token.ownerOf(tokenId), msg.sender, tokenId);
    payable(token.ownerOf(tokenId)).sendValue(priceToPay);
    emit NFTBought(msg.sender, tokenId, priceToPay);
}

Solve

Based on the reuse vulnerability of msg.value, an attacker must have funds to purchase as many NFTs as they like. First, in order to make money, you need to take out a loan using the flash swap function of the current Uniswap liquidity pool. Of course, since it's a flash concept, you'll have to pay it off to complete this transaction. So, in the interim phase, we will leverage the previous vulnerabilities and financial logic of NFT Marketplace contracts.

In the nft marketplace, you can resell NFTs through the offerMany function. This means that the purchase is complete because it can be externally called and ownership of the NFT exists and has been accepted by this contract. Then I found out that I can buy nft and proceed with resale. Also, using the aforementioned vulnerability, in the purchase phase, the initial 30 Ether is used to normally purchase 2 nfts. After that, call the offerMany function to sell 2 nfts worth 90 ether and then trigger the msg.value reuse vulnerability to purchase them, so you can take a larger amount.

Then, after purchasing the remaining NFTs, if 6 are sequentially delivered to the target FreeriderBuyer, the ERC721Recieve handling logic operates and 45 ether can be obtained in the final stage. Finally, the redemption amount is calculated and paid back to complete the flashswap transaction.

Solve:ExploitFreeRider.sol (foundry forge framework)
Solve:ExploitFreeRider.sol (foundry forge framework)

next time.. 🚀

Thank you for the @tinchoabbate that made a good wargame.

Subscribe to Zer0Luck
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.