Beatland Festival

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

Unbounded loops in `getUserMemorabiliaDetailed()` can cause permanent DoS

[H-6] Unbounded loops in getUserMemorabiliaDetailed() can cause permanent DoS

Description

The getUserMemorabiliaDetailed() function contains nested unbounded loops that iterate through all collection IDs and all items within each collection. There are no limits on:

  1. The number of collections the organizer can create

  2. The maxSupply (and thus currentItemId) for each collection

function getUserMemorabiliaDetailed(address user) external view returns (...) {
uint256 count = 0;
for (uint256 cId = 1; cId < nextCollectionId; cId++) { // @audit Unbounded - organizer can create unlimited collections
for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) { // @audit Unbounded - no limit on currentItemId
uint256 tokenId = encodeTokenId(cId, iId);
if (balanceOf(user, tokenId) > 0) {
count++;
}
}
}
// ... second identical nested loop
}

If the organizer creates many collections with high item counts, the gas cost can exceed the block gas limit, making this function permanently uncallable.

Impact

HIGH severity due to permanent DoS:

  • Permanent DoS: Once gas cost exceeds block gas limit (~30M gas), the function will always revert with out-of-gas error

  • Loss of Functionality: Users and front-ends cannot retrieve user memorabilia data, breaking core protocol functionality

  • Griefing Attack: Malicious or compromised organizer can intentionally DoS the function by creating many collections

  • Front-end Breaking: Any dApp relying on this view function will be unable to display user collections

  • No Recovery: Once DoS occurs, the function is permanently broken with no way to recover

Proof of Concept

Attack scenario:

  1. Organizer creates 100 collections, each with maxSupply = 1000

  2. Total iterations: 100 collections × 1000 items = 100,000 iterations per loop

  3. Function has TWO identical nested loops, so 200,000 total iterations

  4. Each iteration performs: encodeTokenId(), balanceOf() (SLOAD), and comparisons

Gas estimation:

  • balanceOf(): ~2,100 gas per call (cold SLOAD)

  • 200,000 iterations × 2,100 gas ≈ 420,000,000 gas

  • Block gas limit: ~30,000,000 gas

  • Result: Function exceeds block gas limit by 14x and always reverts

Even with fewer collections, the quadratic complexity makes DoS inevitable as the protocol grows.

Mitigation

Implement pagination to limit iterations per call:

function getUserMemorabiliaDetailed(
address user,
uint256 startCollectionId,
uint256 maxCollections
) external view returns (
uint256[] memory tokenIds,
uint256[] memory collectionIds,
uint256[] memory itemIds,
uint256 nextStartCollectionId
) {
require(maxCollections > 0 && maxCollections <= 50, "Invalid batch size");
uint256 endCollectionId = startCollectionId + maxCollections;
if (endCollectionId > nextCollectionId) {
endCollectionId = nextCollectionId;
}
// First pass: count
uint256 count = 0;
for (uint256 cId = startCollectionId; cId < endCollectionId; cId++) {
for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) {
uint256 tokenId = encodeTokenId(cId, iId);
if (balanceOf(user, tokenId) > 0) {
count++;
}
}
}
// Second pass: populate arrays
tokenIds = new uint256[](count);
collectionIds = new uint256[](count);
itemIds = new uint256[](count);
uint256 index = 0;
for (uint256 cId = startCollectionId; cId < endCollectionId; 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++;
}
}
}
nextStartCollectionId = endCollectionId;
return (tokenIds, collectionIds, itemIds, nextStartCollectionId);
}

This allows callers to paginate through collections in batches (e.g., 50 at a time), preventing DoS while maintaining functionality.

Updates

Lead Judging Commences

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