MyCut

AI First Flight #8
Beginner FriendlyFoundry
EXP
View results
Submission Details
Severity: high
Valid

[M-5] Unbounded Storage Growth of `claimants` Can Cause Permanent DoS

[M-5] Unbounded Storage Growth of claimants Can Cause Permanent DoS

Description

Expected Behavior

The contract owner should always be able to successfully execute closePot() after the contest deadline in order to:

  • Redistribute unclaimed rewards

  • Finalize the contest

  • Prevent funds from being locked indefinitely

Actual Behavior

The contract stores every claimant address in an unbounded dynamic array:

address[] private claimants;

Each call to claimCut() appends an entry to this array.
During closePot(), the contract iterates over the entire claimants array:

for (uint256 i = 0; i < claimants.length; i++) {
// redistribution logic
}

Because the array:

  • Has no upper bound

  • Is never pruned

  • Grows linearly with participation

The gas cost of closePot() increases linearly with the number of claimants.
At sufficiently large sizes, closePot() will always revert due to out-of-gas, permanently locking funds.


Risk Assessment

Likelihood — Medium

  • Common in contests with large participation

  • Each claimCut() permanently increases storage

  • No safeguards or caps exist on claimant count

Impact — High

  • closePot() becomes permanently uncallable

  • Unclaimed rewards are locked forever

  • Contest manager cannot receive their cut

  • Contract becomes effectively bricked

Severity — Medium (M)

This issue does not directly enable theft but causes permanent loss of functionality and locked funds, which meets Medium severity criteria.


Proof of Concept

This test demonstrates that:

  • Gas usage of closePot() grows linearly with the number of claimants

  • At scale (hundreds or thousands of claimants), execution will exceed the block gas limit

  • This creates a permanent DoS vector, not a temporary inefficiency

Rather than relying on an unreliable expectRevert() due to test-environment gas limits, this PoC measures gas growth and extrapolates real-world failure.

function test_UnboundedClaimantsArray_DoS_PoC() public {
uint256 numPlayers = 50;
address[] memory manyPlayers = new address[](numPlayers);
uint256[] memory manyRewards = new uint256[](numPlayers);
uint256 totalReward;
// Setup players and rewards
for (uint256 i = 0; i < numPlayers; i++) {
manyPlayers[i] = makeAddr(string(abi.encodePacked("player", i)));
manyRewards[i] = 0.1 ether;
totalReward += 0.1 ether;
}
// Deploy pot
vm.prank(user);
Pot largePot = new Pot(manyPlayers, manyRewards, weth, totalReward);
// Fund the pot
vm.prank(user);
weth.transfer(address(largePot), totalReward);
// Simulate half of players claiming
for (uint256 i = 0; i < 25; i++) {
vm.prank(manyPlayers[i]);
largePot.claimCut();
}
console2.log("Number of claimants:", uint256(25));
console2.log("Unclaimed participants:", uint256(numPlayers - 25));
// Advance time beyond contest deadline
vm.warp(block.timestamp + 91 days);
// Measure gas usage of closePot()
uint256 gasBefore = gasleft();
vm.prank(user);
largePot.closePot();
uint256 gasUsed = gasBefore - gasleft();
console2.log("Gas used for closePot() with 25 claimants:", gasUsed);
console2.log("Estimated gas for 500 claimants:", gasUsed * 20);
console2.log("Estimated gas for 1000 claimants:", gasUsed * 40);
console2.log("Approximate block gas limit:", uint256(30_000_000));
// Demonstrates linear gas growth
assertGt(gasUsed, 100_000, "Gas usage grows with number of claimants");
}

Recommended Mitigation

Use Pull-Based Withdrawals (Recommended)

Instead of redistributing funds in closePot(), store unclaimed rewards and allow users to withdraw individually:

mapping(address => uint256) public pendingRewards;
function claimUnclaimed() external {
uint256 amount = pendingRewards[msg.sender];
require(amount > 0, "Nothing to claim");
pendingRewards[msg.sender] = 0;
token.safeTransfer(msg.sender, amount);
}

Why this works:

  • O(1) gas per call

  • No loops

  • No DoS risk

  • Industry-standard pattern (used by Uniswap, Aave)


Updates

Lead Judging Commences

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

[H-04] Gas Limit DoS via large amount of claimants

## Description The `Pot.sol` contract contains a vulnerability that can lead to a Denial of Service (DoS) attack. This issue arises from the inefficient handling of claimants in the `closePot` function, where iterating over a large number of claimants can cause the transaction to run out of gas, thereby preventing the contract from executing as intended. ## Vulnerability Details Affected code - <https://github.com/Cyfrin/2024-08-MyCut/blob/946231db0fe717039429a11706717be568d03b54/src/Pot.sol#L58> The vulnerability is located in the `closePot` function of the Pot contract, specifically at the loop iterating over the claimants array: ```javascript function closePot() external onlyOwner { ... if (remainingRewards > 0) { ... @> for (uint256 i = 0; i < claimants.length; i++) { _transferReward(claimants[i], claimantCut); } } } ``` The `closePot` function is designed to distribute remaining rewards to claimants after a contest ends. However, if the number of claimants is extremly large, the loop iterating over the claimants array can consume a significant amount of gas. This can lead to a situation where the transaction exceeds the gas limit and fails, effectively making it impossible to close the pot and distribute the rewards. ## Exploit 1. Attacker initiates a big contest with a lot of players 2. People claim the cut 3. Owner closes the large pot that will be very costly ```javascript function testGasCostForClosingPotWithManyClaimants() public mintAndApproveTokens { // Generate 2000 players address[] memory players2000 = new address[](2000); uint256[] memory rewards2000 = new uint256[](2000); for (uint256 i = 0; i < 2000; i++) { players2000[i] = address(uint160(i + 1)); rewards2000[i] = 1 ether; } // Create a contest with 2000 players vm.startPrank(user); contest = ContestManager(conMan).createContest(players2000, rewards2000, IERC20(ERC20Mock(weth)), 2000 ether); ContestManager(conMan).fundContest(0); vm.stopPrank(); // Allow 1500 players to claim their cut for (uint256 i = 0; i < 1500; i++) { vm.startPrank(players2000[i]); Pot(contest).claimCut(); vm.stopPrank(); } // Fast forward time to allow closing the pot vm.warp(91 days); // Record gas usage for closing the pot vm.startPrank(user); uint256 gasBeforeClose = gasleft(); ContestManager(conMan).closeContest(contest); uint256 gasUsedClose = gasBeforeClose - gasleft(); vm.stopPrank(); console.log("Gas used for closing pot with 1500 claimants:", gasUsedClose); } ``` ```Solidity Gas used for closing pot with 1500 claimants: 6425853 ``` ## Impact The primary impact of this vulnerability is a Denial of Service (DoS) attack vector. An attacker (or even normal usage with a large number of claimants) can cause the `closePot` function to fail due to excessive gas consumption. This prevents the distribution of remaining rewards and the execution of any subsequent logic in the function, potentially locking funds in the contract indefinitely. In the case of smaller pots it would be a gas inefficency to itterate over the state variabel `claimants`. ## Recommendations Gas Optimization: Optimize the loop to reduce gas consumption by using a local variable to itterate over, like in the following example: ```diff - for (uint256 i = 0; i < claimants.length; i++) { - _transferReward(claimants[i], claimantCut); - } + uint256 claimants_length = claimants.length; + ... + for (uint256 i = 0; i < claimants_length; i++) { + _transferReward(claimants[i], claimantCut); + } ``` Batch Processing: Implement batch processing for distributing rewards. This will redesign the protocol functionallity but instead of processing all claimants in a single transaction, allow the function to process a subset of claimants per transaction. This can be achieved by introducing pagination or limiting the number of claimants processed in one call. This could also be fixed if the user would claim their reward after 90 days themselves

Support

FAQs

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

Give us feedback!