Last Man Standing

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

Broken Throne Mechanic: New Players Can't Participate

Root + Impact

Description

  • Describe the normal behavior in one or more sentences


    Answer: The claimThrone() function should allow any player except the current king to claim the throne by sending at least the required claimFee. A portion of the fee goes to the platform, optionally to the previous king, and the rest to the pot, with the caller becoming the new currentKing and the claimFee increasing for the next claim.


  • Explain the specific issue or problem in one or more sentences
    Answer: The require(msg.sender == currentKing, ...) condition incorrectly restricts claimThrone() to the current king, preventing new players from claiming the throne. This is the opposite of the intended logic. As a result, once a king is set, no other player can participate, breaking the game’s core mechanic.


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."); // Incorrect condition
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:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)
    Answer: The vulnerability manifests as soon as the gracePeriod expires and a player submits a declareWinner() transaction. An attacker monitoring the Ethereum mempool can detect this transaction and submit a claimThrone() transaction with a higher gas price, ensuring it is mined first, thus becoming the currentKing before the winner is declared.


  • Reason 2
    Answer: Ethereum’s public mempool and competitive gas market enable bots to routinely scan for high-value transactions like declareWinner(), especially in a financial game with a potentially large pot, making it highly probable that an attacker exploits this during periods of network activity.

Impact:

  • Impact 1
    Answer: An attacker successfully front-runs the declareWinner() call, becoming the currentKing and winning the entire pot, which deprives the legitimate king of their prize and undermines the game’s fairness.


  • Impact 2
    Answer: Players lose trust in the game due to the unfair outcome, leading to reduced participation, reputational damage, and potential financial losses from gas fees spent on failed attempts to claim or declare the winner

Proof of Concept

Vulnerability
The claimThrone() function contains this incorrect check:
require(msg.sender == currentKing, "Game: You are already the king. No need to re-claim.");
Instead of preventing the current king from re-claiming the throne, it does the opposite: it only allows the current king to claim the throne again, excluding all new participants.
How to Reproduce
Deploy the contract, and then run the following PoC:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IGame {
function claimThrone() external payable;
}
contract PoC {
IGame public game;
constructor(address _gameAddress) {
game = IGame(_gameAddress);
}
// Attempt to claim the throne with sufficient ETH
function tryToClaimThrone() external payable {
require(msg.value >= 1 ether, "Example: Minimum fee not met");
game.claimThrone{value: msg.value}(); // Will revert unless sender == currentKing
}
}
Expected Behavior
After the first player claims the throne, new players should be able to
dethrone the current king by paying the updated claimFee. However, the
require(msg.sender == currentKing) line prevents anyone else from ever
participating.

Recommended Mitigation

Root Cause
Incorrect logic in the access restriction:
// BAD: only currentKing can call
require(msg.sender == currentKing, "Game: You are already the king. No need to re-claim.");
This line must be inverted to prevent the current king from reclaiming:
// GOOD: currentKing is disallowed from reclaiming
require(msg.sender != currentKing, "Game: You are already the king. No need to re-claim.");
Fix Recommendation
Replace the incorrect require() line in the claimThrone() function with:
require(msg.sender != currentKing, "Game: You are already the king. No need to re-claim.");
Updates

Appeal created

inallhonesty Lead Judge about 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Game::claimThrone `msg.sender == currentKing` check is busted

Support

FAQs

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