Last Man Standing

First Flight #45
Beginner FriendlyFoundrySolidity
100 EXP
View results
Submission Details
Impact: high
Likelihood: low
Invalid

Owner Can Short-Circuit Game via Grace Period Manipulation to Self-Win

Root + Impact

Description

  • Normal Behavior:
    The gracePeriod defines a buffer of time in which no new throne claims must occur for the current king to be eligible to win the pot via declareWinner(). It is expected to remain consistent throughout a round to give fair opportunity for contesting the throne.

  • Problem:
    The updateGracePeriod() function allows the owner to reduce the grace period mid-game, after claiming the throne. This can be abused to make the game immediately endable — giving the owner a fast-track path to declare themselves the winner without giving others a fair chance to contest. This behavior violates the documented trust assumptions and fairness expectations of players.

function updateGracePeriod(uint256 _newGracePeriod) external onlyOwner {
require(_newGracePeriod > 0, "Game: New grace period must be greater than zero.");
@> gracePeriod = _newGracePeriod; // No game status check, can alter grace mid-round
emit GracePeriodUpdated(_newGracePeriod);
}

Risk

Likelihood:

The likelihood is Low because:

  • This requires the owner to act maliciously and time it correctly, which may not occur in all deployments.

  • However, since the exploit is deterministic and accessible with no cost barrier, a malicious or key compromised owner can always execute it in any game with a large enough pot.

Impact:

The impact is High because the issue:

  • Allows the owner to forcibly end the game early and extract the pot by declaring themselves winner immediately.

  • Violates documented expectations and fairness for players who assume a stable grace period.

  • Could severely erode user trust and credibility of the protocol/game.

Proof of Concept

The POC demonstrates how an owner can change the grace period mid-game, claim the throne and declare himself as winner without giving others a fair chance to contest.

Add the test below to the Game.t.sol and use the following script to run it:

NOTE: for the poc to work replace the following require statement inside the claimThrone() (which is another issue):

- require(msg.sender == currentKing, "Game: You are already the king. No need to re-claim.");
+ require(msg.sender != currentKing, "Game: You are already the king. No need to re-claim.");
forge test --match-path test/Game.t.sol --match-test test_ownerCanUpdateGraceAndDeclareHimselfAWinner -vvvv
function test_ownerCanUpdateGraceAndDeclareHimselfAWinner() public {
console2.log("Owner balance before: ", deployer.balance);
vm.prank(player1);
game.claimThrone{value: 0.1 ether}();
vm.prank(player2);
game.claimThrone{value: 0.5 ether}();
vm.prank(player3);
game.claimThrone{value: 1 ether}();
// now owner claims the throne
// then immediately changes the grace 1 days => 1 second
// finally, waits 1 second and declares himself a winner + withdraws
vm.startPrank(deployer);
game.claimThrone{value: 1.1 ether}();
game.updateGracePeriod(1);
// lastClaimTime = 1 + gracePeriod = 1 + 1 second wait to pass the check:
// require(block.timestamp > lastClaimTime + gracePeriod);
vm.warp(3);
game.declareWinner();
game.withdrawWinnings();
console2.log("Owner balance after: ", deployer.balance);
assert(deployer.balance > 10 ether);
}

Result:

[PASS] test_ownerCanUpdateGraceAndDeclareHimselfAWinner() (gas: 353710)
Logs:
Owner balance before: 10000000000000000000
Owner balance after: 11465000000000000000
Traces:
[447403] GameTest::test_ownerCanUpdateGraceAndDeclareHimselfAWinner()
├─ [0] console::log("Owner balance before: ", 10000000000000000000 [1e19]) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::prank(player1: [0x7026B763CBE7d4E72049EA67E89326432a50ef84])
│ └─ ← [Return]
├─ [152621] Game::claimThrone{value: 100000000000000000}()
│ ├─ emit ThroneClaimed(newKing: player1: [0x7026B763CBE7d4E72049EA67E89326432a50ef84], claimAmount: 100000000000000000 [1e17], newClaimFee: 110000000000000000 [1.1e17], newPot: 95000000000000000 [9.5e16], timestamp: 1)
│ └─ ← [Stop]
├─ [0] VM::prank(player2: [0xEb0A3b7B96C1883858292F0039161abD287E3324])
│ └─ ← [Return]
├─ [50121] Game::claimThrone{value: 500000000000000000}()
│ ├─ emit ThroneClaimed(newKing: player2: [0xEb0A3b7B96C1883858292F0039161abD287E3324], claimAmount: 500000000000000000 [5e17], newClaimFee: 121000000000000000 [1.21e17], newPot: 570000000000000000 [5.7e17], timestamp: 1)
│ └─ ← [Stop]
├─ [0] VM::prank(player3: [0xcC37919fDb8E2949328cDB49E8bAcCb870d0c9f3])
│ └─ ← [Return]
├─ [50121] Game::claimThrone{value: 1000000000000000000}()
│ ├─ emit ThroneClaimed(newKing: player3: [0xcC37919fDb8E2949328cDB49E8bAcCb870d0c9f3], claimAmount: 1000000000000000000 [1e18], newClaimFee: 133100000000000000 [1.331e17], newPot: 1520000000000000000 [1.52e18], timestamp: 1)
│ └─ ← [Stop]
├─ [0] VM::startPrank(deployer: [0xaE0bDc4eEAC5E950B67C6819B118761CaAF61946])
│ └─ ← [Return]
├─ [50121] Game::claimThrone{value: 1100000000000000000}()
│ ├─ emit ThroneClaimed(newKing: deployer: [0xaE0bDc4eEAC5E950B67C6819B118761CaAF61946], claimAmount: 1100000000000000000 [1.1e18], newClaimFee: 146410000000000000 [1.464e17], newPot: 2565000000000000000 [2.565e18], timestamp: 1)
│ └─ ← [Stop]
├─ [8997] Game::updateGracePeriod(1)
│ ├─ emit GracePeriodUpdated(newGracePeriod: 1)
│ └─ ← [Stop]
├─ [0] VM::warp(3)
│ └─ ← [Return]
├─ [48689] Game::declareWinner()
│ ├─ emit GameEnded(winner: deployer: [0xaE0bDc4eEAC5E950B67C6819B118761CaAF61946], prizeAmount: 0, timestamp: 3, round: 1)
│ └─ ← [Stop]
├─ [29913] Game::withdrawWinnings()
│ ├─ [0] deployer::fallback{value: 2565000000000000000}()
│ │ └─ ← [Stop]
│ ├─ emit WinningsWithdrawn(to: deployer: [0xaE0bDc4eEAC5E950B67C6819B118761CaAF61946], amount: 2565000000000000000 [2.565e18])
│ └─ ← [Stop]
├─ [0] console::log("Owner balance after: ", 11465000000000000000 [1.146e19]) [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.09ms (226.00µs CPU time)
Ran 1 test suite in 595.76ms (1.09ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Do not allow the grace period to be changed when a game is currently running.

function updateGracePeriod(uint256 _newGracePeriod) external onlyOwner {
+ require(gameEnded, "Game: Cannot update grace period mid-round.");
require(_newGracePeriod > 0, "Game: New grace period must be greater than zero.");
gracePeriod = _newGracePeriod;
emit GracePeriodUpdated(_newGracePeriod);
}
Updates

Appeal created

inallhonesty Lead Judge 9 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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