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:
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 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 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 {
vm.startPrank(address(minter));
raacToken.mint(newUser, 10_000_000e18);
raacToken.mint(newUser2, 10_000_000e18);
vm.stopPrank();
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);
vm.stopPrank();
TimelockTestTarget testTarget = new TimelockTestTarget();
assertEq(testTarget.value(),0);
address[] memory targets = new address[](1);
targets[0] = address(testTarget);
uint256[] memory values = new uint256[](1);
values[0] = 0;
bytes[] memory calldatas = new bytes[](1);
calldatas[0] = abi.encodeWithSignature("setValue(uint256)", 42);
string memory description = "Set Value to 42";
IGovernance.ProposalType proposalType = IGovernance.ProposalType.GaugeManagement;
vm.startPrank(newUser);
uint256 proposalId = governance.propose(targets, values, calldatas, description,proposalType);
assertEq(proposalId,0);
vm.stopPrank();
skip(1 days);
vm.startPrank(newUser2);
governance.castVote(proposalId,true);
skip(7 days);
governance.execute(proposalId);
skip(16 days);
governance.execute(proposalId);
assertEq(testTarget.value(),42);
uint256 newProposalId = governance.propose(targets, values, calldatas, description,proposalType);
skip(1 days);
governance.castVote(newProposalId, true);
skip(7 days);
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:
pragma solidity ^0.8.19;
import "forge-std/Test.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";
import {RAACHousePrices} from "../../contracts/core/primitives/RAACHousePrices.sol";
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";
import {Treasury} from "../../contracts/core/collectors/Treasury.sol";
import {FeeCollector} from "../../contracts/core/collectors/FeeCollector.sol";
import {Governance} from "../../contracts/core/governance/proposals/Governance.sol";
import {TimelockController} from "../../contracts/core/governance/proposals/TimelockController.sol";
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";
import {BoostController} from "../../contracts/core/governance/boost/BoostController.sol";
import {RAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {RAACReleaseOrchestrator} from "../../contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol";
import {BaseChainlinkFunctionsOracle} from "../../contracts/core/oracles/BaseChainlinkFunctionsOracle.sol";
import {RAACHousePriceOracle} from "../../contracts/core/oracles/RAACHousePriceOracle.sol";
import {RAACPrimeRateOracle} from "../../contracts/core/oracles/RAACPrimeRateOracle.sol";
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);
Treasury treasury;
Treasury repairFund;
FeeCollector feeCollector;
IERC20 crvUSD = IERC20(address(0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E));
IERC20 basecrvUSD = IERC20(address(0x417Ac0e078398C154EdFadD9Ef675d30Be60Af93));
RAACToken raacToken;
veRAACToken veRaacToken;
RAACNFT raacNFT;
RToken rToken;
DebtToken debtToken;
DEToken deToken;
address mainnetChainlinkRouter = address(0x65Dcc24F8ff9e51F10DCc7Ed1e4e2A61e6E14bd6);
RAACReleaseOrchestrator raacReleaseOrchestrator;
RAACHousePrices housePrices;
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACMinter minter;
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);
raacToken = new RAACToken(owner, 100, 50);
veRaacToken = new veRAACToken(address(raacToken));
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);
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));
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 = 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));
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);
}