Beatland Festival

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

[H-01] Denial of Service in `FestivalPass::getUserMemorabiliaDetailed` Due to Unbounded Nested Loops

Denial of Service in FestivalPass::getUserMemorabiliaDetailed Due to Unbounded Nested Loops

Description

  • The FestivalPass::getUserMemorabiliaDetailed function is designed to aggregate and return a comprehensive list of all token IDs, collection IDs, and item IDs owned by a specific user address:

// Get all memorabilia owned by a user with details
function getUserMemorabiliaDetailed(address user) external view returns (
uint256[] memory tokenIds,
uint256[] memory collectionIds,
uint256[] memory itemIds
) {
// First, count how many memorabilia they own
uint256 count = 0;
@> for (uint256 cId = 1; cId < nextCollectionId; cId++) {
for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) {
uint256 tokenId = encodeTokenId(cId, iId);
if (balanceOf(user, tokenId) > 0) {
count++;
}
}
}
// Then populate arrays
tokenIds = new uint256[](count);
collectionIds = new uint256[](count);
itemIds = new uint256[](count);
uint256 index = 0;
@> for (uint256 cId = 1; cId < nextCollectionId; cId++) {
for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) {
uint256 tokenId = encodeTokenId(cId, iId);
if (balanceOf(user, tokenId) > 0) {
tokenIds[index] = tokenId;
collectionIds[index] = cId;
itemIds[index] = iId;
index++;
}
}
}
return (tokenIds, collectionIds, itemIds);
}
  • The implementation iterates through every single collection and every item within those collections twice—first to count the user's balance and second to populate the return arrays. This results in a time complexity of roughly , meaning the gas cost grows linearly with the total number of items on the platform rather than the number of items owned by the user.

Risk

Likelihood:

  • This will occur when the total number of collections and items minted on the platform grows large enough that the computational cost of the nested loops exceeds the block gas limit.

  • This will occur regardless of the user's actual balance; even a user with zero tokens will cause the function to iterate through the entire history of the platform.

Impact:

  • The function will revert due to an Out of Gas (OOG) error, rendering it permanently unusable for on-chain interactions or off-chain queries that enforce gas limits.

  • Any frontend or external protocol relying on this function to fetch user inventory will break, resulting in a Denial of Service (DoS) for this feature.

Proof of Concept

Add this function into FestivalPass.t.sol:

function testDosAttackOnGetUserMemorabiliaDetailed() public {
// 1. Setup: Mint massive BEAT tokens to user to fund the attack
vm.prank(address(festivalPass));
beatToken.mint(user1, 10_000_000e18);
// 2. Setup: Organizer creates a cheap collection with high supply
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Lag Generator",
"ipfs://spam",
1e18, // Cheap price
10000, // High supply
true
);
// 3. The Attack: Bloat the user's inventory
// We simulate a user accumulating a large number of items.
// If the contract iterates over every token to build the response,
// gas usage will scale linearly (O(n)) or worse.
uint256 itemsToMint = 1500;
vm.startPrank(user1);
for(uint256 i = 0; i < itemsToMint; i++) {
festivalPass.redeemMemorabilia(collectionId);
}
vm.stopPrank();
// 4. Measure Gas Usage
uint256 startGas = gasleft();
// Call the vulnerable view function
festivalPass.getUserMemorabiliaDetailed(user1);
uint256 gasUsed = startGas - gasleft();
// If gas used is excessively high (e.g., > 2-3 million for a read),
// it confirms the DoS risk.
assertTrue(gasUsed > 2_000_000, "VULNERABILITY CONFIRMED: Function is not scalable");
}

Explanation

The included test, testDosAttackOnGetUserMemorabiliaDetailed, simulates a scenario where a dedicated user accumulates a significant number of memorabilia items.

  • Setup: The test funds a user and creates a low-cost, high-supply collection to facilitate bulk purchasing.

  • Action: The user mints 1,500 items. This bloats the array that the contract must iterate over.

  • Measurement: The test calls getUserMemorabiliaDetailed and measures the gas consumption.

  • Result: The assertion confirms that retrieving data for 1,500 items consumes over 2,000,000 gas.

  • If the inventory grows to ~10,000 items (a reasonable number for low-cost POAPs or collectibles), the function would require ~13M+ gas.

  • At ~20,000 items, it would exceed the Ethereum block gas limit entirely, making the data mathematically impossible to retrieve in a single call.

Recommended Mitigation

Avoid iterating over the entire global state to find user-specific data.

  • Use Enumerable Extensions: Implement a standard enumerable pattern (like OpenZeppelin's ERC721Enumerable) that maintains a mapping of owner => tokenId\[]. This allows you to iterate only over the tokens a specific user actually owns.

  • Off-Chain Indexing: If strict on-chain enumeration is not required for contract logic, remove this function entirely and rely on an off-chain indexer (like The Graph) to aggregate user holdings.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 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!