Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: low
Invalid

Token ID collision: collectionId = 0 encodes to the same IDs as festival passes, enabling pass privilege escalation

Root + Impact

Pass token IDs and memorabilia token IDs share the same uint256 space with no enforced lower-bound guard. If a collision is achieved, hasPass() returns true for the holder, granting access to attendPerformance() and all BEAT reward flows without ever purchasing a pass.

Description

Pass token IDs are raw constants: GENERAL_PASS = 1, VIP_PASS = 2, BACKSTAGE_PASS = 3. Memorabilia token IDs are encoded as:

tokenId = (collectionId << 128) + itemId

For collectionId = 0: (0 << 128) + 1 = 1 — identical to GENERAL_PASS. While nextCollectionId is initialised at 100 (making collectionId 0–3 unreachable via createMemorabiliaCollection today), the vulnerability is structural:

  • encodeTokenId is public pure — any external contract or user can call it with (0, 1) and receive token ID 1, which hasPass() treats as a valid GENERAL_PASS.

  • collections[1], collections[2], collections[3] are uninitialised structs with priceInBeat = 0. Nothing at the contract level prevents a future upgrade or organizer action from populating those slots.

  • The separation is a runtime convention (nextCollectionId = 100), not a protocol invariant enforced by the type system or require guards.

Risk

// encodeTokenId is public — anyone can call:
uint256 id = fp.encodeTokenId(0, 1);
// id == 1 == GENERAL_PASS
// If a user somehow holds token ID 1 via any memorabilia path:
fp.hasPass(user); // returns true — free pass access
// Demonstrating the collision:
assertEq(fp.encodeTokenId(0, 1), 1); // == GENERAL_PASS
assertEq(fp.encodeTokenId(0, 2), 2); // == VIP_PASS
assertEq(fp.encodeTokenId(0, 3), 3); // == BACKSTAGE_PASS

Proof of Concept

function test_idCollisionWithPassIds() public {
FestivalPass fp = new FestivalPass(address(bt), organizer);
// Collision is demonstrable via the public function
assertEq(fp.encodeTokenId(0, 1), 1); // GENERAL_PASS
assertEq(fp.encodeTokenId(0, 2), 2); // VIP_PASS
assertEq(fp.encodeTokenId(0, 3), 3); // BACKSTAGE_PASS
// If owner of token ID 1 is checked:
// balanceOf(user, 1) is the SAME storage slot for both
// a purchased GENERAL_PASS and a memorabilia with collectionId=0, itemId=1
}

Recommended Mitigation

uint256 constant MIN_COLLECTION_ID = 100;
function encodeTokenId(uint256 collectionId, uint256 itemId)
public pure returns (uint256)
{
+ require(collectionId >= MIN_COLLECTION_ID,
+ "Collection ID too low: collision with pass IDs");
return (collectionId << COLLECTION_ID_SHIFT) + itemId;
}
// Also enforce in createMemorabiliaCollection:
uint256 collectionId = nextCollectionId++;
+ require(collectionId >= MIN_COLLECTION_ID, "Collection ID underflow");
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 8 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!