Core Contracts

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

Locked Funds in Emergency Revoke Function

Summary

The emergencyRevoke function in RAACReleaseOrchestrator contract transfers revoked tokens to the contract's own address (address(this)) without any mechanism to withdraw these tokens, resulting in permanent token lockup.

Vulnerability Details

function emergencyRevoke(address beneficiary) external onlyRole(EMERGENCY_ROLE) {
// ...existing code...
if (unreleasedAmount > 0) {
raacToken.transfer(address(this), unreleasedAmount); // @audit - tokens sent to contract
emit EmergencyWithdraw(beneficiary, unreleasedAmount);
}
// ...existing code...
}

The issue occurs because:

  1. Tokens are transferred to address(this)

  2. No withdrawal function exists to recover these tokens

  3. No designated treasury address to receive revoked tokens

  4. Contract lacks rescue functionality for stuck tokens

Impact

  • All tokens revoked through emergency procedures become permanently locked

  • Could affect significant portions of the total token supply

  • Financial loss equivalent to the value of locked tokens

  • Risk Rating: HIGH (permanent loss of assets)

Proof of Concept

  1. Admin creates vesting schedule for user with 1000 tokens

  2. Emergency role calls emergencyRevoke on user's schedule

  3. 1000 tokens get transferred to contract

  4. No mechanism exists to withdraw these tokens

  5. Tokens are permanently locked

const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("EmergencyRevokePOC", function () {
let token;
let orchestrator;
let exploit;
let admin, emergency, beneficiary, treasury;
before(async function () {
[admin, emergency, beneficiary, treasury, ...addrs] = await ethers.getSigners();
// Deploy dummy ERC20 token (ERC20Mock)
const ERC20Mock = await ethers.getContractFactory("ERC20Mock");
token = await ERC20Mock.deploy("RAAC Token", "RAAC", admin.address, ethers.utils.parseEther("100000000"));
await token.deployed();
// Deploy RAACReleaseOrchestrator
const RAACReleaseOrchestrator = await ethers.getContractFactory("RAACReleaseOrchestrator");
orchestrator = await RAACReleaseOrchestrator.deploy(token.address, treasury.address);
await orchestrator.deployed();
// Grant EMERGENCY_ROLE to the emergency account
const EMERGENCY_ROLE = await orchestrator.EMERGENCY_ROLE();
await orchestrator.connect(admin).grantRole(EMERGENCY_ROLE, emergency.address);
// Fund the orchestrator with tokens for withdrawals (if needed)
await token.connect(admin).transfer(orchestrator.address, ethers.utils.parseEther("5000000"));
// Create vesting schedule for beneficiary using ORCHESTRATOR_ROLE
const currentTime = (await ethers.provider.getBlock("latest")).timestamp;
await orchestrator.connect(admin).createVestingSchedule(
beneficiary.address,
ethers.utils.formatBytes32String("TEAM"),
ethers.utils.parseEther("1000"),
currentTime
);
});
it("should execute emergency revoke and clear tokens from orchestrator", async function () {
// Deploy EmergencyRevokePOC as emergency account
const EmergencyRevokePOC = await ethers.getContractFactory("EmergencyRevokePOC", emergency);
exploit = await EmergencyRevokePOC.deploy(orchestrator.address);
await exploit.deployed();
// Trigger emergency revoke for the beneficiary
await exploit.connect(emergency).runExploit(beneficiary.address);
// Verify tokens are not stuck in the orchestrator (balance should be zero)
const stuck = await exploit.stuckTokens();
expect(stuck).to.equal(0);
});
});

Recommendations

Implement one of these solutions:

  1. Add treasury address (Preferred):

address public immutable treasury;
constructor(address _raacToken, address _treasury) {
require(_treasury != address(0), "Invalid treasury");
treasury = _treasury;
// ...existing code...
}
function emergencyRevoke(address beneficiary) external onlyRole(EMERGENCY_ROLE) {
// ...existing code...
if (unreleasedAmount > 0) {
raacToken.transfer(treasury, unreleasedAmount);
emit EmergencyWithdraw(beneficiary, unreleasedAmount);
}
// ...existing code...
}
  1. Or add withdrawal mechanism:

function withdrawLockedTokens(address to, uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) {
raacToken.transfer(to, amount);
emit LockedTokensWithdrawn(to, amount);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RAACReleaseOrchestrator::emergencyRevoke sends revoked tokens to contract address with no withdrawal mechanism, permanently locking funds

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RAACReleaseOrchestrator::emergencyRevoke sends revoked tokens to contract address with no withdrawal mechanism, permanently locking funds

Support

FAQs

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