MyCut

AI First Flight #8
Beginner FriendlyFoundry
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Missing Validation Allows Rewards Mismatch Causing Permanent DoS

## Summary
The absence of sum validation in `Pot.sol` constructor will cause permanent denial of service for later claimants as the protocol allows creation of contests where `sum(rewards[])` exceeds `totalRewards`, causing arithmetic underflow when late claimants attempt to call `claimCut()`.
## Root Cause
In `Pot.sol:22-35` the constructor does NOT validate that the rewards array sum matches totalRewards:
```solidity
constructor(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards) {
i_players = players;
i_rewards = rewards;
i_token = token;
i_totalRewards = totalRewards;
remainingRewards = totalRewards; // ✗ BUG: No validation that sum(rewards) == totalRewards
i_deployedAt = block.timestamp;
for (uint256 i = 0; i < i_players.length; i++) {
playersToRewards[i_players[i]] = i_rewards[i]; // Sets individual rewards without checking total
}
}
```
When `claimCut()` is called:
```solidity
function claimCut() public {
address player = msg.sender;
uint256 reward = playersToRewards[player];
// ...
remainingRewards -= reward; // ✗ BUG: Underflows if remainingRewards < reward
// ...
}
```
If `sum(rewards) > totalRewards`, later claimants will trigger arithmetic underflow (Solidity 0.8+ reverts).
## Internal Pre-conditions
1. Admin needs to create a contest where `sum(rewards[]) > totalRewards`
2. This can happen due to:
- Configuration error (honest mistake)
- Malicious admin attack
- UI/frontend bug passing wrong values
## External Pre-conditions
None - pure internal logic vulnerability.
## Attack Path
1. Admin creates contest with 3 players:
- `rewards = [100, 100, 100]` (300 total)
- `totalRewards = 200` (only 200 funded)
2. Player 1 calls `claimCut()` → Success, receives 100 tokens, `remainingRewards = 100`
3. Player 2 calls `claimCut()` → Success, receives 100 tokens, `remainingRewards = 0`
4. Player 3 calls `claimCut()`**REVERT!** (`0 - 100` = underflow)
5. Player 3 is permanently DoS'd, cannot claim their legitimate reward
## Impact
The affected players suffer **permanent loss of 100% of their expected rewards** when they are late to claim:
| Scenario | TotalRewards | Sum(Rewards) | Shortage | Players DoS'd |
|----------|--------------|--------------|----------|---------------|
| Slight mismatch | 250 | 300 | 50 | 1 player loses 100% |
| Severe mismatch | 100 | 300 | 200 | 2 players lose 100% |
| Extreme | 1 wei | 1000 ETH | ~1000 ETH | ALL players lose 100% |
**Race Condition Impact:**
- Early claimers get full rewards
- Late claimers get NOTHING
- Creates unfair first-come-first-served dynamics
- Encourages gas wars and MEV exploitation
**Quantified:**
- For a contest with 1000 ETH promised but only 800 ETH funded:
- First 8 players (out of 10) claim 100 ETH each
- Last 2 players: **200 ETH permanently locked/lost**
## PoC
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {ContestManager} from "../../src/ContestManager.sol";
import {Pot} from "../../src/Pot.sol";
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
/**
* @title ERC20Mock - Simple mock ERC20 for testing
*/
contract ERC20Mock is IERC20 {
string public name;
string public symbol;
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
constructor(string memory _name, string memory _symbol) {
name = _name;
symbol = _symbol;
}
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
totalSupply += amount;
}
function transfer(address to, uint256 amount) external returns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
}
/**
* @title RewardsMismatchDoSPoC
* @notice PoC demonstrating DoS attack via rewards array / totalRewards mismatch
*
* ## Vulnerability Summary
* The Pot constructor does NOT validate that sum(rewards[]) == totalRewards.
* This allows creation of a Pot where:
* - rewards array promises more than totalRewards available
* - Later claims cause arithmetic underflow in remainingRewards, reverting
* - Legitimate players are DoS'd from claiming their rewards
*
* ## Root Cause
* No validation in Pot constructor:
* ```solidity
* constructor(...) {
* // Missing: require(sum(rewards) == totalRewards)
* remainingRewards = totalRewards; // Can be less than sum(rewards)
* }
* ```
*
* ## Attack Scenario
* 1. Admin (malicious or by mistake) creates contest with:
* - rewards = [100, 100] (200 total needed)
* - totalRewards = 150 (only 150 available)
* 2. Player A claims 100 tokens -> remainingRewards = 50
* 3. Player B tries to claim 100 tokens -> underflow revert!
*/
contract RewardsMismatchDoSPoC is Test {
ContestManager public contestManager;
ERC20Mock public token;
address public admin = makeAddr("admin");
address public player1 = makeAddr("player1");
address public player2 = makeAddr("player2");
address public player3 = makeAddr("player3");
function setUp() public {
vm.startPrank(admin);
contestManager = new ContestManager();
token = new ERC20Mock("Test Token", "TEST");
vm.stopPrank();
}
/**
* @notice Test: Misconfigured totalRewards causes DoS for later claimants
*/
function test_RewardsMismatch_DoS_LaterClaimants() public {
console.log("=== Rewards Mismatch DoS Vulnerability PoC ===");
console.log("");
// Step 1: Setup misconfigured contest
// rewards array totals 300, but totalRewards is only 200
address[] memory players = new address[](3);
players[0] = player1;
players[1] = player2;
players[2] = player3;
uint256[] memory rewards = new uint256[](3);
rewards[0] = 100 ether; // Player 1 gets 100
rewards[1] = 100 ether; // Player 2 gets 100
rewards[2] = 100 ether; // Player 3 gets 100
// Total promised: 300 ether
uint256 totalRewards = 200 ether; // Only 200 available - MISMATCH!
vm.startPrank(admin);
token.mint(admin, totalRewards);
token.approve(address(contestManager), totalRewards);
address potAddress = contestManager.createContest(
players,
rewards,
IERC20(address(token)),
totalRewards
);
contestManager.fundContest(0);
vm.stopPrank();
Pot pot = Pot(potAddress);
console.log("[SETUP - MISCONFIGURATION]");
console.log(" Players: 3");
console.log(" Promised per player: 100 tokens");
console.log(" Total promised: 300 tokens");
console.log(" Total funded: 200 tokens (MISMATCH!)");
console.log(" remainingRewards: %s tokens", pot.getRemainingRewards() / 1e18);
console.log("");
// Step 2: Player 1 claims successfully
console.log("[CLAIM 1] Player1 claims 100 tokens...");
vm.prank(player1);
pot.claimCut();
uint256 p1Balance = token.balanceOf(player1);
uint256 remaining = pot.getRemainingRewards();
console.log(" Player1 received: %s tokens", p1Balance / 1e18);
console.log(" remainingRewards: %s tokens", remaining / 1e18);
console.log(" SUCCESS!");
console.log("");
// Step 3: Player 2 claims successfully (barely)
console.log("[CLAIM 2] Player2 claims 100 tokens...");
vm.prank(player2);
pot.claimCut();
uint256 p2Balance = token.balanceOf(player2);
remaining = pot.getRemainingRewards();
console.log(" Player2 received: %s tokens", p2Balance / 1e18);
console.log(" remainingRewards: %s tokens", remaining / 1e18);
console.log(" SUCCESS!");
console.log("");
// Step 4: Player 3 CANNOT claim - arithmetic underflow!
console.log("[CLAIM 3] Player3 tries to claim 100 tokens...");
console.log(" remainingRewards = %s, claim amount = 100", remaining / 1e18);
console.log(" Underflow: 0 - 100 = REVERT!");
console.log("");
// Expect revert due to underflow (Solidity 0.8+)
vm.prank(player3);
vm.expectRevert(); // Arithmetic underflow
pot.claimCut();
console.log("[RESULT - DoS CONFIRMED]");
console.log(" Player1: Claimed 100 tokens (SUCCESS)");
console.log(" Player2: Claimed 100 tokens (SUCCESS)");
console.log(" Player3: PERMANENTLY BLOCKED (DoS!)");
console.log("");
console.log(" Player3's 100 tokens are UNRECOVERABLE");
console.log("");
console.log("=== PoC SUCCESSFUL: Player permanently DoS'd ===");
}
/**
* @notice FIRST claimer gets DoS'd if totalRewards < first reward
*/
function test_FirstClaimDoS_ExtremeCase() public {
console.log("=== Extreme Case: Even FIRST Claim DoS'd ===");
console.log("");
address[] memory players = new address[](1);
players[0] = player1;
uint256[] memory rewards = new uint256[](1);
rewards[0] = 100 ether; // Player expects 100
uint256 totalRewards = 50 ether; // Only 50 funded - SEVERE MISMATCH
vm.startPrank(admin);
token.mint(admin, totalRewards);
token.approve(address(contestManager), totalRewards);
address potAddress = contestManager.createContest(
players,
rewards,
IERC20(address(token)),
totalRewards
);
contestManager.fundContest(0);
vm.stopPrank();
Pot pot = Pot(potAddress);
console.log("[SETUP]");
console.log(" Player1 expects: 100 tokens");
console.log(" But totalRewards: 50 tokens");
console.log(" remainingRewards: %s tokens", pot.getRemainingRewards() / 1e18);
console.log("");
console.log("[CLAIM] Player1 tries to claim...");
console.log(" Calculation: remainingRewards (50) - reward (100) = UNDERFLOW!");
console.log("");
// Even the FIRST claim reverts!
vm.prank(player1);
vm.expectRevert();
pot.claimCut();
console.log("[RESULT]");
console.log(" Player1 CANNOT claim their reward");
console.log(" 50 tokens STUCK in Pot forever");
console.log(" 100% DoS - NO ONE can claim!");
console.log("");
console.log("=== CRITICAL: Complete protocol freeze ===");
}
/**
* @notice Verify NO validation exists in constructor
*/
function test_NoValidation_AllowsArbitraryMismatch() public {
console.log("=== Proof: No Constructor Validation ===");
console.log("");
address[] memory players = new address[](2);
players[0] = player1;
players[1] = player2;
// Extreme mismatch: 1000 tokens promised, 1 wei funded
uint256[] memory rewards = new uint256[](2);
rewards[0] = 500 ether;
rewards[1] = 500 ether;
uint256 totalRewards = 1; // 1 wei!
vm.startPrank(admin);
token.mint(admin, totalRewards);
token.approve(address(contestManager), totalRewards);
// This SHOULD revert but doesn't!
address potAddress = contestManager.createContest(
players,
rewards,
IERC20(address(token)),
totalRewards
);
// Successfully funds with 1 wei!
contestManager.fundContest(0);
vm.stopPrank();
Pot pot = Pot(potAddress);
console.log("[CREATED - NO VALIDATION!]");
console.log(" Total promised: 1000 tokens");
console.log(" Actually funded: 1 wei");
console.log(" Pot balance: %s wei", token.balanceOf(potAddress));
console.log(" remainingRewards: %s wei", pot.getRemainingRewards());
console.log("");
console.log(" CRITICAL: No revert during creation!");
console.log(" Both players have legitimate expectations");
console.log(" But NEITHER can claim anything!");
console.log("");
// Both claims will revert
vm.prank(player1);
vm.expectRevert();
pot.claimCut();
vm.prank(player2);
vm.expectRevert();
pot.claimCut();
console.log("=== PoC SUCCESSFUL: Zero validation on creation ===");
}
/**
* @notice Race condition: First claimers steal from later claimers
*/
function test_RaceCondition_FirstComeFirstServed() public {
console.log("=== Race Condition: Early Birds Win ===");
console.log("");
address[] memory players = new address[](3);
players[0] = player1;
players[1] = player2;
players[2] = player3;
uint256[] memory rewards = new uint256[](3);
rewards[0] = 100 ether;
rewards[1] = 100 ether;
rewards[2] = 100 ether; // 300 total
uint256 totalRewards = 250 ether; // 50 token short
vm.startPrank(admin);
token.mint(admin, totalRewards);
token.approve(address(contestManager), totalRewards);
address potAddress = contestManager.createContest(
players,
rewards,
IERC20(address(token)),
totalRewards
);
contestManager.fundContest(0);
vm.stopPrank();
Pot pot = Pot(potAddress);
console.log("[SETUP]");
console.log(" 3 players, each promised 100 tokens");
console.log(" But only 250 tokens available (50 short)");
console.log("");
// Player 1 and 2 claim first - they get full amount
vm.prank(player1);
pot.claimCut();
vm.prank(player2);
pot.claimCut();
console.log("[RACE RESULT]");
console.log(" Player1 claimed: %s tokens", token.balanceOf(player1) / 1e18);
console.log(" Player2 claimed: %s tokens", token.balanceOf(player2) / 1e18);
console.log(" Remaining: %s tokens", pot.getRemainingRewards() / 1e18);
console.log("");
// Player 3 is the victim
console.log("[VICTIM - Player3]");
console.log(" Tries to claim 100 tokens");
console.log(" Only 50 remain in remainingRewards");
console.log(" REVERT!");
vm.prank(player3);
vm.expectRevert();
pot.claimCut();
console.log("");
console.log("=== UNFAIR: Honest late claimers lose everything ===");
}
}
```
### Files Included
- `RewardsMismatchDoSPoC.t.sol` - Main PoC test file (4 tests)
### Run Command
```bash
forge test --match-contract RewardsMismatchDoSPoC -vvv
```
### Expected Output
```
[PASS] test_FirstClaimDoS_ExtremeCase() (gas: 885600)
Logs:
=== Extreme Case: Even FIRST Claim DoS'd ===
[SETUP]
Player1 expects: 100 tokens
But totalRewards: 50 tokens
remainingRewards: 50 tokens
[CLAIM] Player1 tries to claim...
Calculation: remainingRewards (50) - reward (100) = UNDERFLOW!
[RESULT]
Player1 CANNOT claim their reward
50 tokens STUCK in Pot forever
100% DoS - NO ONE can claim!
=== CRITICAL: Complete protocol freeze ===
[PASS] test_NoValidation_AllowsArbitraryMismatch() (gas: 939180)
Logs:
=== Proof: No Constructor Validation ===
[CREATED - NO VALIDATION!]
Total promised: 1000 tokens
Actually funded: 1 wei
Pot balance: 1 wei
remainingRewards: 1 wei
CRITICAL: No revert during creation!
Both players have legitimate expectations
But NEITHER can claim anything!
=== PoC SUCCESSFUL: Zero validation on creation ===
[PASS] test_RaceCondition_FirstComeFirstServed() (gas: 1119189)
Logs:
=== Race Condition: Early Birds Win ===
[SETUP]
3 players, each promised 100 tokens
But only 250 tokens available (50 short)
[RACE RESULT]
Player1 claimed: 100 tokens
Player2 claimed: 100 tokens
Remaining: 50 tokens
[VICTIM - Player3]
Tries to claim 100 tokens
Only 50 remain in remainingRewards
REVERT!
=== UNFAIR: Honest late claimers lose everything ===
[PASS] test_RewardsMismatch_DoS_LaterClaimants() (gas: 1093942)
Logs:
=== Rewards Mismatch DoS Vulnerability PoC ===
[SETUP - MISCONFIGURATION]
Players: 3
Promised per player: 100 tokens
Total promised: 300 tokens
Total funded: 200 tokens (MISMATCH!)
remainingRewards: 200 tokens
[CLAIM 3] Player3 tries to claim 100 tokens...
remainingRewards = 0, claim amount = 100
Underflow: 0 - 100 = REVERT!
[RESULT - DoS CONFIRMED]
Player3: PERMANENTLY BLOCKED (DoS!)
=== PoC SUCCESSFUL: Player permanently DoS'd ===
Suite result: ok. 4 passed; 0 failed; 0 skipped
```
## Mitigation
Add validation in the `Pot` constructor to ensure rewards sum matches totalRewards:
```diff
constructor(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards) {
+ // Validate that rewards sum equals totalRewards
+ uint256 rewardsSum;
+ for (uint256 i = 0; i < rewards.length; i++) {
+ rewardsSum += rewards[i];
+ }
+ require(rewardsSum == totalRewards, "Pot: rewards sum mismatch");
+ require(players.length == rewards.length, "Pot: array length mismatch");
i_players = players;
i_rewards = rewards;
i_token = token;
i_totalRewards = totalRewards;
remainingRewards = totalRewards;
i_deployedAt = block.timestamp;
for (uint256 i = 0; i < i_players.length; i++) {
playersToRewards[i_players[i]] = i_rewards[i];
}
}
```
**Alternative Mitigation** (in `ContestManager.createContest`):
```diff
function createContest(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards)
public
onlyOwner
returns (address)
{
+ uint256 sum;
+ for (uint256 i = 0; i < rewards.length; i++) {
+ sum += rewards[i];
+ }
+ require(sum == totalRewards, "ContestManager: rewards mismatch");
Pot pot = new Pot(players, rewards, token, totalRewards);
// ...
}
```
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!