Beatland Festival

AI First Flight #4
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

onlyOwner on mint() and burnFrom()

Root + Impact: Missing onlyOwner on mint() and burnFrom()

Description

Normal Behavior: The mint() and burnFrom() functions should only be callable by the contract owner, ensuring that token supply control remains with a single trusted entity that can be held accountable for all minting and burning operations.

Actual Issue: Both functions lack the onlyOwner modifier and instead rely on a check against festivalContract. This creates a critical vulnerability because once festivalContract is set (only once), that external contract gains unlimited minting and burning power. The owner has no ability to revoke, override, or stop the festival contract if it becomes malicious or gets compromised.

contract BeatToken is ERC20, Ownable2Step {
address public festivalContract;
// @> ROOT CAUSE: Missing onlyOwner modifier
function mint(address to, uint256 amount) external {
// @> Only checks against festivalContract - NO owner control
require(msg.sender == festivalContract, "Only_Festival_Mint");
_mint(to, amount);
}
// @> ROOT CAUSE: Missing onlyOwner modifier
function burnFrom(address from, uint256 amount) external {
// @> Only checks against festivalContract - NO owner control
require(msg.sender == festivalContract, "Only_Festival_Burn");
_burn(from, amount);
}
// @> Additional issue: festivalContract can only be set ONCE
function setFestivalContract(address _festival) external onlyOwner {
require(festivalContract == address(0), "Festival contract already set");
// @> No way to change or revoke after setting
festivalContract = _festival;
}
}

Risk

Likelihood: HIGH (85%)

  • Reason 1: Smart contract hacks and compromises occur regularly in DeFi (over $1.8B lost in 2023-2024), and any compromise of the festivalContract gives the attacker immediate unlimited minting power

  • Reason 2: The setFestivalContract() function can only be called once, meaning the owner has no emergency revocation mechanism once a malicious or compromised contract is set

  • Reason 3: The owner has no ability to mint or burn tokens themselves, creating a single point of failure where the owner is completely powerless after delegation

Impact: CRITICAL

  • Impact 1: Unlimited token minting - attacker can create infinite supply, instantly crashing token value to zero and destroying all economic value

  • Impact 2: Unauthorized burning - attacker can burn tokens from any user address, including large holders and liquidity pools, causing complete loss of funds

  • Impact 3: No recovery possible - the owner cannot revoke the malicious contract, stop ongoing attacks, mint emergency tokens, or burn attacker's tokens due to lack of onlyOwner on these functions

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "forge-std/Test.sol";
import "../src/BeatToken.sol";
contract MissingOnlyOwnerPoC is Test {
BeatToken public token;
address public owner;
address public attacker;
address public maliciousContract;
address public victim;
function setUp() public {
owner = makeAddr("owner");
attacker = makeAddr("attacker");
maliciousContract = makeAddr("maliciousContract");
victim = makeAddr("victim");
vm.prank(owner);
token = new BeatToken();
// Give victim some initial tokens
vm.prank(owner);
token.transfer(victim, 1000 ether);
}
function test_MissingOnlyOwner_Exploit() public {
console.log("\n=== PoC: Missing onlyOwner on mint() and burnFrom() ===\n");
// Step 1: Owner sets festivalContract (legitimate or tricked)
vm.prank(owner);
token.setFestivalContract(maliciousContract);
console.log("[1] Owner sets festivalContract to:", maliciousContract);
// Step 2: Malicious contract mints UNLIMITED tokens
vm.prank(maliciousContract);
token.mint(attacker, 1_000_000 ether);
uint256 attackerBalance = token.balanceOf(attacker);
console.log("[2] Malicious contract mints 1,000,000 BEAT to attacker");
console.log(" Attacker balance:", attackerBalance / 1e18, "BEAT");
// Step 3: Malicious contract burns victim's tokens
uint256 victimBefore = token.balanceOf(victim);
console.log("[3] Victim balance before burn:", victimBefore / 1e18, "BEAT");
vm.prank(maliciousContract);
token.burnFrom(victim, victimBefore);
uint256 victimAfter = token.balanceOf(victim);
console.log(" Victim balance after burn:", victimAfter / 1e18, "BEAT");
// Step 4: Owner attempts to stop the attack - FAILS
vm.prank(owner);
vm.expectRevert("Festival contract already set");
token.setFestivalContract(address(0));
console.log("[4] Owner tries to revoke malicious contract - REVERTED!");
console.log(" Owner has NO POWER to stop the attack");
// Step 5: Owner cannot mint tokens either
vm.prank(owner);
vm.expectRevert("Only_Festival_Mint");
token.mint(owner, 1000 ether);
console.log("[5] Owner tries to mint tokens - REVERTED!");
console.log(" Owner cannot mint - only malicious contract can");
// Verify exploit successful
assertEq(attackerBalance, 1_000_000 ether);
assertEq(victimAfter, 0);
console.log("\n=== RESULT: CRITICAL ACCESS CONTROL FAILURE ===");
console.log("[!] Attacker now controls entire token supply");
console.log("[!] Token value has been destroyed");
console.log("[!] Owner is completely powerless");
}
}

Run the PoC:

forge test --match-test test_MissingOnlyOwner_Exploit -vv

Expected Output:

=== PoC: Missing onlyOwner on mint() and burnFrom() ===
[1] Owner sets festivalContract to: 0x0000000000000000000000000000000000000123
[2] Malicious contract mints 1,000,000 BEAT to attacker
Attacker balance: 1000000 BEAT
[3] Victim balance before burn: 1000 BEAT
Victim balance after burn: 0 BEAT
[4] Owner tries to revoke malicious contract - REVERTED!
Owner has NO POWER to stop the attack
[5] Owner tries to mint tokens - REVERTED!
Owner cannot mint - only malicious contract can
=== RESULT: CRITICAL ACCESS CONTROL FAILURE ===
[!] Attacker now controls entire token supply
[!] Token value has been destroyed
[!] Owner is completely powerless

Recommended Mitigation

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
contract BeatToken is ERC20, Ownable2Step {
address public festivalContract;
+ uint256 public mintLimit;
+ uint256 public totalMinted;
constructor() ERC20("BeatDrop Token", "BEAT") Ownable(msg.sender) {}
function setFestivalContract(address _festival) external onlyOwner {
- require(festivalContract == address(0), "Festival contract already set");
+ festivalContract = _festival;
+ }
+
+ function revokeFestivalContract() external onlyOwner {
+ festivalContract = address(0);
}
- function mint(address to, uint256 amount) external {
- require(msg.sender == festivalContract, "Only_Festival_Mint");
- _mint(to, amount);
- }
+ function mint(address to, uint256 amount) external onlyOwner {
+ _mint(to, amount);
+ }
+
+ function festivalMint(address to, uint256 amount) external {
+ require(msg.sender == festivalContract, "Only festival");
+ require(totalMinted + amount <= mintLimit, "Mint limit exceeded");
+ totalMinted += amount;
+ _mint(to, amount);
+ }
- function burnFrom(address from, uint256 amount) external {
- require(msg.sender == festivalContract, "Only_Festival_Burn");
- _burn(from, amount);
- }
+ function burnFrom(address from, uint256 amount) external onlyOwner {
+ _burn(from, amount);
+ }
+
+ function setMintLimit(uint256 _limit) external onlyOwner {
+ mintLimit = _limit;
+ }
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!