Last Man Standing

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

Malicious/Compromised Owner Can Frontrun claimThrone() & Set Platform Fee to 100%, Draining All Claimed Value

Root + Impact

Description

The function updatePlatformFeePercentage(uint256 _newPlatformFeePercentage) allows the contract owner to set the platform fee to any value between 0% and 100% without restriction:

function updatePlatformFeePercentage(uint256 _newPlatformFeePercentage)
external
onlyOwner
isValidPercentage(_newPlatformFeePercentage)
{
platformFeePercentage = _newPlatformFeePercentage;
emit PlatformFeePercentageUpdated(_newPlatformFeePercentage);
}

This fee is then used in the claimThrone() function:

uint256 currentPlatformFee = (sentAmount * platformFeePercentage) / 100;
uint256 contributionToPot = sentAmount - currentPlatformFee;

Generally, the onwer is trusted but If compromised they can frontrun any claimThrone() call and set platformFeePercentage = 100, which means that the entire claim amount will be absorbed as a platform fee, and nothing will be added to the pot. The player still becomes the king, but the pot does not reflect their contribution.

This results in a silent value drain, breaking the implied assumption that the pot grows with each claim.

Risk

Likelihood:

Low. While owner functions are generally trusted, this is an unrestricted, real-time setting that can be abused maliciously or accidentally (e.g., misconfigured to 100%).

Impact:

The impact is Medium because:

  • Pot value is silently reduced without warning or visibility.

  • Breaks the fairness and transparency expectations of the game.

  • Allows for potential stealth rug-pull behavior by a malicious or compromised owner.

Proof of Concept

The POC demonstrates how a compromised owner can frontrun claimThrone() and absorb the claimFee.

Add the test below to the Game.t.sol and use 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_ownerCanUpdatePlatformPercentageAndAbsorbClaimFee -vvvv
function test_ownerCanUpdatePlatformPercentageAndAbsorbClaimFee() public {
console2.log("Pot before: ", game.pot());
vm.prank(player1);
game.claimThrone{value: 0.1 ether}();
vm.prank(player2);
game.claimThrone{value: 0.5 ether}();
// Compromised owner frontruns and sets 100% platform fee
vm.prank(deployer);
game.updatePlatformFeePercentage(100);
vm.prank(player3);
game.claimThrone{value: 1 ether}();
console2.log("Pot after: ", game.pot());
}

Result:

Ran 1 test for test/Game.t.sol:GameTest
[PASS] test_ownerCanUpdatePlatformPercentageAndAbsorbClaimFee() (gas: 247094)
Logs:
Pot before: 0
Pot after: 570000000000000000
Traces:
[306794] GameTest::test_ownerCanUpdatePlatformPercentageAndAbsorbClaimFee()
├─ [2515] Game::pot() [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("Pot before: ", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::prank(player1: [0x7026B763CBE7d4E72049EA67E89326432a50ef84])
│ └─ ← [Return]
├─ [150621] 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(deployer: [0xaE0bDc4eEAC5E950B67C6819B118761CaAF61946])
│ └─ ← [Return]
├─ [6838] Game::updatePlatformFeePercentage(100)
│ ├─ emit PlatformFeePercentageUpdated(newPlatformFeePercentage: 100)
│ └─ ← [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: 570000000000000000 [5.7e17], timestamp: 1)
│ └─ ← [Stop]
├─ [515] Game::pot() [staticcall]
│ └─ ← [Return] 570000000000000000 [5.7e17]
├─ [0] console::log("Pot after: ", 570000000000000000 [5.7e17]) [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.42ms (288.00µs CPU time)
Ran 1 test suite in 628.23ms (1.42ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Apply the new platform fee only after resetGame() to prevent mid-game manipulation.

uint256 public platformFeePercentage;
+ uint256 public initialPlatformFeePercentage;
function updatePlatformFeePercentage(uint256 _newPlatformFeePercentage)
external
onlyOwner
isValidPercentage(_newPlatformFeePercentage)
{
- platformFeePercentage = _newPlatformFeePercentage;
+ initialPlatformFeePercentage = _newPlatformFeePercentage;
emit PlatformFeePercentageUpdated(_newPlatformFeePercentage);
}
function resetGame() external onlyOwner gameEndedOnly {
currentKing = address(0);
lastClaimTime = block.timestamp;
pot = 0;
claimFee = initialClaimFee;
gracePeriod = initialGracePeriod;
gameEnded = false;
gameRound = gameRound + 1;
// totalClaims is cumulative across rounds, not reset here, but could be if desired.
+ platformFeePercentage = initialPlatformFeePercentage;
emit GameReset(gameRound, block.timestamp);
}
Updates

Appeal created

inallhonesty Lead Judge 16 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.