Beatland Festival

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

[L-01] `withdraw` uses `.transfer()` with 2300 gas stipend, blocking withdrawals to smart contract wallets

Description

withdraw sends ETH with .transfer(), which forwards only 2300 gas. Since EIP-1884 (Istanbul hard fork), SLOAD costs 800 gas, making the 2300 stipend insufficient for smart contract wallets like Gnosis Safe that execute logic in their receive() function. If the owner sets a multisig as the target, all withdrawal attempts revert and ETH from pass sales is stuck in the contract.

Vulnerability Details

// src/FestivalPass.sol, line 147-149
function withdraw(address target) external onlyOwner {
payable(target).transfer(address(this).balance); // @> 2300 gas limit
}

Gnosis Safe's receive() fallback hits a DELEGATECALL to its fallback handler, which consumes well over 2300 gas. The same applies to other multisig wallets and smart contract wallets (e.g., Argent, Sequence). The withdrawal silently reverts with no error message, and since there is no alternative withdrawal path, the ETH remains locked until the owner calls withdraw with an EOA target.

Risk

Likelihood:

  • Requires the owner to pass a smart contract wallet address as target. Many DAOs and protocol teams use multisigs as their treasury address, making this a realistic scenario.

Impact:

  • ETH from pass sales is temporarily stuck. The owner can work around this by calling withdraw with an EOA address, so funds are not permanently lost. But it creates operational friction and confusion.

Proof of Concept

The test deploys a contract with a receive() function that costs more than 2300 gas, then shows that withdraw to that address reverts.

contract ExpensiveReceiver {
uint256 public count;
receive() external payable {
// Simulates smart contract wallet logic (SLOAD + SSTORE > 2300 gas)
count += 1;
}
}
function testExploit_WithdrawTransferFails() public {
// Configure and sell a pass to put ETH in the contract
vm.prank(organizer);
festivalPass.configurePass(1, 1 ether, 10);
vm.deal(user, 1 ether);
vm.prank(user);
festivalPass.buyPass{value: 1 ether}(1);
assertEq(address(festivalPass).balance, 1 ether, "Contract holds 1 ETH");
// Deploy a receiver that uses > 2300 gas (like a multisig)
ExpensiveReceiver receiver = new ExpensiveReceiver();
// Withdraw to smart contract wallet fails
vm.prank(festivalPass.owner());
vm.expectRevert();
festivalPass.withdraw(address(receiver));
// ETH is still stuck in the contract
assertEq(address(festivalPass).balance, 1 ether, "ETH stuck: withdraw failed");
}

Output:

Contract balance: 1 ETH
Withdraw to smart contract wallet: REVERTED
Contract balance after failed withdraw: 1 ETH

Recommendations

Replace .transfer() with a low-level call:

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 8 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!