Beatland Festival

First Flight #44
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: low
Likelihood: medium
Invalid

ETH Withdrawals Can Fail for Contract Recipients

Description

The withdraw function uses .transfer() to send ETH, which will fail if the recipient is a contract without a receive() or fallback() function, potentially locking funds in the contract.

Root Cause

The contract uses .transfer() with a fixed 2300 gas stipend:

function withdraw(address target) external onlyOwner {
payable(target).transfer(address(this).balance);
}

This will fail if:

  1. The target is a contract without receive() or fallback() functions

  2. The target contract's fallback function requires more than 2300 gas

  3. The organizer address is set to a multisig or other contract wallet

Risk

Likelihood: Low - Only affects withdrawals to contract addresses that lack proper fallback functions or require more than 2300 gas.

Impact: Low - Funds aren't permanently locked as the owner can change the target address, but it breaks composability with modern smart contract infrastructure.

Impact

  • Withdrawals to contracts without receive() or fallback() functions will fail

  • Multisig wallets and smart contract wallets may be incompatible

  • Payment splitter contracts requiring more than 2300 gas won't work

  • Owner must use EOA addresses as workaround, reducing flexibility

Proof of Concept

This test demonstrates how the withdraw function fails when attempting to send ETH to contracts that cannot receive it:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test, console} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
// Contract without receive/fallback
contract NoFallbackContract {}
// Contract with expensive fallback
contract ExpensiveFallbackContract {
uint256[] public data;
receive() external payable {
// This requires more than 2300 gas
data.push(block.timestamp);
}
}
contract FailingWithdrawTest is Test {
FestivalPass festivalPass;
BeatToken beatToken;
address organizer = address(0x1);
function setUp() public {
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
// Setup: Send ETH to FestivalPass from pass sales
vm.deal(address(this), 1 ether);
vm.prank(organizer);
festivalPass.configurePass(1, 0.1 ether, 10);
address user = address(0x123);
vm.deal(user, 1 ether);
vm.prank(user);
festivalPass.buyPass{value: 0.1 ether}(1);
}
function test_WithdrawFailsToContractWithoutFallback() public {
// Try to withdraw to contract without fallback
NoFallbackContract noFallback = new NoFallbackContract();
vm.expectRevert();
festivalPass.withdraw(address(noFallback));
}
function test_WithdrawFailsToExpensiveFallback() public {
// Try to withdraw to contract with expensive fallback
ExpensiveFallbackContract expensive = new ExpensiveFallbackContract();
vm.expectRevert();
festivalPass.withdraw(address(expensive));
}
}

Recommended Mitigation

Use .call() instead of .transfer() to support contract recipients:

function withdraw(address target) external onlyOwner {
- payable(target).transfer(address(this).balance);
+ uint256 amount = address(this).balance;
+ (bool success, ) = payable(target).call{value: amount}("");
+ require(success, "ETH transfer failed");
+ emit FundsWithdrawn(target, amount);
}

This approach:

  1. Provides unlimited gas for the recipient's fallback function

  2. Supports all types of recipients (EOAs, multisigs, smart wallets)

  3. Properly handles transfer failures

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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