Beatland Festival

First Flight #44
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Denial of Service: Unbounded nested loops in `getUserMemorabiliaDetailed` function

Root + Impact

Description

  • The getUserMemorabiliaDetailed function in FestivalPass.sol contains nested loops that iterate through all collections and all items within each collection. As the number of collections and items increases, this function will consume an increasingly greater amount of gas, eventually reaching the block gas limit and becoming unusable.

function getUserMemorabiliaDetailed(address user) external view returns (
uint256[] memory tokenIds,
uint256[] memory collectionIds,
uint256[] memory itemIds
) {
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);
}

Risk

Likelihood:

  • The function will become completely unusable as the protocol scales

  • Users will be unable to retrieve their memorabilia details

Impact:

  • Users will be unable to retrieve their memorabilia details

  • Front-end applications relying on this function will break

  • Gas costs will become prohibitively expensive even before hitting the gas limit

Proof of Concept

The POC is a test that is run to show the gas consumption when there are numerous collections and items in the contract. Running the test with `forge test --match-contract DoSUnboundedLoopTest -vv` will show the gas consumption for different collection and item sizes.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "forge-std/Test.sol";
import "../src/FestivalPass.sol";
import "../src/BeatToken.sol";
contract DoSUnboundedLoopTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
address public organizer = makeAddr("organizer");
address public user = makeAddr("user");
uint256 constant GENERAL_PRICE = 0.05 ether;
uint256 constant VIP_PRICE = 0.1 ether;
uint256 constant BACKSTAGE_PRICE = 0.25 ether;
uint256 constant GENERAL_MAX_SUPPLY = 5000;
uint256 constant VIP_MAX_SUPPLY = 1000;
uint256 constant BACKSTAGE_MAX_SUPPLY = 100;
function setUp() public {
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.startPrank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, GENERAL_MAX_SUPPLY);
festivalPass.configurePass(2, VIP_PRICE, VIP_MAX_SUPPLY);
festivalPass.configurePass(3, BACKSTAGE_PRICE, BACKSTAGE_MAX_SUPPLY);
vm.stopPrank();
vm.deal(user, 100 ether);
vm.prank(address(festivalPass));
beatToken.mint(user, 1000000e18);
}
function testDoSWithManyCollections() public {
// User must buy a pass first
vm.prank(user);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
// Create many collections as organizer - scale up significantly
vm.startPrank(organizer);
// Create 200 collections with 50 items each = 10,000 total items to iterate through
for (uint256 i = 0; i < 200; i++) {
festivalPass.createMemorabiliaCollection(
string(abi.encodePacked("Collection ", vm.toString(i))),
"ipfs://test",
1e18, // 1 BEAT price
50, // 50 max supply per collection
true // activate now
);
}
vm.stopPrank();
// User redeems some memorabilia from different collections
vm.startPrank(user);
// Redeem 1 item from first 50 collections (50 items owned)
for (uint256 collectionId = 100; collectionId < 150; collectionId++) {
festivalPass.redeemMemorabilia(collectionId);
}
vm.stopPrank();
// Measure gas consumption - this demonstrates the DoS vulnerability
uint256 gasBefore = gasleft();
vm.prank(user);
festivalPass.getUserMemorabiliaDetailed(user);
uint256 gasUsed = gasBefore - gasleft();
console.log("Gas used with 200 collections (50 items owned):", gasUsed);
console.log(
"Total iterations performed: 200 collections * ~25 items avg = ~5000 iterations"
);
// Demonstrate the vulnerability: gas usage is extremely high
assertTrue(
gasUsed > 500000,
"Gas usage should be substantial with many collections"
);
// Show extrapolation to real DoS scenario
uint256 estimatedGasFor1000Collections = gasUsed * 5; // 1000 collections vs 200
console.log(
"Estimated gas for 1000 collections:",
estimatedGasFor1000Collections
);
if (estimatedGasFor1000Collections > 30000000) {
console.log(
"CRITICAL: DoS vulnerability confirmed - would exceed block gas limit!"
);
}
console.log(
"DoS vulnerability demonstrated through gas consumption scaling"
);
}
function testDoSWithManyItemsPerCollection() public {
// User must buy a pass first
vm.prank(user);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
// Create one collection with many items
vm.prank(organizer);
uint256 collectionId = festivalPass.createMemorabiliaCollection(
"Large Collection",
"ipfs://test",
1e18,
5000, // 5000 max supply - much larger
true
);
// User redeems many items from this collection
vm.startPrank(user);
for (uint256 i = 0; i < 500; i++) {
festivalPass.redeemMemorabilia(collectionId);
}
vm.stopPrank();
// Measure gas consumption for many items in single collection
uint256 gasBefore = gasleft();
vm.prank(user);
festivalPass.getUserMemorabiliaDetailed(user);
uint256 gasUsed = gasBefore - gasleft();
console.log("Gas used with 1 collection (500 items owned):", gasUsed);
console.log("Inner loop iterations: 500 items in collection");
// Demonstrate the vulnerability: gas usage scales with items per collection
assertTrue(
gasUsed > 1500000,
"Gas usage should be substantial with many items"
);
// Show extrapolation to DoS scenario
uint256 estimatedGasFor5000Items = (gasUsed * 5000) / 500;
console.log(
"Estimated gas for 5000 items in one collection:",
estimatedGasFor5000Items
);
if (estimatedGasFor5000Items > 30000000) {
console.log(
"CRITICAL: DoS vulnerability confirmed - inner loop would exceed block gas limit!"
);
}
console.log(
"DoS vulnerability demonstrated through inner loop scaling"
);
}
function testDoSGasUsageComparison() public {
// User must buy a pass first
vm.prank(user);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
// Test with small number first
vm.prank(organizer);
uint256 collectionId1 = festivalPass.createMemorabiliaCollection(
"Small Collection",
"ipfs://test",
1e18,
10,
true
);
// User redeems 5 items
vm.startPrank(user);
for (uint256 i = 0; i < 5; i++) {
festivalPass.redeemMemorabilia(collectionId1);
}
vm.stopPrank();
// Measure gas for small collection
uint256 gasBefore = gasleft();
vm.prank(user);
festivalPass.getUserMemorabiliaDetailed(user);
uint256 gasUsedSmall = gasBefore - gasleft();
console.log("Gas used for small collection (5 items):", gasUsedSmall);
// Now create a larger collection
vm.prank(organizer);
uint256 collectionId2 = festivalPass.createMemorabiliaCollection(
"Large Collection",
"ipfs://test",
1e18,
1000,
true
);
// User redeems 200 more items
vm.startPrank(user);
for (uint256 i = 0; i < 200; i++) {
festivalPass.redeemMemorabilia(collectionId2);
}
vm.stopPrank();
// Measure gas for larger collection
gasBefore = gasleft();
vm.prank(user);
festivalPass.getUserMemorabiliaDetailed(user);
uint256 gasUsedLarge = gasBefore - gasleft();
console.log(
"Gas used for large collection (205 total items):",
gasUsedLarge
);
console.log("Gas increase factor:", gasUsedLarge / gasUsedSmall);
// Show that gas usage grows significantly (more realistic expectation)
assertTrue(
gasUsedLarge > gasUsedSmall * 2,
"Gas usage should increase significantly"
);
// Demonstrate the vulnerability: gas usage grows with collection size
// In a real scenario with thousands of collections/items, this would exceed block gas limit
console.log("DoS Vulnerability Demonstrated:");
console.log("- Gas usage scales with number of collections and items");
console.log(
"- With enough collections/items, getUserMemorabiliaDetailed will exceed block gas limit"
);
console.log("- Current Ethereum block gas limit: ~30M gas");
console.log(
"- Extrapolated gas for 10,000 items: ~",
(gasUsedLarge * 10000) / 205,
"gas"
);
}
function testDoSWithExtremeLoad() public {
// User must buy a pass first
vm.prank(user);
festivalPass.buyPass{value: GENERAL_PRICE}(1);
// Create many collections to simulate real DoS scenario
vm.startPrank(organizer);
// Create 100 collections with 100 items each = 10,000 total items to iterate through
for (uint256 i = 0; i < 100; i++) {
festivalPass.createMemorabiliaCollection(
string(abi.encodePacked("Collection ", vm.toString(i))),
"ipfs://test",
1e18,
100, // 100 max supply per collection
true
);
}
vm.stopPrank();
// User redeems items from many collections
vm.startPrank(user);
// Redeem 1 item from first 50 collections (50 items owned)
for (uint256 collectionId = 100; collectionId < 150; collectionId++) {
festivalPass.redeemMemorabilia(collectionId);
}
vm.stopPrank();
// Measure gas usage - this should be very high
uint256 gasBefore = gasleft();
vm.prank(user);
festivalPass.getUserMemorabiliaDetailed(user);
uint256 gasUsed = gasBefore - gasleft();
console.log("Gas used with 100 collections (50 items owned):", gasUsed);
console.log("Estimated gas for 1000 collections:", gasUsed * 10);
console.log(
"Block gas limit (~30M) would be exceeded with ~",
30000000 / gasUsed,
"x this load"
);
// This demonstrates the DoS vulnerability exists
assertTrue(
gasUsed > 100000,
"Gas usage should be substantial with many collections"
);
// Show that with enough scale, this becomes a real DoS
uint256 estimatedGasFor1000Collections = gasUsed * 10;
if (estimatedGasFor1000Collections > 30000000) {
console.log(
"CRITICAL: Estimated gas exceeds typical block gas limit!"
);
console.log("This confirms the DoS vulnerability exists");
}
}
}

Recommended Mitigation

The architectural design should be changed to track user collections instead of looping through all the collections and items.

- function getUserMemorabiliaDetailed(address user) external view returns (...) {
uint256 count = 0;
for (uint256 cId = 1; cId < nextCollectionId; cId++) { // ❌ Unbounded outer loop
for (uint256 iId = 1; iId < collections[cId].currentItemId; iId++) { // ❌ Unbounded inner loop
uint256 tokenId = encodeTokenId(cId, iId);
if (balanceOf(user, tokenId) > 0) {
count++;
}
}
}
// ... more unbounded loops for population
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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