Beatland Festival

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

withdraw uses .transfer (2300 gas), bricking withdrawals to contract targets with heavier fallbacks

Summary

FestivalPass.withdraw() pays out with payable(target).transfer(...), which forwards only 2300 gas. If the owner designates a contract target whose fallback/receive logic costs more than 2300 gas (e.g., a multisig or vault), the withdrawal reverts and funds cannot be pulled to that target.

Root Cause

Contract: src/FestivalPass.sol
Function: withdraw(address target)

payable(target).transfer(amount); // @> only 2300 gas forwarded; reverts on heavy fallbacks

transfer hard-caps the gas stipend at 2300, which is not enough for any state-changing fallback logic (a single cold SSTORE costs ~22,100 gas).

Impact

  • Withdrawal DoS on contract targets: withdrawing to a contract with non-trivial fallback logic (multisigs, vaults, any receiver doing storage writes) always reverts, so that withdrawal path is bricked. Funds stay locked in FestivalPass until the owner retries with a different target.

  • Low severity: the owner chooses target on every withdraw call and can simply use an EOA to work around it — no funds are lost, only specific targets are unusable.

Description

withdraw(address target) lets the owner pull accumulated pass-sale proceeds to an arbitrary target. It sends ETH via Solidity's transfer, which forwards a fixed 2300 gas stipend. Any recipient contract whose receive/fallback does more than log an event — e.g., a multisig wallet or vault performing storage writes — exceeds the stipend and causes the whole withdraw call to revert with an out-of-gas error.

The PoC below funds the contract with a pass sale, then attempts withdraw to a GreedyReceiver contract whose fallback performs an SSTORE (~22k gas on first write). The withdrawal reverts (vm.expectRevert()), while withdrawing the same balance to an EOA succeeds — proving the 2300-gas stipend is the sole cause.

Proof of Concept

From repo/test/PoC.t.sol (BeatlandPoC; all 5 tests pass with forge test):

// 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";
​
/// @notice Receiver whose fallback costs far more than the 2300 gas stipend
/// forwarded by `withdraw`'s use of `.transfer`.
contract GreedyReceiver {
uint256 public x;
​
fallback() external payable {
x += 1; // SSTORE: ~22k gas on first write, way over 2300
}
​
receive() external payable {
x += 1;
}
}
​
contract BeatlandPoC is Test {
FestivalPass public fp;
BeatToken public beat;
address public owner;
address public organizer;
address public user;
​
uint256 constant VIP = 2;
uint256 constant VIP_PRICE = 0.1 ether;
​
function setUp() public {
owner = address(this);
organizer = makeAddr("organizer");
user = makeAddr("user");
vm.deal(user, 100 ether);
​
beat = new BeatToken();
fp = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(fp));
​
vm.startPrank(organizer);
fp.configurePass(1, 0.05 ether, 5000);
fp.configurePass(VIP, VIP_PRICE, 1000);
fp.configurePass(3, 0.25 ether, 100);
vm.stopPrank();
}
​
// ============ L-2: withdraw via .transfer bricks on contract targets ============
function test_PoC_Withdraw_TransferBricksOnContracts() public {
// Fund the contract with pass sale proceeds.
vm.prank(user);
fp.buyPass{value: 0.05 ether}(1);
assertEq(address(fp).balance, 0.05 ether);
​
GreedyReceiver greedy = new GreedyReceiver();
// owner (this test contract) tries to withdraw to a contract whose
// fallback costs > 2300 gas -> .transfer runs out of gas -> revert.
vm.expectRevert();
fp.withdraw(address(greedy));
​
// Sanity: withdrawing to an EOA works fine.
address eoa = makeAddr("eoa");
fp.withdraw(eoa);
assertEq(eoa.balance, 0.05 ether);
}
}

Recommended Mitigation

Replace .transfer with a low-level call that forwards all gas, plus an explicit success check:

- payable(target).transfer(amount);
+ (bool ok, ) = target.call{value: amount}("");
+ require(ok, "withdraw failed");
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!