Rock Paper Scissors

First Flight #38
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Invalid

Contract ownership at initialisation lacks permission preventing proper distribution of prizes

Summary

The RockPaperScissors contract lacks permission to mint reward tokens because the WinningToken contract's ownership is incorrectly assigned to the protocol admin instead of the game contract itself. This prevented the proper distribution of prizes to winners in token-based games.

Vulnerability Details

Root Cause: The WinningToken was deployed with this ownership flow:

constructor() {
// Owner = msg.sender (admin address), not the RockPaperScissors contract
winningToken = new WinningToken();
adminAddress = msg.sender;
}

The WinningToken's mint function is restricted to its owner, but the RockPaperScissors contract never received ownership. While the initial tests used privileged access to bypass this, production deployments would fail to award tokens.

Technical Insight: The game contract attempted to mint tokens without proper authorisation:

// In _finishGame()
winningToken.mint(_winner, 2); // Fails unless game contract is token owner

Impact

Critical Severity: This vulnerability completely breaks the core functionality of token-based games:

  • Winners receive no rewards despite winning

  • Staked tokens remain locked in the contract

  • Protocol reputation damage due to broken promises

  • Direct financial loss for players

Tools Used

This vulnerability was identified through manual code review. The PoC was written using Foundry.

Proof of Concept

The following PoC simulates a game, but before the game starts the ownership of the WinningToken contract is passed on to the RockPaperScissors contract to ensure that the game can conclude successfully.

function test_TokenOwnershipVulnerability() public {
// Deploy fresh contracts to isolate from setup
RockPaperScissors freshGame = new RockPaperScissors();
WinningToken freshToken = WinningToken(freshGame.winningToken());
// Mint initial tokens AS THE GAME CONTRACT (real owner)
vm.startPrank(address(freshGame)); // Impersonate owner
freshToken.mint(playerA, 1);
freshToken.mint(playerB, 1);
vm.stopPrank();
// Player A creates game
vm.startPrank(playerA);
freshToken.approve(address(freshGame), 1);
uint256 gamesId = freshGame.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
// Player B joins game
vm.startPrank(playerB);
freshToken.approve(address(freshGame), 1);
freshGame.joinGameWithToken(gamesId);
vm.stopPrank();
// Play turns normally
// Turn 1
bytes32 saltA1 = keccak256("saltA1");
bytes32 commitA1 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA1));
vm.prank(playerA);
freshGame.commitMove(gamesId, commitA1);
bytes32 saltB1 = keccak256("saltB1");
bytes32 commitB1 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltB1));
vm.prank(playerB);
freshGame.commitMove(gamesId, commitB1);
vm.prank(playerA);
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Rock), saltA1);
vm.prank(playerB);
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Paper), saltB1);
// Turn 2
bytes32 saltA2 = keccak256("saltA2");
bytes32 commitA2 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Scissors), saltA2));
vm.prank(playerA);
freshGame.commitMove(gamesId, commitA2);
bytes32 saltB2 = keccak256("saltB2");
bytes32 commitB2 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltB2));
vm.prank(playerB);
freshGame.commitMove(gamesId, commitB2);
vm.prank(playerA);
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Scissors), saltA2);
vm.prank(playerB);
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Rock), saltB2);
// Final turn should fail at reward minting
bytes32 saltA3 = keccak256("saltA3");
bytes32 commitA3 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltA3));
vm.prank(playerA);
freshGame.commitMove(gamesId, commitA3);
bytes32 saltB3 = keccak256("saltB3");
bytes32 commitB3 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Scissors), saltB3));
vm.prank(playerB);
freshGame.commitMove(gamesId, commitB3);
vm.prank(playerA);
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Paper), saltA3);
// Final reveal should SUCCESSFULLY mint rewards
vm.prank(playerB);
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Scissors), saltB3);
// Verify rewards
assertEq(freshToken.balanceOf(playerA), 0, "Player A should have 0 tokens");
assertEq(freshToken.balanceOf(playerB), 2, "Player B should have 2 tokens");
}

Recommendations

Immediate Fix: Transfer token ownership during initialisation

constructor() {
winningToken = new WinningToken();
winningToken.transferOwnership(address(this)); // Add this line
adminAddress = msg.sender;
}

Long-Term Prevention:

  1. Implement ownership verification checks in tests without privileged access

  2. Use explicit role-based access control (RBAC) instead of simple ownership

  3. Add invariant testing for token balances after game completion

  4. Implement automated security scanners for permission mismatches

This fix ensures the game contract has permanent, irrevocable authority to mint reward tokens while maintaining admin control over other protocol parameters.

Updates

Appeal created

m3dython Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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