Beatland Festival

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

`withdraw` Uses `.transfer()` — ETH Permanently Locked When Recipient Is a Contract

withdraw Uses .transfer() — ETH Permanently Locked When Recipient Is a Contract

Scope

  • FestivalPass.sol

Description

  • The withdraw() function is the only mechanism for the owner to extract ETH from the contract. It should reliably send the full balance to any valid Ethereum address.

  • The function uses Solidity's .transfer() (line 148), which forwards exactly 2,300 gas to the recipient. If the recipient is a smart contract (Gnosis Safe multisig, governance timelock, DAO treasury, or any contract with a non-trivial receive() function), the call will revert due to insufficient gas. Since withdraw() sends the entire balance in a single call, a failed transfer permanently locks all ETH in the contract.

function withdraw(address target) external onlyOwner {
@> payable(target).transfer(address(this).balance);
// .transfer() forwards only 2300 gas — insufficient for contract recipients
}

Risk

Likelihood: Medium

  • Triggers when the owner address is a contract (multisig, DAO, treasury). Professional deployments commonly use multisigs as owners. Also triggers if target is any contract with moderate receive() logic.

Impact: High

  • All collected ETH (pass sale revenue) becomes permanently irrecoverable. No fallback withdrawal mechanism exists.

Severity: Medium

Proof of Concept

The contract holds ETH from pass sales. When withdraw() is called with a contract address as the target (such as a Gnosis Safe multisig), the .transfer() call reverts because the Safe's receive() function costs more than 2,300 gas. The ETH remains locked in the FestivalPass contract with no alternative extraction method.

function test_F5_withdrawTransferDoS() public {
vm.deal(alice, 1 ether);
vm.prank(alice);
festival.buyPass{value: 0.1 ether}(1);
uint256 balance = address(festival).balance;
assertGt(balance, 0, "Contract should have ETH");
// withdraw uses .transfer() which only forwards 2300 gas
// Gnosis Safe and other multisigs need more gas
console.log("Contract ETH balance:", balance);
console.log("F5: withdraw() uses .transfer() - may fail for contract wallets");
}

PoC result: test_F5_withdrawTransferDoS()PASS (gas: 78,727). Note: full DoS demonstration would require deploying a contract with a gas-expensive receive().

Recommended Mitigation

Replace .transfer() with .call{value:}() and check the return value.

function withdraw(address target) external onlyOwner {
- payable(target).transfer(address(this).balance);
+ (bool success, ) = payable(target).call{value: address(this).balance}("");
+ require(success, "ETH 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!