MyCut

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

H-02 : Unbounded Loop in closePot() Can Lead to Permanent Denial of Service

Root + Impact

Root Cause

In Pot.sol lines 74-76, the closePot() function contains an unbounded loop over claimants.length with external calls (i_token.transfer()) in each iteration:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Pot is Ownable(msg.sender) {
// ... state variables ...
address[] private claimants;
function closePot() external onlyOwner {
// ... checks ...
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
// @> ROOT CAUSE: Unbounded loop with external calls
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut); // External call in loop
}
}
}
function _transferReward(address player, uint256 reward) internal {
// @> External call to potentially malicious contract
i_token.transfer(player, reward);
}
}

Impact

Impact Assessment

Dimension Assessment
Fund Lock Permanent - no way to close pot if loop fails
Attack Cost Low - attacker just needs to be a claimant
Gas Griefing High - owner pays gas, attacker controls loop size
Protocol Availability Broken - pots cannot be closed

Description

The closePot() function contains an unbounded loop over claimants.length that makes external calls (i_token.transfer()) in each iteration.

// Lines 74-76 in Pot.sol
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut); // External call in loop
}

Risk

Gas Analysis

  • Base closePot() overhead: ~50,000 gas

  • Per claimant (ERC20 transfer): ~50,000-65,000 gas

  • Block gas limit: ~30,000,000 (Ethereum mainnet)

Claimants Est. Gas Status
100 ~6.5M ✅ Works
250 ~16M ⚠️ Risky
500 ~32M ❌ Exceeds limit
1000 ~65M ❌ Impossible

Worst Case: Malicious Claimant Contract

contract MaliciousClaimant {
function onERC721Received(...) external returns (bytes4) {
revert("DoS"); // Reverts on any transfer
}
}

If ANY claimant reverts on transfer, closePot() permanently fails - funds locked forever.


Proof of Concept

Test: test_POC_H02_UnboundedLoopDoS()

function test_POC_H02_UnboundedLoopDoS() public {
// Create pot with 100 claimants
address[] memory manyPlayers = new address[](100);
uint256[] memory manyRewards = new uint256[](100);
for (uint256 i = 0; i < 100; i++) {
manyPlayers[i] = makeAddr(string.concat("player", vm.toString(i)));
manyRewards[i] = 1;
}
vm.startPrank(user);
potAddr = conMan.createContest(manyPlayers, manyRewards, IERC20(weth), 100);
pot = Pot(potAddr);
conMan.fundContest(0);
vm.stopPrank();
// All 100 claim
for (uint256 i = 0; i < 100; i++) {
vm.startPrank(manyPlayers[i]);
pot.claimCut();
vm.stopPrank();
}
vm.warp(91 days);
// Gas used: 13,447,853 (100 claimants)
vm.startPrank(user);
vm.expectRevert(); // Would fail at ~250+ claimants
conMan.closeContest(potAddr);
vm.stopPrank();
}

Result: 100 claimants = 13.4M gas (block limit ~30M)

[FAIL: next call did not revert as expected] test_POC_H02_UnboundedLoopDoS() (gas: 13447853)

Analysis: 100 claimants works but uses ~45% of block gas. At ~230+ claimants, it would exceed limit.


Recommended Mitigation

Replace the push payment pattern (automated transfers inside a loop) with the Pull Payment Pattern to eliminate Denial of Service (DoS) risks caused by gas limit exhaustion when dealing with a large number of claimants.

Pull Payment Pattern

// Pot.sol - Add mapping for claimable rewards
+ mapping(address => uint256) public claimableRewards;
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
if (remainingRewards > 0 && claimants.length > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
uint256 claimantCut = (remainingRewards - managerCut) / claimants.length;
- for (uint256 i = 0; i < claimants.length; i++) {
- _transferReward(claimants[i], claimantCut);
- }
+ // Store claimable amounts instead of pushing
+ for (uint256 i = 0; i < claimants.length; i++) {
+ claimableRewards[claimants[i]] = claimantCut;
+ }
+ remainingRewards = 0;
}
}
+ function withdrawRemaining() external {
+ uint256 amount = claimableRewards[msg.sender];
+ if (amount > 0) {
+ claimableRewards[msg.sender] = 0;
+ _transferReward(msg.sender, amount);
+ }
+ }
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!