The BEAT contract inherits "AccessControl" mechanism but does not expose or properly implement role lifecycle management functions (grant), which makes the FESTIVAL_ROLE irrevocable once assigned in the contract.
Proposed fix to root cause which creates a permanant one way lock to zero implementation in the festival contract
The festival contract is compromised, contain bugs and the token contract is permanently unusable. Therefore the contract upgrade will be effected. All BEAT tokens will be worthless as no new festival contract can be set to restore functionality. The token economy could fail as a result of this vulnerability.
pragma solidity ^0.8.25;
import {Test} from "forge-std/Test.sol";
import {console} from "forge-std/console.sol";
import {IERC20} from "openzeppelin-contracts/token/ERC20/IERC20.sol";
import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";
import {AccessControl} from "openzeppelin-contracts/access/AccessControl.sol";
import {ERC20Burnable} from "openzeppelin-contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {Ownable} from "openzeppelin-contracts/access/Ownable.sol";
@author Security Researcher
@notice This PoC demonstrates the risk of permanently locking a contract reference
due to a one-time set function without upgrade capabilities.
@dev This is inspired by the FestivalLock issue in Festival.sol
*/
contract VulnerableTicketSystem {
address public festivalContract;
address public owner;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Only Owner");
_;
}
function setFestivalContract(address _festival) external onlyOwner {
require(festivalContract == address(0), "Festival contract already set");
festivalContract = _festival;
}
}
contract MaliciousFestival {
function exploit() external pure returns (string memory) {
return "This is a malicious contract";
}
}
contract UpgradedFestival {
function newFeature() external pure returns (string memory) {
return "This is an upgraded festival contract";
}
}
contract FestivalLockPoC is Test {
VulnerableTicketSystem public ticketSystem;
MaliciousFestival public maliciousFestival;
UpgradedFestival public upgradedFestival;
address public owner;
function setUp() public {
owner = address(this);
ticketSystem = new VulnerableTicketSystem();
maliciousFestival = new MaliciousFestival();
upgradedFestival = new UpgradedFestival();
}
function testPermanentFestivalLock() public {
console.log("Step 1: Owner sets malicious festival contract");
ticketSystem.setFestivalContract(address(maliciousFestival));
console.log("Festival contract set to:", ticketSystem.festivalContract());
console.log("Step 2: Attempting to upgrade to a new festival contract");
try ticketSystem.setFestivalContract(address(upgradedFestival)) {
console.log("Upgrade succeeded unexpectedly!");
} catch {
console.log("Upgrade failed as expected: Festival contract is permanently locked.");
}
console.log("Step 3: Verifying the festival contract remains unchanged");
require(ticketSystem.festivalContract() == address(maliciousFestival), "Festival contract should remain the malicious one");
console.log("Festival contract is still:", ticketSystem.festivalContract());
console.log("Test completed successfully.");
}
}
The festival contract mitigation provides total protection against the vulnerability thereby maintaining flexibility for upgrades, security checks and business evolution process.
- remove this code
function setFestivalContract(address _festival) external onlyOwner {
require(festivalContract == address(0), "Festival contract already set"); //@audit cannot be reused for other festivals
festivalContract = _festival;
}
+add this code
// Upgradeable Ticket System Contract with Timelock
contract UpgradeableTicketSystem is Ownable {
address public festivalContract;
event FestivalContractUpgradeProposed(address indexed newContract, uint256 execute after);
event FestivalContractUpgraded(address indexed newContract);
uint256 public constant UPGRADE_TIMELOCK = 1 days;
address public pendingFestivalContract;
uint256 public upgradeTimestamp;
function proposedUpgrade(address _newFestivalContract) external onlyOwner {
require(_newFestivalContract != address(0), "Invalid festival address");
pendingFestivalContract = _newFestivalContract;
upgradeTimestamp = block.timestamp + UPGRADE_TIMELOCK;
emit FestivalContractUpgradeProposed(_newFestivalContract, upgradeTimestamp);
}
function executeUpgrade() external onlyOwner {
require(pendingFestivalContract != address(0), "No upgrade proposed");
require(block.timestamp >= upgradeTimestamp, "Upgrade timelock not expired");
festivalContract = pendingFestivalContract;
pendingFestivalContract = address(0);
upgradeTimestamp = 0;
emit FestivalContractUpgraded(festivalContract);
}