Description
The withdraw function allows the owner to pull all accumulated ETH from pass sales to an address of their choice. This is the only mechanism to recover ETH
from the contract.
Solidity's .transfer() forwards a hard-coded 2300 gas stipend. Smart contract wallets (Gnosis Safe, multisig treasuries, DAO vaults) require significantly more
than 2300 gas in their receive() functions to process incoming ETH. When the target is any such contract, .transfer() always reverts, leaving the ETH
permanently locked in FestivalPass with no fallback recovery path.
// src/FestivalPass.sol
function withdraw(address target) external onlyOwner {
@> payable(target).transfer(address(this).balance);
// ^^^^^^^^ 2300 gas hard cap — reverts for any contract recipient
// No fallback exists; no secondary withdrawal function exists
}
Risk
Likelihood:
Protocol teams and event organizers routinely use multisig wallets (Gnosis Safe) as their treasury address — this is considered best practice for fund custody,
meaning the failure case is the normal operational case.
There is no alternative withdrawal mechanism; if one withdraw call reverts, all ETH is permanently trapped with no recovery path available through any other
function.
Impact:
All ETH collected from pass sales is permanently locked inside FestivalPass with no recovery path if the target is a contract wallet.
The organizer cannot access revenue from any performance cycle where the intended recipient is a multisig or smart contract treasury.
Proof of Concept
contract ContractWallet {
uint256 private counter;
receive() external payable {
counter++; // SSTORE costs 20,000 gas — far exceeds the 2300 stipend
}
}
function test_WithdrawLocksETHForContractTarget() public {
// User buys a pass — ETH enters FestivalPass
vm.prank(user1);
festivalPass.buyPass{value: 0.05 ether}(1);
assertEq(address(festivalPass).balance, 0.05 ether);
// Owner wants to withdraw to a contract wallet (multisig scenario)
ContractWallet wallet = new ContractWallet();
vm.prank(owner);
vm.expectRevert(); // .transfer() fails — only 2300 gas forwarded
festivalPass.withdraw(address(wallet));
// ETH remains permanently locked
assertEq(address(festivalPass).balance, 0.05 ether);
assertEq(address(wallet).balance, 0);
}
Recommended Mitigation
// src/FestivalPass.sol
function withdraw(address target) external onlyOwner {
payable(target).transfer(address(this).balance);
(bool success, ) = payable(target).call{value: address(this).balance}("");
require(success, "Transfer failed");
}
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.