Beatland Festival

First Flight #44
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Lack of Purchase Limits in FestivalPass::buyPass, enables MEV/Front-running attacks

Lack of Purchase Limits in FestivalPass::buyPass Enables MEV/Front-running Attacks

Description

  • The FestivalPass::buyPass function does not implement any restrictions on the number of purchases per-address or enforce anti-front-running measures.

  • Opening an attack surface for MEV bots and malicious actors who can monitor the mempool and front-run legitimate users by bulk-purchasing all available exclusive passes before others.

@> 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: Can occur on every single transaction in the mempool.

Impact:

  • High - affects all users, acquire all passes ahead of legitimate users by front-running their transactions

  • Resell passes on secondary markets at inflated prices for profit

  • Create an unfair and exclusionary environment, reducing user trust

Proof of Concept

  1. User1 tries to buy the pass and the transaction is waiting in the public mempool.

  2. User2 is also trying to buy the pass.

  3. An Attacker front-runs all these transactions by submitting bulk purchase with higher gas price.

function test_MEV_attack_On_Buy_Pass() external {
//vm.prank(user1);
//user1 intends to buy the exclusive pass
//festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
//vm.prank(user2);
//user2 intends to buy the exclusive pass
//festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
//Atacker front runs all the transactions by purchasing all the passes in bulk
address randomUser = makeAddr('random');
BulkPassBuyer bulkBuyer = new BulkPassBuyer(festivalPass);
hoax(randomUser, 50 ether);
for(uint i = 0 ; i < BACKSTAGE_MAX_SUPPLY ; i ++){
bulkBuyer.buyPass{value: BACKSTAGE_PRICE}();
}
vm.stopPrank();
assertEq(festivalPass.balanceOf(address(bulkBuyer), 3), BACKSTAGE_MAX_SUPPLY);
vm.prank(user1);
//user1 transaction goes through resulting in error
vm.expectRevert("Max supply reached");
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
vm.prank(user2);
//user2 transaction goes through resulting in error
vm.expectRevert("Max supply reached");
festivalPass.buyPass{value: BACKSTAGE_PRICE}(3);
}
contract BulkPassBuyer{
FestivalPass immutable pass;
constructor(FestivalPass _pass){
pass = _pass;
}
function buyPass() external payable {
pass.buyPass{value: msg.value}(3); // Buy BACKSTAGE pass
}
function onERC1155Received(address, address, uint256, uint256, bytes memory) public pure returns (bytes4) {
return this.onERC1155Received.selector;
}
}

Recommended Mitigation

  1. Enforce per-address purchase limits with cooldown

  2. Inducing commit-reveal scheme to bypass MEV-attack

  3. Recommended Approach: Implement both the above options for high-value passes.

+ //Enforce per-address purchase limits with cooldown
+ mapping(address => mapping(uint256 => uint256)) private lastPurchaseTime;
+ mapping(address => mapping(uint256 => uint256)) private purchaseCount;
+ uint256 constant PURCHASE_COOLDOWN = 1 hours;
+ uint256 constant MAX_PURCHASES_PER_ADDRESS = 1;
function buyPass(uint256 collectionId) external payable {
+ require(
+ purchaseCount[msg.sender][collectionId] < MAX_PURCHASES_PER_ADDRESS,
+ "Purchase limit exceeded"
+ );
+ require(
+ block.timestamp >= lastPurchaseTime[msg.sender][collectionId] + PURCHASE_COOLDOWN,
+ "Cooldown period active"
+ );
// 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];
+ lastPurchaseTime[msg.sender][collectionId] = block.timestamp;
+ purchaseCount[msg.sender][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);
}
+ //Inducing commit-reveal scheme to bypass MEV-attack
+ mapping(address => bytes32) private commitments;
+ mapping(address => uint256) private commitmentTimestamp;
+ uint256 constant REVEAL_DELAY = 10 minutes;
+ function commitToPurchase(bytes32 commitment) external {
+ commitments[msg.sender] = commitment;
+ commitmentTimestamp[msg.sender] = block.timestamp;
+ }
+ function revealAndPurchase(
+ uint256 collectionId,
+ uint256 nonce
+ ) external payable {
+ require(block.timestamp >= commitmentTimestamp[msg.sender] + REVEAL_DELAY,"Reveal period not reached"
+ );
+ require(keccak256(abi.encodePacked(msg.sender, collectionId, nonce)) == commitments[msg.sender],
+ "Invalid reveal"
+ );
. //Rest of buyPass logic
+ delete commitments[msg.sender];
+ }
Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.