Beatland Festival

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

withdraw sends proceeds with transfer() and its 2300-gas stipend, so withdrawing to a Safe-style multisig (cold proxy) or any treasury whose receive() writes storage reverts

Root + Impact

Description

  • withdraw(target) is the protocol's only ETH exit. The owner calls it to send every pass-sale proceed to a target of their choice. The README says the owner "collects the festival proceeds", and the interface NatSpec says it "sends to a target inputed by the owner".

  • withdraw uses payable(target).transfer(...), which forwards only 2300 gas. Two kinds of contract treasury therefore cannot receive:

    • A Safe-style proxy. Its fallback does a cold SLOAD (2100 gas) and a cold DELEGATECALL (2600 gas) in an ordinary transaction without an access list, which exceeds the stipend, so the whole withdraw reverts.

    • Any treasury whose receive() writes storage. SSTORE needs more than 2300 gas remaining (EIP-2200), so this fails whether the contract is warm or cold.

// src/FestivalPass.sol L146-L149; @> on L148
// Organizer withdraws ETH
function withdraw(address target) external onlyOwner {
@> payable(target).transfer(address(this).balance);
}

Risk

Likelihood:

  • It happens when the owner names a Safe multisig or another smart-contract treasury as the target, which is a common custody setup for proceeds, and sends an ordinary transaction without an EIP-2930 access list.

  • Only the trusted owner calls withdraw, and they can retry immediately with an EOA or a gas-light target, so likelihood is Low.

Impact:

  • Withdrawal to the intended treasury reverts and the ETH stays in FestivalPass. The owner has to route proceeds through an EOA or an intermediary, which adds an operational and trust hop.

  • Treasuries with storage-writing receive() logic, such as deposit accounting, can never be a withdraw target. No funds are lost.

Proof of Concept

Two replica contracts model the Safe treasury; no fork is used. SafeProxyLike reproduces SafeProxy: the singleton sits in slot 0, and the fallback does sload(0) and a delegatecall with all gas. SafeSingletonLike reproduces Safe's receive(), which emits SafeReceived. Both are deployed in setUp, so they are cold inside the test.

  • test_baseline_withdrawToEOA_succeeds moves 2 ETH to an EOA.

  • test_withdrawToSafeProxy_reverts shows the withdraw reverting with the 2 ETH still in FestivalPass. A control call{value: 1 ether} to the same proxy succeeds, and a retry to an EOA recovers the funds.

  • test_prewarmedSafe_withdrawSucceeds_showsColdAccessIsTheCause shows that once the proxy's slot 0 and the singleton address are warmed, the identical withdraw succeeds. This confirms that EIP-2929 cold-access costs are the cause.

  • test_withdrawToAccountingTreasury_alwaysReverts shows that a warm treasury whose receive() writes storage still reverts.

Save as test/PoC_BF-07.t.sol and run from the repository root:

FOUNDRY_FFI=false forge test --match-path test/PoC_BF-07.t.sol -vv

// 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";
​
/// Replica of the Safe singleton's ETH entry point (EtherPaymentFallback):
/// `receive() external payable { emit SafeReceived(msg.sender, msg.value); }`
contract SafeSingletonLike {
event SafeReceived(address indexed sender, uint256 value);
​
receive() external payable {
emit SafeReceived(msg.sender, msg.value);
}
}
​
/// Replica of SafeProxy: singleton address in storage slot 0, fallback
/// reads it (SLOAD) and DELEGATECALLs every call, including plain ETH sends.
contract SafeProxyLike {
address internal singleton; // slot 0, as in SafeProxy
​
constructor(address _singleton) {
singleton = _singleton;
}
​
fallback() external payable {
assembly {
let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff)
// 0xa619486e == keccak("masterCopy()")
if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000) {
mstore(0, _singleton)
return(0, 0x20)
}
calldatacopy(0, 0, calldatasize())
let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
if eq(success, 0) { revert(0, returndatasize()) }
return(0, returndatasize())
}
}
}
​
contract PoC_BF07 is Test {
FestivalPass festival;
BeatToken beat;
​
address organizer = makeAddr("organizer");
address buyer1 = makeAddr("buyer1");
address buyer2 = makeAddr("buyer2");
address ownerEOA = makeAddr("ownerEOA");
​
SafeSingletonLike singleton;
SafeProxyLike treasurySafe;
​
uint256 constant GENERAL_PASS = 1;
uint256 constant PRICE = 1 ether;
​
function setUp() public {
// The test contract is the owner of FestivalPass (Ownable(msg.sender)).
beat = new BeatToken();
festival = new FestivalPass(address(beat), organizer);
beat.setFestivalContract(address(festival));
​
vm.prank(organizer);
festival.configurePass(GENERAL_PASS, PRICE, 100);
​
vm.deal(buyer1, 10 ether);
vm.deal(buyer2, 10 ether);
vm.prank(buyer1);
festival.buyPass{value: PRICE}(GENERAL_PASS);
vm.prank(buyer2);
festival.buyPass{value: PRICE}(GENERAL_PASS);
​
singleton = new SafeSingletonLike();
treasurySafe = new SafeProxyLike(address(singleton));
}
​
/// Baseline: withdraw to an EOA works and moves the whole balance.
function test_baseline_withdrawToEOA_succeeds() public {
assertEq(address(festival).balance, 2 ether);
festival.withdraw(ownerEOA);
assertEq(ownerEOA.balance, 2 ether, "EOA got proceeds");
assertEq(address(festival).balance, 0, "festival drained");
}
​
/// The break: withdraw to a Safe-style proxy treasury reverts (2300-gas stipend
/// is below cold SLOAD 2100 + cold DELEGATECALL 2600). ETH stays in the contract.
function test_withdrawToSafeProxy_reverts() public {
// Do NOT touch treasurySafe/singleton before the call: in a real tx they are cold.
uint256 before = address(festival).balance;
assertEq(before, 2 ether);
​
vm.expectRevert();
festival.withdraw(address(treasurySafe));
​
assertEq(address(festival).balance, before, "proceeds still stuck in FestivalPass");
assertEq(address(treasurySafe).balance, 0, "Safe treasury received nothing");
console.log("festival balance after failed withdraw:", address(festival).balance);
​
// Control: the same Safe proxy accepts ETH fine when the sender forwards gas
// (this is what `target.call{value: ...}("")` would do in withdraw).
vm.deal(address(this), 1 ether);
(bool ok,) = address(treasurySafe).call{value: 1 ether}("");
assertTrue(ok, "Safe proxy accepts ETH via call");
assertEq(address(treasurySafe).balance, 1 ether);
​
// Mitigation available to the trusted owner: retry to an EOA; nothing is lost.
festival.withdraw(ownerEOA);
assertEq(ownerEOA.balance, 2 ether, "owner can still recover via EOA");
assertEq(address(festival).balance, 0);
}
​
/// Mechanism check (narrowing): the revert is caused by COLD access costs.
/// If the proxy's slot 0 and the singleton address are pre-warmed in the same tx
/// (what an EIP-2930 access list would do), the identical withdraw succeeds.
function test_prewarmedSafe_withdrawSucceeds_showsColdAccessIsTheCause() public {
// warm proxy account + slot 0 via SafeProxy's masterCopy() shortcut
(bool ok, bytes memory ret) = address(treasurySafe).staticcall(abi.encodeWithSelector(bytes4(0xa619486e)));
assertTrue(ok);
assertEq(abi.decode(ret, (address)), address(singleton));
// warm singleton account (BALANCE opcode)
assertEq(address(singleton).balance, 0);
​
festival.withdraw(address(treasurySafe));
assertEq(address(treasurySafe).balance, 2 ether, "warm Safe proxy receives within 2300 stipend");
assertEq(address(festival).balance, 0);
}
​
/// Generalisation: any treasury whose receive() writes storage (e.g. deposit
/// accounting) can never be a withdraw target, warm or cold, because SSTORE
/// requires more than 2300 gas left (EIP-2200).
function test_withdrawToAccountingTreasury_alwaysReverts() public {
AccountingTreasury t = new AccountingTreasury(); // created in this tx => warm
vm.expectRevert();
festival.withdraw(address(t));
assertEq(address(festival).balance, 2 ether, "proceeds still in FestivalPass");
assertEq(address(t).balance, 0);
​
// control: same treasury accepts ETH when gas is forwarded
vm.deal(address(this), 1 ether);
(bool ok,) = address(t).call{value: 1 ether}("");
assertTrue(ok);
assertEq(t.totalReceived(), 1 ether);
}
}
​
contract AccountingTreasury {
uint256 public totalReceived;
​
receive() external payable {
totalReceived += msg.value;
}
}

Observed output (excerpt):

[PASS] test_baseline_withdrawToEOA_succeeds() (gas: 45834)
[PASS] test_prewarmedSafe_withdrawSucceeds_showsColdAccessIsTheCause() (gas: 30692)
[PASS] test_withdrawToAccountingTreasury_alwaysReverts() (gas: 142320)
[PASS] test_withdrawToSafeProxy_reverts() (gas: 87811)
festival balance after failed withdraw: 2000000000000000000
Suite result: ok. 4 passed; 0 failed; 0 skipped

Recommended Mitigation

Forward all gas with a low-level call and check the result. The function is onlyOwner and changes no state after the send. The fix also emits the FundsWithdrawn event that the interface declares but the contract never emits.

- // Organizer withdraws ETH
+ // Owner withdraws ETH
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, "Withdraw failed");
+ emit FundsWithdrawn(target, amount);
}
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!