MyCut

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

Pot::closePot` distributes to every claimant in one unbounded loop, so a large contest can never be closed and all remaining funds are locked

Severity: H · Impact: H · Likelihood: M

Description

  • closePot is the ONLY way to release the leftover pool, and it pays every claimant inside a single transaction via a for loop of external token transfers.

  • The number of claimants is unbounded (one per player who claimed). Gas cost grows linearly with the claimant count, so for a sufficiently large contest closePot exceeds the block gas limit and can never succeed — permanently locking the manager cut and the entire remaining pool.

// src/Pot.sol:L57-L60
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
@> for (uint256 i = 0; i < claimants.length; i++) { // unbounded: one external transfer per claimant, single tx
_transferReward(claimants[i], claimantCut);
}

Risk

Likelihood

  • Occurs whenever a contest has enough claimants that the close transaction's gas exceeds the block limit. Measured cost is ~5,278 gas/claimant, so ~5,683 claimants makes closePot uncallable on a 30M-gas chain. Contests with large player sets (airdrops, community reward rounds) reach this readily.

Impact

  • Permanent denial of service on the only fund-release path: the manager cut and the whole leftover pool are locked in the contract with no recovery.

Proof of Concept

Save as test/PoC_H02.t.sol and run forge test --mt test_unboundedLoopGasDoS -vv. It measures closePot gas at 50 and 150 claimants, shows linear growth, and extrapolates the claimant count that bricks the call.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "lib/forge-std/src/Test.sol";
import {ERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {ContestManager} from "../src/ContestManager.sol";
import {Pot} from "../src/Pot.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MCK") {}
function mint(address to, uint256 amt) external { _mint(to, amt); }
}
contract PoC_H02 is Test {
// src/Pot.sol:L58 loops over an unbounded claimants array
function test_unboundedLoopGasDoS() public {
uint256 g1 = _measureCloseGas(50);
uint256 g2 = _measureCloseGas(150);
console.log("closePot gas, 50 claimants: ", g1); // ~293,882
console.log("closePot gas, 150 claimants:", g2); // ~821,724
uint256 perClaimant = (g2 - g1) / 100;
console.log("gas per extra claimant: ", perClaimant); // ~5,278
console.log("claimants to exceed 30M gas: ", uint256(30_000_000) / perClaimant); // ~5,683
assertGt(g2, g1 * 2); // ~linear growth confirms the loop is unbounded -> eventually unclosable
}
function _measureCloseGas(uint256 n) internal returns (uint256 gasUsed) {
ContestManager m = new ContestManager();
MockERC20 t = new MockERC20();
address[] memory players = new address[](n);
uint256[] memory rewards = new uint256[](n);
for (uint256 i = 0; i < n; i++) { players[i] = address(uint160(0x100000 + i)); rewards[i] = 1; }
uint256 total = 100 * n; // positive remainder so the distribution loop actually runs
t.mint(address(this), total);
t.approve(address(m), total);
address pot = m.createContest(players, rewards, IERC20(address(t)), total);
m.fundContest(0);
for (uint256 i = 0; i < n; i++) { vm.prank(players[i]); Pot(pot).claimCut(); }
vm.warp(block.timestamp + 91 days);
uint256 before = gasleft();
m.closeContest(pot);
gasUsed = before - gasleft();
}
}

Recommended Mitigation

Replace the push loop with a pull pattern: record the per-claimant cut once (O(1)) at close, and let each claimant withdraw their share individually. This removes the unbounded loop from the only-callable-once close path.

// src/Pot.sol:L57-L60
+ uint256 public closedClaimantCut;
+ mapping(address => bool) public remainderClaimed;
...
- uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
- for (uint256 i = 0; i < claimants.length; i++) {
- _transferReward(claimants[i], claimantCut);
- }
+ closedClaimantCut = (remainingRewards - managerCut) / claimants.length; // set once, O(1)
}
}
+
+ function withdrawRemainder() external {
+ require(playersToRewards[msg.sender] == 0 && !remainderClaimed[msg.sender], "not an in-time claimant");
+ remainderClaimed[msg.sender] = true;
+ _transferReward(msg.sender, closedClaimantCut);
+ }

Why: a fixed-cost close cannot be gas-DoS'd, and each claimant bears the gas of their own withdrawal.

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!