Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: medium
Invalid

Missing Role-Based Access Control

Summary

Critical access control vulnerability in treasury/repair fund update mechanisms allows any address to hijack protocol funds after timelock expiration. Missing role validation enables complete bypass of admin privileges.

Vulnerability Details

Affected Functions:

// FeeCollector.sol
function applyTreasuryUpdate() external {
// ❌ No role check!
if (pendingTreasury.newAddress == address(0)) revert InvalidAddress();
if (block.timestamp < pendingTreasury.effectiveTime) revert UnauthorizedCaller();
treasury = pendingTreasury.newAddress; // Accessible to any address
}
function applyRepairFundUpdate() external {
// Same vulnerability pattern
}

Technical Analysis:

  1. Missing Role Check: Functions lack onlyRole(DEFAULT_ADMIN_ROLE) modifier

  2. Timelock Bypass: While timelock prevents immediate execution, it doesn't restrict who can finalize updates after delay

  3. Storage Corruption: Attackers can permanently overwrite treasury/repair addresses with arbitrary values

Impact

  • Direct Fund Theft: 100% of protocol fees could be drained

  • Governance Takeover: Malicious treasury could mint unlimited tokens/change protocol rules

  • Permanent DoS: Set addresses to non-functional contracts (e.g., token burners)

Tools Used

  1. Foundry

  2. Manual Code Review

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
// Import the FeeCollector contract along with its dependencies.
import "../contracts/core/collectors/FeeCollector.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MockRAACToken is ERC20, Ownable {
constructor() ERC20("MockRAAC", "MRAAC") Ownable(msg.sender) {
// Mint 1,000,000 tokens (using 18 decimals) to the deployer.
_mint(msg.sender, 1_000_000_000e18); // Mint 1 billion tokens to have enough for tests
}
function burn(uint256 amount) external {
_burn(msg.sender, amount);
}
// Add mint function for testing
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract MockveRAACToken {
mapping(address => uint256) public votingPower;
uint256 public totalVotingPower;
function setVotingPower(address user, uint256 power) external {
votingPower[user] = power;
totalVotingPower += power;
}
function getVotingPower(address user) external view returns (uint256) {
return votingPower[user];
}
function getTotalVotingPower() external view returns (uint256) {
return totalVotingPower;
}
}
/**
* @title FeeCollectorTest
* @notice This Foundry test verifies two things:
* 1. That the fee distribution calculation (which does multiplication and division by BASIS_POINTS)
* results in distributed shares whose sum equals the total fee amount. This helps expose any rounding errors.
* 2. That the distribution period is created with a fixed 7-day window.
*/
contract FeeCollectorTest is Test {
FeeCollector feeCollector;
MockRAACToken mockRAACToken;
MockveRAACToken mockveRAACToken;
// Define test addresses.
address admin;
address user;
address distributor;
address treasury;
address repairFund;
function setUp() public {
// Set up distinct addresses using Foundry's cheatcodes.
admin = vm.addr(1);
user = vm.addr(2);
distributor = vm.addr(3);
treasury = vm.addr(4);
repairFund = vm.addr(5);
// Deploy the mocks.
mockRAACToken = new MockRAACToken();
mockveRAACToken = new MockveRAACToken();
// Deploy FeeCollector from the admin account.
vm.prank(admin);
feeCollector = new FeeCollector(
address(mockRAACToken), // raacToken
address(mockveRAACToken), // veRAACToken
treasury, // treasury address
repairFund, // repair fund address
admin // admin address (also granted DISTRIBUTOR_ROLE)
);
// Set up a non‑zero voting power for admin so that veRAAC distribution will occur.
mockveRAACToken.setVotingPower(admin, 1000 ether);
// Mint tokens to admin and transfer to test users
mockRAACToken.mint(admin, 1_000_000_000e18);
vm.startPrank(admin);
mockRAACToken.transfer(user, 100_000e18);
mockRAACToken.transfer(distributor, 100_000e18);
vm.stopPrank();
// Approve FeeCollector to spend tokens from test users
vm.startPrank(user);
mockRAACToken.approve(address(feeCollector), type(uint256).max);
vm.stopPrank();
vm.startPrank(distributor);
mockRAACToken.approve(address(feeCollector), type(uint256).max);
vm.stopPrank();
}
function testOnlyAdminCanApplyTreasuryUpdate() public {
address newTreasuryAddr = vm.addr(10);
// Admin initiates treasury update
vm.prank(admin);
feeCollector.setTreasury(newTreasuryAddr);
// Advance time past timelock
vm.warp(block.timestamp + 2 days);
// Non-admin tries to apply update - should revert
vm.prank(user);
vm.expectRevert("UnauthorizedCaller()");
feeCollector.applyTreasuryUpdate();
// Admin applies update - should succeed
vm.prank(admin);
feeCollector.applyTreasuryUpdate();
assertEq(feeCollector.treasury(), newTreasuryAddr);
}
function testOnlyAdminCanApplyRepairFundUpdate() public {
address newRepairFundAddr = vm.addr(11);
// Admin initiates repair fund update
vm.prank(admin);
feeCollector.setRepairFund(newRepairFundAddr);
// Advance time past timelock
vm.warp(block.timestamp + 2 days);
// Non-admin tries to apply update - should revert
vm.prank(user);
vm.expectRevert("UnauthorizedCaller()");
feeCollector.applyRepairFundUpdate();
// Admin applies update - should succeed
vm.prank(admin);
feeCollector.applyRepairFundUpdate();
assertEq(feeCollector.repairFund(), newRepairFundAddr);
}
}

Recommendations

  1. Immediate Fix:

function applyTreasuryUpdate() external onlyRole(DEFAULT_ADMIN_ROLE) {
// Keep existing timelock checks
treasury = pendingTreasury.newAddress;
}
  1. Additional Safeguards:

    • Add 2FA pattern requiring multi-sig confirmation

    • Implement address change cooldown (e.g., 24h window to cancel)

  2. Monitoring:

    • Create real-time alerts for treasury address changes

    • Add circuit breaker to pause distributions during updates

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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