Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

withdraw Uses .transfer(), Permanently Locking ETH When Target Is a Contract

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");
    }


Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!