MyCut

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

Gas Denial-of-Service via Unbounded claimants Array

Root Cause: The contract stores every claimant in a dynamic array (claimants) and iterates over this array in closePot(), causing gas costs to scale linearly with the number of claimants.

Description

In normal operation, users call claimCut() to receive their allocated rewards, and the contract records each claimant by pushing their address to the claimants array. Later, the owner calls closePot() after 90 days to distribute any remaining rewards, which iterates through all entries in the claimants array to redistribute unclaimed funds.

However, the claimants array grows without bound as more users claim rewards. The closePot() function contains a loop that iterates over every element in this array, making its gas cost directly proportional to the number of claimants. With a sufficiently large number of claimants (typically >1,000–2,000), the transaction will exceed Ethereum's block gas limit and permanently fail.

https://github.com/CodeHawks-Contests/ai-mycut/blob/819134663950999ca5ab29a91eeceddb80274743/src/Pot.sol#L45-59

Risk

Likelihood: High

  • The issue will occur whenever the number of claimants exceeds approximately 1,000–2,000 addresses, which is easily achievable in any reasonably successful reward distribution.

  • The contract design inherently encourages participation, making large numbers of claimants a realistic and expected scenario rather than an edge case.

Impact:

  • The closePot() function becomes unusable, preventing the contract owner from claiming their manager cut of unclaimed rewards.

  • Funds may become permanently locked in the contract if the redistribution logic cannot execute, or the economic model breaks down entirely


Proof of Concept

Proof of Concept
Consider a scenario with 10,000 participants:
All 10,000 users call claimCut() successfully
The claimants array now contains 10,000 addresses
After 90 days, the owner attempts to call closePot()
The loop in closePot() attempts to iterate 10,000 times
The transaction requires ~510 million gas (conservative estimate), exceeding Ethereum's block gas limit (~30 million as of 2025, but function calls typically fail well before this due to internal limitations)
The transaction reverts, and the owner cannot close the pot
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract TestToken is ERC20 {
constructor() ERC20("TestToken", "TEST") {
_mint(msg.sender, 100_000_000 ether);
}
}
contract VulnerablePot {
address[] public claimants;
mapping(address => uint256) public playersToRewards;
uint256 public remainingRewards = 0,
TestToken public token;
uint256 public deployedAt;
uint256 private constant managerCutPercent = 10;
constructor(uint256 numPlayers) {
token = new TestToken();
deployedAt = block.timestamp;
for (uint256 i = 1; i <= numPlayers; i++) {
address player = address(uint160(i));
playersToRewards[player] = 1 wei;
}
token.transfer(address(this), numPlayers * 1 wei);
remainingRewards = numPlayers * 1 wei;
}
function claimCut() external {
uint256 reward = playersToRewards[msg.sender];
require(reward > 0, "No reward");
playersToRewards[msg.sender] = 0;
remainingRewards -= reward;
claimants.push(msg.sender);
token.transfer(msg.sender, reward);
}
function closePot() external {
require(block.timestamp >= deployedAt + 90 days, "Too early");
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
token.transfer(msg.sender, managerCut);
uint256 claimantCut = (remainingRewards - managerCut) / claimants.length;
for (uint256 i = 0; i < claimants.length; i++) {
token.transfer(claimants[i], claimantCut);
}
}
}
function getClaimantsCount() external view returns (uint256) {
return claimants.length;
}
function fund(uint256 amount) external {
token.transfer(address(this), amount);
remainingRewards += amount;
}
}

Recommended Mitigation

- for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
+ Replace the claimants array with a mapping(address => bool) hasClaimed and eliminate the loop in closePot()
Updates

Lead Judging Commences

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