Core Contracts

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

Legitimate Proposals cannot be executed more than once

Summary

The Governance and TimelockController are in charge of executing proposals that veRaacToken holders vote for. Legitimate proposals that had been successfully executed before, cannot be executed again due to the way TimelockController tracks operations.

Vulnerability Details

In TimelockController, operations are identified by a bytes32 id. This id is a hash of the operation's parameters (targets, values, calldatas, predecessor, and salt).

function hashOperationBatch(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata calldatas,
bytes32 predecessor,
bytes32 salt
) public pure returns (bytes32) {
return keccak256(abi.encode(targets, values, calldatas, predecessor, salt));
}

This means if two proposals have identical parameters, where salt is keccak256(bytes(description)), they will have the same id.

Once an operation is scheduled and executed, its state timestamp and executed is stored in the _operations mapping:

_operations[id] = Operation({
timestamp: timestamp.toUint64(),
executed: false
});

After execution, executed is set to true, but the timestamp remains unchanged. This means that the state for this operation id is not cleared after execution.

When the same proposal that generates the same id is sent for execution via execute(), it will revert in scheduleBatch() as OperationAlreadyScheduled.

Impact

Legitimate Proposals cannot be executed more than once

Tools Used

Manual Review

PoC

Foundry PoC:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import {IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "../../contracts/libraries/math/PercentageMath.sol";
import "../../contracts/libraries/math/WadRayMath.sol";
import {BaseSetup} from "./BaseSetup.t.sol";
import "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import "../../contracts/interfaces/core/governance/proposals/IGovernance.sol";
import "../../contracts/core/collectors/FeeCollector.sol";
import "../../contracts/mocks/core/governance/proposals/TimelockTestTarget.sol";
import "../../contracts/core/governance/proposals/TimelockController.sol";
contract GovernanceTest is BaseSetup {
using WadRayMath for uint256;
using PercentageMath for uint256;
using SafeCast for uint256;
using SafeERC20 for IERC20;
// IERC20 baseUSDC = IERC20(address(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913));
// address baseUSDCWhale = address(0x20FE51A9229EEf2cF8Ad9E89d91CAb9312cF3b7A);
IERC20 mainnetUSDC = IERC20(address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48));
address mainnetUsdcCurveUSDVault = address(0x7cA00559B978CFde81297849be6151d3ccB408A9);
address curveUSDWhale = address(0x4e59541306910aD6dC1daC0AC9dFB29bD9F15c67);
address newUser = makeAddr("new user");
address newUser2 = makeAddr("new user2");
address newUser3 = makeAddr("new user3");
address newUser4 = makeAddr("new user4");
function setUp() public override {
// string memory BASE_RPL_URL = vm.envString("BASE_RPC_URL");
// uint256 baseFork = vm.createFork(BASE_RPL_URL, 20265816);
// vm.selectFork(baseFork);
string memory MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
uint256 mainnetFork = vm.createFork(MAINNET_RPC_URL, 19614507);
vm.selectFork(mainnetFork);
super.setUp();
}
function test_SubmitSameProposalAfterExecution() public {
// // Pre-conditions:
// // - RAAC tokens have been minted and distributed to users.
// // - Users can lock RAAC tokens to obtain veRAAC tokens (which provide voting power).
vm.startPrank(address(minter));
raacToken.mint(newUser, 10_000_000e18);
raacToken.mint(newUser2, 10_000_000e18);
vm.stopPrank();
// // STEP 1: Lock RAAC Tokens to Obtain Voting Power
vm.startPrank(newUser);
raacToken.approve(address(veRaacToken), 4_000_000e18);
veRaacToken.lock(4_000_000e18,365 days);
assertEq(veRaacToken.balanceOf(newUser),1_000_000e18);
vm.stopPrank();
vm.startPrank(newUser2);
raacToken.approve(address(veRaacToken), 4_000_000e18);
veRaacToken.lock(4_000_000e18,365 days);
assertEq(veRaacToken.balanceOf(newUser2),1_000_000e18);
// uint256 votingPower1 = veRaacToken.getVotingPower(newUser2);// 1e24
// console.log("votingPower1: %e",votingPower1); // 1e24
vm.stopPrank();
// // STEP 2: Create a Proposal
// // User1 (who has sufficient voting power) creates a new governance proposal.
TimelockTestTarget testTarget = new TimelockTestTarget();
//Assert Test TargetValue is 0
assertEq(testTarget.value(),0);
address[] memory targets = new address[](1);
// - targets: [Address of a contract to be called (e.g., a test target)]
targets[0] = address(testTarget);
// - values: [ETH values to send (usually 0 if not sending ETH)]
uint256[] memory values = new uint256[](1);
values[0] = 0;
// - calldatas: [Encoded function call data, e.g., setValue(42)]
bytes[] memory calldatas = new bytes[](1);
calldatas[0] = abi.encodeWithSignature("setValue(uint256)", 42);
// - description: "Set Value to 42"
string memory description = "Set Value to 42";
// - proposalType: (e.g., a specific proposal type enumeration)
IGovernance.ProposalType proposalType = IGovernance.ProposalType.GaugeManagement;
// User1 calls governance.propose(targets, values, calldatas, description, proposalType)
vm.startPrank(newUser);
uint256 proposalId = governance.propose(targets, values, calldatas, description,proposalType);
// -> ProposalID is returned.
// Assert ProposalID is valid
assertEq(proposalId,0);
vm.stopPrank();
// // STEP 3: Wait for Voting Delay to Elapse
// Simulate time progression by advancing the blockchain time by 'votingDelay' seconds
skip(1 days);
// // STEP 4: Vote on the Proposal
// User2 calls governance.castVote(ProposalID, support = true)
// (Optionally, User1 can cast a vote as well)
vm.startPrank(newUser2);
governance.castVote(proposalId,true);
// STEP 6: Wait for Voting Period to End
skip(7 days);
// Step 7: Execute for the first time
governance.execute(proposalId);
// STEP 8: Wait for Timelock Delay to Elapse
// Simulate time progression by advancing time by the timelock delay period
skip(16 days);
// STEP 9: Execute the Proposal for the second time
governance.execute(proposalId);
// STEP 10: Verify Proposal Effects
// Assert that targetContract.value() == 42
assertEq(testTarget.value(),42);
// Step 11. Make the same proposal. This is a legitimate proposal
uint256 newProposalId = governance.propose(targets, values, calldatas, description,proposalType);
// Step 12: Wait for Voting Delay to Elapse
skip(1 days);
// Step 13: Cast Votes, to ensure that it wins
governance.castVote(newProposalId, true);
// Step 14: Wait for Voting Period To End
skip(7 days);
// Step 15: Execute New Proposal for the first time
// But the same params is used to calculate the `id` for _operations in TimeLockController
// and scheduledOperations are not cleared, this will revert
bytes32 id = timelockController.hashOperationBatch(targets, values, calldatas, bytes32(0), keccak256(bytes(description)));
vm.expectRevert(abi.encodeWithSelector(ITimelockController.OperationAlreadyScheduled.selector,id));
governance.execute(newProposalId);
}
}

Using this BaseSetup:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
// OpenZeppelin Imports
// import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
// import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
// Primitives Import
import {RAACHousePrices} from "../../contracts/core/primitives/RAACHousePrices.sol";
// Token Imports
import {DebtToken} from "../../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../../contracts/core/tokens/DEToken.sol";
import {IndexToken} from "../../contracts/core/tokens/IndexToken.sol";
import {LPToken} from "../../contracts/core/tokens/LPToken.sol";
import {RAACNFT} from "../../contracts/core/tokens/RAACNFT.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RToken} from "../../contracts/core/tokens/RToken.sol";
import {veRAACToken} from "../../contracts/core/tokens/veRAACToken.sol";
// Collector Imports
import {Treasury} from "../../contracts/core/collectors/Treasury.sol";
import {FeeCollector} from "../../contracts/core/collectors/FeeCollector.sol";
// Governance Imports
import {Governance} from "../../contracts/core/governance/proposals/Governance.sol";
import {TimelockController} from "../../contracts/core/governance/proposals/TimelockController.sol";
// Gauge Imports
import {BaseGauge} from "../../contracts/core/governance/gauges/BaseGauge.sol";
import {GaugeController} from "../../contracts/core/governance/gauges/GaugeController.sol";
import {RAACGauge} from "../../contracts/core/governance/gauges/RAACGauge.sol";
import {RWAGauge} from "../../contracts/core/governance/gauges/RWAGauge.sol";
// Boost Import
import {BoostController} from "../../contracts/core/governance/boost/BoostController.sol";
// Minter Imports
import {RAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {RAACReleaseOrchestrator} from "../../contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol";
// Oracle Imports
import {BaseChainlinkFunctionsOracle} from "../../contracts/core/oracles/BaseChainlinkFunctionsOracle.sol";
import {RAACHousePriceOracle} from "../../contracts/core/oracles/RAACHousePriceOracle.sol";
import {RAACPrimeRateOracle} from "../../contracts/core/oracles/RAACPrimeRateOracle.sol";
// Pool Imports
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {MarketCreator} from "../../contracts/core/pools/StabilityPool/MarketCreator.sol";
import {NFTLiquidator} from "../../contracts/core/pools/StabilityPool/NFTLiquidator.sol";
contract BaseSetup is Test {
IERC20 baseUSDC = IERC20(address(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913));
address baseUSDCWhale = address(0x20FE51A9229EEf2cF8Ad9E89d91CAb9312cF3b7A);
// Collectors
Treasury treasury;
Treasury repairFund;
FeeCollector feeCollector;
// Tokens
IERC20 crvUSD = IERC20(address(0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E)); // main
IERC20 basecrvUSD = IERC20(address(0x417Ac0e078398C154EdFadD9Ef675d30Be60Af93)); // base
RAACToken raacToken;
veRAACToken veRaacToken;
RAACNFT raacNFT;
RToken rToken;
DebtToken debtToken;
DEToken deToken;
address mainnetChainlinkRouter = address(0x65Dcc24F8ff9e51F10DCc7Ed1e4e2A61e6E14bd6);
//m Orchestatro
RAACReleaseOrchestrator raacReleaseOrchestrator;
RAACHousePrices housePrices;
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACMinter minter;
// Governance
Governance public governance;
TimelockController public timelockController;
BoostController public boostController;
GaugeController public gaugeController;
RAACGauge public raacGauge;
RWAGauge public rwaGauge;
address owner;
address user1;
address user2;
address user3;
function setUp() public virtual {
owner = makeAddr("owner");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
vm.startPrank(owner);
// collectors
//
// Setup Tokens
raacToken = new RAACToken(owner, 100, 50);
veRaacToken = new veRAACToken(address(raacToken));
// veRAACToken
// RAACNFT
// DeToken
// Setup contracts
raacReleaseOrchestrator = new RAACReleaseOrchestrator(address(raacToken));
housePrices = new RAACHousePrices(owner);
housePrices.setOracle(owner);
raacNFT = new RAACNFT(address(crvUSD), address(housePrices), owner);
rToken = new RToken("RToken", "RT", owner, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", owner);
lendingPool = new LendingPool(
address(crvUSD), address(rToken), address(debtToken), address(raacNFT), address(housePrices), 0.1e27
);
console.log("lendingPool:", address(lendingPool));
treasury = new Treasury(owner);
repairFund = new Treasury(owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
stabilityPool = new StabilityPool(owner);
minter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner); // should be owner
feeCollector =
new FeeCollector(address(raacToken), address(veRaacToken), address(treasury), address(repairFund), owner);
raacToken.setFeeCollector(address(feeCollector));
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(veRaacToken), true);
raacToken.manageWhitelist(owner, true);
raacToken.setMinter(address(owner));
raacToken.mint(user2, 1000e18);
raacToken.mint(user3, 1000e18);
feeCollector.grantRole(feeCollector.FEE_MANAGER_ROLE(), owner);
feeCollector.grantRole(feeCollector.EMERGENCY_ROLE(), owner);
feeCollector.grantRole(feeCollector.DISTRIBUTOR_ROLE(), owner);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
//
raacToken.setMinter(address(minter));
raacToken.transferOwnership(address(minter));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(minter),
address(crvUSD),
address(lendingPool)
);
lendingPool.setStabilityPool(address(stabilityPool));
// Governance Components
// Timelock controller
address[] memory proposers = new address[](1);
proposers[0] = owner;
address[] memory executors = new address[](1);
executors[0] = owner;
timelockController = new TimelockController(2 days, proposers, executors, owner);
// Governance
governance = new Governance(address(veRaacToken), address(timelockController));
timelockController.grantRole(timelockController.PROPOSER_ROLE(), address(governance));
timelockController.grantRole(timelockController.EXECUTOR_ROLE(), address(governance));
timelockController.grantRole(timelockController.CANCELLER_ROLE(), address(governance));
//Gauge
gaugeController = new GaugeController(address(veRaacToken));
vm.stopPrank();
}
}

Recommendations

Add code as follows:

function executeBatch(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata calldatas,
bytes32 predecessor,
bytes32 salt
) external override payable nonReentrant onlyRole(EXECUTOR_ROLE) {
//...omitted for brevity
// Mark as executed before external calls
op.executed = true;
// Execute each call
for (uint256 i = 0; i < targets.length; i++) {
(bool success, bytes memory returndata) = targets[i].call{value: values[i]}(calldatas[i]);
if (!success) {
revert CallReverted(id, i);
}
}
+ delete _operations[id];
emit OperationExecuted(id, targets, values, calldatas, predecessor, salt);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 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 7 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.

Give us feedback!