Beatland Festival

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

Centralization Risk in `FestivalPass.sol` Leading to Fund Theft, Economic Manipulation, and Protocol Disruption

Description:

The FestivalPass.sol contract is designed with an owner and an organizer role, each having specific administrative and operational controls. Normally, these roles manage the contract's settings, funds, and content creation.

The specific issue is that both the owner and organizer roles are controlled by single addresses. This creates a severe centralization risk where a compromise of either private key grants an attacker extensive and immediate control over the contract's critical functions and funds.

// Owner's Privileged Functions (via onlyOwner modifier):
// The owner address, set at deployment, holds two highly sensitive permissions:
// Changing the organizer:
function setOrganizer(address _organizer) public @>onlyOwner { // Single-point control
require(_organizer != address(0), "Organizer cannot be zero address");
organizer = _organizer;
emit OrganizerSet(_organizer);
}
// Withdrawing all contract ETH:
function withdraw(address target) external @>onlyOwner { // Single-point control
require(target != address(0), "Target address cannot be zero");
(bool success, ) = target.call{value: address(this).balance}("");
require(success, "Failed to withdraw Ether");
emit EtherWithdrawn(target, address(this).balance);
}
// Organizer's Privileged Functions (via onlyOrganizer modifier):
// The organizer address has direct control over the project's core business logic:
// Configuring Pass Sales:
function configurePass(
uint256 passType,
uint256 price,
uint256 maxSupply
) external @>onlyOrganizer { // Single-point control
require(price > 0, "Price must be greater than zero");
passConfigs[passType].price = price;
passConfigs[passType].maxSupply = maxSupply;
passConfigs[passType].passSupply = 0; // Resets current supply, allowing new sales up to maxSupply
emit PassConfigured(passType, price, maxSupply);
}
// Creating New Performances and Memorabilia:
function createPerformance(
string memory name,
uint256 startTime,
uint256 endTime,
uint256 baseReward
) external @>onlyOrganizer returns (uint256) { // Single-point control
// ... logic to create new performances
}
function createMemorabiliaCollection(
string memory name,
string memory baseUri,
uint256 priceInBeat,
uint256 maxSupply
) external @>onlyOrganizer returns (uint256) { // Single-point control
// ... logic to create new memorabilia collections
}

Risk:

Likelihood:

  • A malicious actor gains control when the private key of either the owner or organizer account is compromised.

  • This compromise happens when an administrator's machine is infected with malware, a phishing attack is successful, or the key is mishandled (e.g., stored insecurely).

Impact:

  • All funds held in the FestivalPass contract are stolen.

  • An attacker manipulates pass prices and availability, disrupting the project's economy.

Proof of Concept:

A simplified scenario demonstrating the immediate impact of a compromised owner key:

Let's say the owner's private key is compromised. The attacker immediately calls withdraw.

Scenario: Attacker compromises owner's private key (owner = 0xOwnerAddress)

  • Attacker initiates the withdrawal from a malicious address (e.g., 0xMaliciousAddress)

  • Assume current contract balance is 100 ETH

  • FestivalPass.sol contract address: 0xFestivalPassContract

Attacker's transaction:

From: 0xOwnerAddress (compromised owner)
To: 0xFestivalPassContract
Function call: withdraw(0xMaliciousAddress)

Result:

The withdraw function executes, sending all 100 ETH from 0xFestivalPassContract to 0xMaliciousAddress. This happens instantly without any delay or further approval.

Similarly, if the organizer key is compromised, an attacker could instantly set pass prices to zero or create fraudulent items, impacting ongoing operations.

Recommended Mitigation

To significantly decentralize control and enhance security, multisig wallets should be implemented for both the owner and organizer roles. For highly sensitive operations, timelock mechanisms should also be added.

- // Current structure relying on single-address control:
- contract FestivalPass is ERC1155, Ownable2Step, IFestivalPass {
- address public organizer; // Single EOA
- // ... other contract logic
- }
+ // Proposed structure using Multi-Signature and Timelock for critical roles/functions:
+ // (Requires external Multi-Sig and Timelock contracts, e.g., Safe Wallet and OpenZeppelin TimelockController)
+ interface IMultisig {
+ function submitTransaction(address destination, uint256 value, bytes calldata data) external returns (uint256 transactionId);
+ function confirmTransaction(uint256 transactionId) external;
+ function executeTransaction(uint256 transactionId) external;
+ }
+ interface ITimelock {
+ function schedule(address target, uint256 value, bytes calldata data, bytes32 predecessor, bytes32 salt, uint256 delay) external returns (bytes32 id);
+ function execute(address target, uint256 value, bytes calldata data, bytes32 predecessor, bytes32 salt) external returns (bytes32 id);
+ }
+ contract FestivalPass is ERC1155, Ownable2Step, IFestivalPass {
+ // The 'owner' and 'organizer' should now be multi-sig contract addresses
+ // The `owner` of this contract (via Ownable2Step) would be the TimelockController
+ // The `organizer` could be a separate Multi-Sig
+ address public immutable ORGANIZER_MULTISIG; // Set at construction
+ // This contract's owner would be the TimelockController address
+ constructor(address _organizerMultisig) {
+ ORGANIZER_MULTISIG = _organizerMultisig;
+ }
+ // Update setOrganizer to be called by the Timelock (owner of this contract)
+ // The Timelock's admin would propose this change via Multi-Sig.
- function setOrganizer(address _organizer) public onlyOwner {
+ function setOrganizer(address _newOrganizer) public onlyOwner { // onlyOwner is now Timelock
+ require(_newOrganizer != address(0), "Organizer cannot be zero address");
+ // The actual `organizer` state variable should ideally be managed via the new `ORGANIZER_MULTISIG`
+ emit OrganizerSet(_newOrganizer);
+ }
+ // Modify withdraw to be callable only by the Timelock,
+ // which requires a delay and prior scheduling from its own admin (Multi-Sig).
- function withdraw(address target) external onlyOwner {
+ function withdraw(address target) external onlyOwner { // onlyOwner is now Timelock
+ require(target != address(0), "Target address cannot be zero");
+ (bool success, ) = target.call{value: address(this).balance}("");
+ require(success, "Failed to withdraw Ether");
+ emit EtherWithdrawn(target, address(this).balance);
+ }
// Modify `onlyOrganizer` modifier to use the `ORGANIZER_MULTISIG` address
- modifier onlyOrganizer() {
- require(msg.sender == organizer, "Caller is not the organizer");
+ modifier onlyOrganizer() { // This modifier now points to the Multi-Sig
+ require(msg.sender == ORGANIZER_MULTISIG, "Caller is not the designated organizer multisig");
_;
}
+ // All functions previously protected by onlyOrganizer are now effectively
+ // controlled by the `ORGANIZER_MULTISIG` which requires multiple approvals.
- function configurePass(
- uint256 passType,
- uint256 price,
- uint256 maxSupply
- ) external onlyOrganizer {
+ function configurePass(
+ uint256 passType,
+ uint256 price,
+ uint256 maxSupply
+ ) external onlyOrganizer { // This now requires multi-sig approval
+ require(price > 0, "Price must be greater than zero");
+ passConfigs[passType].price = price;
+ passConfigs[passType].maxSupply = maxSupply;
+ passConfigs[passType].passSupply = 0;
+ emit PassConfigured(passType, price, maxSupply);
+ }
+ // Similar changes apply to createPerformance and createMemorabiliaCollection.
}

Switching to multi-signature control for important roles and adding timelocks for crucial actions will greatly improve the contract's security, bringing it more in line with decentralized best practices.

Updates

Lead Judging Commences

inallhonesty Lead Judge 25 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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