MyCut

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

Push-based distribution in `Pot::closePot` loops over every claimant, so a large number of claimants or one reverting transfer blocks closing forever

Root + Impact

Description

  • closePot pays the manager cut and then sends the bonus to every claimant in a single transaction.

  • The loop is unbounded and every iteration performs an external transfer. The cost grows linearly with claimants.length: about 12k gas per claimant with cold storage. From about 1,400 claimants the call exceeds the 2^24 (16,777,216) per-transaction gas cap introduced by EIP-7825. Independently of gas, if a single transfer reverts (e.g. a claimant blacklisted by USDC/USDT after claiming), the whole closePot reverts. closePot cannot be retried partially, so the Pot can never be closed.

uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
@> for (uint256 i = 0; i < claimants.length; i++) {
@> _transferReward(claimants[i], claimantCut); // one revert = whole close reverts
}

Risk

Likelihood:

  • When a contest has well over a thousand winners who claim, for example large community or airdrop-style contests. claimants grows with every claimCut, and nothing caps it.

  • When a claimant's address gets blacklisted by the token issuer between claiming and closing. USDC and USDT, the most common reward tokens, both have blacklists.

Impact:

  • closePot reverts permanently. The manager cut and the whole leftover pool stay locked in the Pot.

  • Claimants who claimed in time never receive their bonus.

Proof of Concept

Gas. 2,000 players, 1,999 claim, one does not. Run with forge test --isolate so storage is cold, as in a real transaction:

function testClosePotGasGrowsWithClaimants() public {
uint256 n = 2000;
ERC20Mock token = new ERC20Mock("W", "W", admin, 0);
uint256 total = (n - 1) * 10 + 1_000_000;
token.mint(admin, total);
address[] memory players = new address[](n);
uint256[] memory rewards = new uint256[](n);
for (uint256 i = 0; i < n; i++) {
players[i] = address(uint160(0x10000 + i));
rewards[i] = 10;
}
rewards[n - 1] = 1_000_000; // never claimed
vm.startPrank(admin);
token.approve(address(conMan), total);
Pot pot = Pot(conMan.createContest(players, rewards, IERC20(address(token)), total));
conMan.fundContest(0);
vm.stopPrank();
for (uint256 i = 0; i < n - 1; i++) {
vm.prank(players[i]);
pot.claimCut();
}
vm.warp(block.timestamp + 90 days);
vm.prank(admin);
uint256 gasBefore = gasleft();
conMan.closeContest(address(pot));
uint256 gasUsed = gasBefore - gasleft(); // 24,225,177
assertGt(gasUsed, 16_777_216); // above the EIP-7825 tx cap
}
//Blacklist. Uses a token that reverts on transfers to blacklisted addresses, like USDC:
contract BlacklistToken is ERC20 {
mapping(address => bool) public blacklisted;
constructor() ERC20("Black", "BLK") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
function setBlacklisted(address a, bool v) external { blacklisted[a] = v; }
function _update(address from, address to, uint256 value) internal override {
require(!blacklisted[to] && !blacklisted[from], "blacklisted");
super._update(from, to, value);
}
}
function testBlacklistedClaimantBlocksClosePot() public {
BlacklistToken token = new BlacklistToken();
token.mint(admin, 100);
address[] memory players = new address[](3);
players[0] = player1; players[1] = player2; players[2] = player3;
uint256[] memory rewards = new uint256[](3);
rewards[0] = 40; rewards[1] = 30; rewards[2] = 30;
vm.startPrank(admin);
token.approve(address(conMan), 100);
Pot pot = Pot(conMan.createContest(players, rewards, IERC20(address(token)), 100));
conMan.fundContest(0);
vm.stopPrank();
vm.prank(player1);
pot.claimCut();
vm.prank(player2);
pot.claimCut();
token.setBlacklisted(player2, true); // after claiming
vm.warp(block.timestamp + 90 days);
vm.prank(admin);
vm.expectRevert("blacklisted");
conMan.closeContest(address(pot)); // can never succeed
}

Recommended Mitigation

Switch to a pull pattern. closePot only records the per-claimant bonus, and each claimant withdraws it.

claimCut sets isClaimant[player] = true. One blacklisted or expensive claimant then only affects their own withdrawal.

uint256 private claimantBonus;
+ bool private closed;
+ mapping(address => bool) private bonusClaimed;
+ mapping(address => bool) private isClaimant;
function closePot() external onlyOwner {
...
- uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
- for (uint256 i = 0; i < claimants.length; i++) {
- _transferReward(claimants[i], claimantCut);
- }
+ claimantBonus = (remainingRewards - managerCut) / claimants.length;
+ closed = true;
}
+ function claimBonus() external {
+ require(closed && isClaimant[msg.sender] && !bonusClaimed[msg.sender]);
+ bonusClaimed[msg.sender] = true;
+ _transferReward(msg.sender, claimantBonus);
+ }
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!