Last Man Standing

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

Missing Grace-Period Check in `claimThrone` — Eternal Game/Front-Running Vulnerability

Root + Impact

Root Cause: Missing Grace Period Check in claimThrone -> Impact: Vulnerability to Eternal Game Front-Running

Description

  • The Game contract’s claimThrone() function should have a check to prevent claims after the grace period expires.

  • Due to the lack of the check, as a result, malicious users can still call claimThrone() well beyond the intended deadline and front-run the declareWinner() function, resetting the game timer indefinitely. In practice, an attacker can repeatedly claim the throne after the grace period to prevent the game from ending, , effectively freezing the game and ensuring they eventually become the last claimant.

function claimThrone() external payable gameNotEnded nonReentrant {
require(msg.value >= claimFee, "Game: Insufficient ETH sent to claim the throne.");
require(msg.sender != currentKing, "Game: You are already the king. No need to re-claim.");
@> // @info: allowed to claim throne even after grace period
// @danger: declareWinner function is vulnerable to get front-runned by validators and actors
// who knows about this
uint256 sentAmount = msg.value;
uint256 previousKingPayout = 0;
uint256 currentPlatformFee = 0;
uint256 amountToPot = 0;
// Calculate platform fee
currentPlatformFee = (sentAmount * platformFeePercentage) / 100;
// Defensive check to ensure platformFee doesn't exceed available amount after previousKingPayout
if (currentPlatformFee > (sentAmount - previousKingPayout)) {
currentPlatformFee = sentAmount - previousKingPayout;
}
platformFeesBalance = platformFeesBalance + currentPlatformFee;
// Remaining amount goes to the pot
amountToPot = sentAmount - currentPlatformFee;
pot = pot + amountToPot;
// Update game state
currentKing = msg.sender;
lastClaimTime = block.timestamp;
playerClaimCount[msg.sender] = playerClaimCount[msg.sender] + 1;
totalClaims = totalClaims + 1;
// Increase the claim fee for the next player
claimFee = claimFee + (claimFee * feeIncreasePercentage) / 100;
emit ThroneClaimed(msg.sender, sentAmount, claimFee, pot, block.timestamp);
}

Risk

Likelihood: High

  • Missing Time Check: The claimThrone() function does not verify whether the grace period has passed. Normally, once lastClaimTime + gracePeriod is exceeded, no further claims should be allowed. However, no require(block.timestamp <= lastClaimTime + gracePeriod) (or similar) is present, so anyone can call claimThrone() at any time as long as gameEnded is still false.

  • Front-Running declareWinner(): Because claimThrone() can still be called after the grace period, an attacker in the mempool can see a pending declareWinner() transaction and race it. By sending their own claimThrone() with a higher gas price, they reset lastClaimTime and increase claimFee, causing declareWinner() to revert (“Grace period has not expired yet”). The next opportunity to end the game is pushed out by another full grace interval.

  • Interplay of State Variables: On each (malicious) claim, currentKing is updated and lastClaimTime is set to the new timestamp. Only after the grace period elapses without any claims can declareWinner() succeed. Without the missing time-check, a well-capitalized attacker can continuously reset the timer. This mirrors other DoS-style exploits in “king of the hill” contracts: for example, in the famous KingOfEther contract, an attacker’s contract prevented any refunds, making themselves the eternal king and permanently DoSing the game.

  • Game Logic Flaw: The code comments even note the danger: // @info: allowed to claim throne even after grace period // @danger: declareWinner ... front-run. In effect, the game can never naturally conclude if an attacker chooses to claim at the last moment. This breaks the intended game flow and locks the pot until the attacker is the last claimant.

Impact: Medium

This flaw has serious consequences: an attacker can permanently lock out all other players and claim the entire pot at will. In practice:

  • Denial of Service to Game End: By continuously resetting the timer, the attacker forces the game into a loop, effectively making it impossible for any honest player to win or for the game to conclude. This is a form of DoS on the game mechanics. A similar outcome occurred in the “KingOfEther” contract, where an attacker became the “eternal king” and prevented anyone else from dethroning them.

  • Fund Expropriation: The malicious player ultimately collects all of the accumulated pot (which can be very large). This can rival historic cases: for example, Fomo3D’s winner netted over 10,469 ETH (~$2.1M) by using high-gas strategies to block others. In this game, the attacker similarly ensures they are the final claimant, draining the pot into their account.

  • Trust and Fairness Eroded: Other players lose faith when the game never fairly ends or when only a few with deep pockets can manipulate it. Repeatedly inflated claimFee values (due to fee increases on each claim) make participation prohibitively expensive, further centralizing power to attackers. Over time, this undermines the integrity and intended incentive structure of the game.

  • Platform Reputation and Funds at Risk: If deployed in a real DeFi context, such a vulnerability could lead to significant financial losses for users and damage the project’s credibility. Funds allocated as platform fees or player stakes could remain locked or be effectively stolen by the attacker.

Overall, this is a critical logic vulnerability: it combines a missing time check with the inherent front-running risk of public transactions. Without addressing it, the game can become an endless, unresolvable contest favoring the highest-bidding player.

Proof of Concept

function test_users_can_claim_throne_even_after_grace_period_ends() public {
vm.startPrank(player1);
game.claimThrone{value: INITIAL_CLAIM_FEE}();
vm.stopPrank();
vm.warp(block.timestamp + 1 days - 10);
uint256 incrementedClaimFee = (game.claimFee() * FEE_INCREASE_PERCENTAGE) / 100;
vm.startPrank(player2);
game.claimThrone{value: game.claimFee() + incrementedClaimFee}();
vm.stopPrank();
vm.warp(block.timestamp + 1 days + 10);
// player3 detected that the grace period has been passed and he is now so late to participate
// however, he went to block explorer and read the Game contract(verified) and found
// that he can still claim the throne he just need to front run the declareWinner function.
incrementedClaimFee = (game.claimFee() * FEE_INCREASE_PERCENTAGE) / 100;
// player3 front-runs the declareWinner tx...
// gave high priority fee...
vm.startPrank(player3);
game.claimThrone{value: game.claimFee() + incrementedClaimFee}();
vm.stopPrank();
assert(game.lastClaimTime() + game.gracePeriod() == block.timestamp + 1 days);
// last claim time updated and interval resets
vm.expectRevert("Game: Grace period has not expired yet.");
game.declareWinner();
// adversaris and users who knows about this oversight can force the game to never end
// by front-running the declareWinner tx...
// however this time nobody front-runs the declareWinner tx... and time-interval passes
vm.warp(block.timestamp + 1 days + 1);
game.declareWinner();
address currentWinner = game.currentKing();
console2.log("current winner is: ", currentWinner);
assertEq(currentWinner, player3);
}

step 1: go to test/Game.t.sol file

step 2: paste the above code ⬆️

step 3: run the test suite

forge test --mt test_users_can_claim_throne_even_after_grace_period_ends

step 4: See the Output

Running this test (forge test --mt) reveals the issue: the call to declareWinner() reverts unless no one front-runs, and the last attacker (Player3) inevitably wins the game.

Scenario:

  1. Initial Claim: Player1 calls claimThrone() with the required INITIAL_CLAIM_FEE. They become currentKing and lastClaimTime is set to now.

  2. Subsequent Claim Before Grace: After just under one day (gracePeriod) has passed, Player2 calls claimThrone() (paying the incremented fee) and becomes the new king. lastClaimTime updates.

  3. Grace Period Expires: Time advances beyond lastClaimTime + gracePeriod, so normally Player2 could call declareWinner() to end the game. But Player3 sees this pending situation on-chain.

  4. Front-Run Claim: Player3 immediately sends a transaction calling claimThrone() (with the new higher fee) and outbids the miner fee. This resets lastClaimTime to the current time (and increments the fee again).

  5. DeclareWinner Reverts: Now, if anyone (even Player3) calls declareWinner(), it reverts because block.timestamp is no longer > lastClaimTime + gracePeriod. In effect, the game’s end has been postponed by another day.

  6. Game Eventually Ends: If Player3 chooses not to front-run again, after another full day passes, calling declareWinner() will succeed. The contract will award the entire pot to Player3 (as they are currentKing).

This shows an attacker can time and fund transactions to ensure they are the last claimant, effectively denying anyone else the win and monopolizing the pot.

Tools Used:

  • Foundry Test Suite

  • Chat-GPT AI Assistance (Report Grammar Check & Improvements)

  • Manual Review

Recommended Mitigation

To remediate and harden the contract:

  • Enforce Grace Period in claimThrone(): Add a time check at the start of claimThrone(), for example:

+ require(block.timestamp <= lastClaimTime + gracePeriod,
+ "Game: Grace period expired, no further claims allowed.");

This ensures no one can claim after the deadline, so once the grace period passes, only declareWinner() can be called.

  • Guard declareWinner() Access: Maintain the existing require in declareWinner() (block.timestamp > lastClaimTime + gracePeriod), but consider adding an additional safeguard or state flag once it succeeds so claims are disabled (though gameEnded already prevents further claims).

Updates

Appeal created

inallhonesty Lead Judge about 2 months 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.