Last Man Standing

First Flight #45
Beginner FriendlyFoundrySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

Frontrunning Allows Malicious Players to Block declareWinner() Even After Grace Period Ended

Root + Impact

The root cause is the absence of a grace period expiration check within the claimThrone() function. While declareWinner() checks that the gracePeriod has passed before ending the game, claimThrone() does not enforce this, allowing a player to frontrun and become the new king even after the grace period has expired. Also, this issue does not explicitly depends on frontrunning since a time can pass between the expiration of the grace period and the call to declareWinner() within which a user can still call claimThrone().

The impact is that declareWinner() becomes permanently blocked, since each frontrunning claim resets the lastClaimTime, extending the game indefinitely. Malicious actors can grief legitimate winners or automate DoS for financial or disruptive purposes.

Description

  • Normally, once the gracePeriod has elapsed since the last throne claim, anyone can call declareWinner() to end the game.

  • However, a malicious user can frontrun this action by calling claimThrone() just before declareWinner() is mined, thus resetting the timer and continuing the game without restriction.

The only time check the function does is via gameNotEnded , but it only checks whether the game has ended or not. A game ends only after declareWinner() is called and since the attacker front runs it they wil be eligible to claim the throne even after grace period expired.

function claimThrone() external payable gameNotEnded nonReentrant {
...
@> // No grace period expiry check is performed here
lastClaimTime = block.timestamp;
...
}
modifier gameNotEnded() {
require(!gameEnded, "Game: Game has already ended. Reset to play again.");
_;
}
function declareWinner() external gameNotEnded {
require(currentKing != address(0), "Game: No one has claimed the throne yet.");
require(
block.timestamp > lastClaimTime + gracePeriod,
"Game: Grace period has not expired yet."
);
@> gameEnded = true;
pendingWinnings[currentKing] = pendingWinnings[currentKing] + pot;
pot = 0; // Reset pot after assigning to winner's pending winnings
emit GameEnded(currentKing, pot, block.timestamp, gameRound);
}

Risk

Likelihood:

The likehood is High because:

  • Happens any time a round is about to end, especially with bots or scripts monitoring the mempool.

  • Especially likely in high-value pots where incentives are aligned to prevent finalization.

Impact:

The impact is high because the issue:

  • Makes it impossible for a legitimate winner to claim the pot.

  • Allows griefing and endless game extension.

Proof of Concept

The POC demonstrates how a legitimate king is blocked to declare himself and claim his rewards even after grace period expired and the game technically ended.

Add the test bellow to the Game.t.sol and run the test with the following script:

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_declareWinnerCanBeFrontrunAndBlockedAfterGracePeriod
function test_declareWinnerCanBeFrontrunAndBlockedAfterGracePeriod()
public
{
vm.prank(player1);
game.claimThrone{value: 0.1 ether}();
// no one claimed player1's throne and grace period ended
// now declareWinner can be called to finish the game
vm.warp(1 days + 1);
// player2 frontruns declayerWinner(), blocks it and continues the game after the grace period
vm.prank(player2);
game.claimThrone{value: 1 ether}();
vm.prank(player1);
vm.expectRevert("Game: Grace period has not expired yet.");
game.declareWinner();
}

Result:

Ran 1 test for test/Game.t.sol:GameTest
[PASS] test_declareWinnerCanBeFrontrunAndBlockedAfterGracePeriod() (gas: 198358)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.61ms (235.54µs CPU time)
Ran 1 test suite in 7.24ms (1.61ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Add this check in claimThrone() to ensure no claims can occur after the grace period has passed and before the winner has been declared:

+ require(block.timestamp >= lastClaimTime + gracePeriod, "Game: Cannot claim after grace period has expired.");
Updates

Appeal created

inallhonesty Lead Judge 9 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Game::claimThrone can still be called regardless of the grace period

Support

FAQs

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