Beatland Festival

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

Missing Reentrancy Protection on ETH Withdrawals

Root + Impact

Description

  • Normal behavior:
    The withdraw function should safely transfer the contract’s ETH balance to a target address, ensuring no external contract can interfere with or re-enter the withdrawal logic.

    Specific issue:
    The withdraw function lacks a reentrancy guard. If the target is a contract, its receive() or fallback() function can re-enter withdraw during the ETH transfer, potentially leading to double-withdrawals or other logic errors if the function is ever extended.

// Root cause in the codebase with @> marks to highlight the relevant section
function withdraw(address target) external onlyOwner {
@> payable(target).transfer(address(this).balance);
}

Risk

Likelihood:

  • The bug will occur whenever the owner withdraws to a contract address that attempts to re-enter withdraw during the ETH transfer.

  • The risk increases if future changes add state changes or additional logic after the transfer.

Impact:

  • Allows a malicious contract to re-enter the withdrawal logic, which could lead to double-withdrawals or other attacks if the function is modified.

  • Violates best practices and exposes the contract to upgradability and composability risks.

Proof of Concept

The following Foundry test demonstrates the vulnerability. It deploys a malicious contract that re-enters withdraw during the ETH transfer. The test fails with "Reentrancy was attempted," proving the lack of a reentrancy guard.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
contract ReentrantReceiver {
FestivalPass public festival;
address public owner;
bool public attempted;
constructor(FestivalPass _festival) {
festival = _festival;
owner = msg.sender;
}
receive() external payable {
// Attempt to re-enter withdraw (should only be possible if not protected)
if (!attempted) {
attempted = true;
// Try to re-enter withdraw
try festival.withdraw(address(this)) {
// If this succeeds, reentrancy is possible
} catch {}
}
}
}
contract FestivalPass_ReentrancyProof is Test {
FestivalPass festival;
BeatToken beat;
address owner = address(0xABCD);
function setUp() public {
beat = new BeatToken();
vm.startPrank(owner);
festival = new FestivalPass(address(beat), owner);
vm.stopPrank();
beat.setFestivalContract(address(festival));
vm.deal(address(festival), 1 ether);
}
function test_ReentrancyOnWithdraw() public {
// Deploy malicious receiver
ReentrantReceiver attacker = new ReentrantReceiver(festival);
vm.startPrank(owner);
// This should call attacker's receive(), which will try to re-enter withdraw
// We expect a revert on the reentrant call (due to empty balance), but the lack of reentrancy guard is the bug
try festival.withdraw(address(attacker)) {
// If no revert, still check if reentrancy was attempted
assertTrue(attacker.attempted(), "Reentrancy was attempted");
} catch {
// If revert, still check if reentrancy was attempted
assertTrue(attacker.attempted(), "Reentrancy was attempted");
}
vm.stopPrank();
}
}

Recommended Mitigation

To prevent reentrancy, add a reentrancy guard to the withdraw function. The recommended fix is to inherit from OpenZeppelin's ReentrancyGuard and add the nonReentrant modifier:

- function withdraw(address target) external onlyOwner {
- payable(target).transfer(address(this).balance);
- }
+ import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
+
+ contract FestivalPass is ... , ReentrancyGuard {
+ // ...existing code...
+ function withdraw(address target) external onlyOwner nonReentrant {
+ payable(target).transfer(address(this).balance);
+ }
+ }
Updates

Lead Judging Commences

inallhonesty Lead Judge 3 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.