SNARKeling Treasure Hunt

First Flight #59
Beginner FriendlyGameFiFoundry
100 EXP
Submission Details
Impact: high
Likelihood: low

L-1 Centralization Risk Owner can drain contract funds via emergencyWithdraw

Author Revealed upon completion

Root + Impact

Description

  • The emergencyWithdraw function allows the contract owner to withdraw the entire ETH balance to an arbitrary address.


  • Because the owner also controls the pause functionality and there is no timelock, this represents a significant centralization risk where a compromised or malicious owner can take all participant rewards.

The implementation relies on a single EOA owner with absolute power and no execution delay.
Line 262 require(paused, THE_CONTRACT_MUST_BE_PAUSED);
Line 263 require(msg.sender == owner, ONLY_OWNER_CAN_EMERGENCY_WITHDRAW);
The check recipient != owner is easily bypassed by the owner providing a secondary wallet address they control.

Risk

Likelihood:

  • Reason 1 It requires the owner to act maliciously or for their private key to be compromised.

  • Reason 2 In a competitive audit context, centralization risks are documented even if the owner is currently trusted.

Impact:

  • Impact 1 If exploited, 100 percent of the contract value is lost.

  • Impact 2 Participants who have already spent resources finding treasures lose their rewards permanently.

Proof of Concept

PoC Explanation
The following test script demonstrates how a single owner can bypass the intended emergency nature of the function to perform a total drain of funds. The owner triggers the pause state and then immediately calls emergencyWithdraw to a secondary address. The assert confirms the contract balance is zeroed out instantly.
function test_ownerCentralizationDrain() public {
address attacker = makeAddr(attacker);
uint256 contractBalance = address(treasureHunt).balance;
vm.prank(owner);
treasureHunt.pause();
vm.prank(owner);
treasureHunt.emergencyWithdraw(payable(attacker), contractBalance);
assertEq(address(treasureHunt).balance, 0);
assertEq(attacker.balance, contractBalance);
}

Recommended Mitigation

+ uint256 public constant WITHDRAWAL_DELAY = 2 days;
+ uint256 public pausedAt;
function pause() external onlyOwner {
paused = true;
+ pausedAt = block.timestamp;
}
function emergencyWithdraw(...) external {
require(paused, THE_CONTRACT_MUST_BE_PAUSED);
- require(msg.sender == owner, ONLY_OWNER);
+ require(block.timestamp >= pausedAt + WITHDRAWAL_DELAY, Timelock not expired);
+ require(msg.sender == owner, ONLY_OWNER);
...
}

Support

FAQs

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

Give us feedback!