Core Contracts

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

Governance is vulnerable to vote manipulation due to not using snapshots from users' balances

Summary

The governance system (Governance.sol and veRAACToken.sol) allows malicious actors to force through any governance proposal by manipulating voting power at the last minute. The root cause is that voting power is calculated at vote time rather than being snapshotted at proposal creation.

Vulnerability Details

Problem is the checkpoints used to track user balances for a specific time are not used in the Governance contract. The checkpoints are a well-known mechanism used in OZ Governance and ERC20Votes contract, but in RAAC they are not being used.

Example of checking for when user is locking RAAC tokens.

function lock(uint256 amount, uint256 duration) external nonReentrant whenNotPaused {
...
// Update checkpoints
uint256 newPower = uint256(uint128(bias));
@> _checkpointState.writeCheckpoint(msg.sender, newPower);
// Mint veTokens
_mint(msg.sender, newPower);
...
}

Notice veRAAC has the function getPastVotes, that returns the user balance for that block number.

function (address account, uint256 blockNumber) public view returns (uint256) {
return _checkpointState.getPastVotes(account, blockNumber);
}

This function is well known in the integration of the Governor from OZ with ERC20Votes, as it is used to cast the user votes properly, taking into account the block/time of the proposal creation.

// Governor
function _castVote(
uint256 proposalId,
address account,
uint8 support,
string memory reason,
bytes memory params
) internal virtual returns (uint256) {
_validateStateBitmap(proposalId, _encodeStateBitmap(ProposalState.Active));
@> uint256 totalWeight = _getVotes(account, proposalSnapshot(proposalId), params);
uint256 votedWeight = _countVote(proposalId, account, support, totalWeight, params);
// GovernorVotes
function _getVotes(
address account,
uint256 timepoint,
bytes memory /*params*/
) internal view virtual override returns (uint256) {
@> return token().getPastVotes(account, timepoint);
}

Now checking the Governancecontract, we see votingPower is used instead of the checkpoint(snapshot) balance.

function castVote(uint256 proposalId, bool support) external override returns (uint256) {
...
uint256 weight = _veToken.getVotingPower(msg.sender);
if (support) {
proposalVote.forVotes += weight;
} else {
proposalVote.againstVotes += weight;
}
...
}

The system uses current voting power when votes are cast, making it vulnerable to last-minute manipulation:

Look how the voting power is calculated when user locks his tokens calling veRAAC.lock:

//VotingPowerLib.sol
function calculateAndUpdatePower(
VotingPowerState storage state,
address user,
uint256 amount,
uint256 unlockTime
) internal returns (int128 bias, int128 slope) {
if (amount == 0 || unlockTime <= block.timestamp) revert InvalidPowerParameters();
uint256 MAX_LOCK_DURATION = 1460 days; // 4 years
uint256 duration = unlockTime - block.timestamp;
// @audit-issue if user lock for max_period(4 years)
// his bias is 100% of the amount deposited.
@> uint256 initialPower = (amount * duration) / MAX_LOCK_DURATION; // Normalize by max duration
@> bias = int128(int256(initialPower));
@> slope = int128(int256(initialPower / duration)); // Power per second decay
uint256 oldPower = getCurrentPower(state, user, block.timestamp);
state.points[user] = RAACVoting.Point({
bias: bias,
slope: slope,
timestamp: block.timestamp
});
_updateSlopeChanges(state, unlockTime, 0, slope);
emit VotingPowerUpdated(user, oldPower, uint256(uint128(bias)));
return (bias, slope);
}

This is how the getVotingPower logic works: the voting power calculation decreases over time from the initial lock period. The closer to when tokens were first locked, the higher the voting power. An attacker could exploit this by locking tokens just before voting, ensuring their voting power starts at maximum, nearly equal to their deposited amount.

int128 decay = (point.slope * int128(int256(timeDelta))) / int128(int256(1));
adjustedBias = point.bias - decay;

So 1 hour after locking the tokens for 4 years, the voting power number is almost the same as the amount deposited. I.e:

// getCurrentPower math:
// int128 decay = (point.slope * int128(int256(timeDelta))) / int128(int256(1));
// adjustedBias = point.bias - decay;
// user locked 10M for 4 years and after 1 hour called veRAAC.getVotingPower
1. First, let's get the slope per second:
* slope = 10M \* 10^18 / (4 \* 365 \* 24 \* 3600)
* slope = 10,000,000 \* 10^18 / 126,144,000
* slope ≈ 79,365 \* 10^18 per second
2. For 1 hour (3600 seconds):
* decay = slope \* 3600
* decay = 79,365.079365079 \* 10^18 \* 3600
* decay = 285,714,285.714285714 \* 10^18
3. Therefore:
* adjustedBias = initialBias - decay
* adjustedBias = (10,000,000 \* 10^18) - (285,714.285714285714 \* 10^18)
* adjustedBias ≈ 9,714M \* 10^18
// Result: 9.7M.

Notice the Governance contract uses the voting power to account for the votes:

// Governance.sol
function castVote(uint256 proposalId, bool support) external override returns (uint256) {
// @audit-issue Gets current power instead of snapshot
uint256 weight = _veToken.getVotingPower(msg.sender);
if (support) {
proposalVote.forVotes += weight;
} else {
proposalVote.againstVotes += weight;
}
}

The second issue here is the quorum. The quorum calculation uses current total voting power, which can also be increase if attacker locks a certain amount of tokens to pass the quorum threshold.

// Governance.sol
function quorum() public view override returns (uint256) {
return (_veToken.getTotalVotingPower() * quorumNumerator) / QUORUM_DENOMINATOR;
}

This opens the possibility for the following attack scenario:

PoC

Attacker creates a proposal for any malicious action(to upgrade contracts to malicious versions, withdraw all funds, modify access control to take over the protocol, etc).

Now the attacker will take action:

  1. Current proposal state: 900K in voting power voted against the proposal.

  2. Attacker creates 1 account in the last minute of voting for that proposal.

  3. Attacker locks 10M RAAC for 4 years = after 1 second the attacker has > 9.9M in voting power.

  4. Attacker votes for "yes" in the proposal. New vote totals: 9.9M "yes" vs 900K "no".

  5. Attacker proposal succeeds and he takes over the protocol.

Impact

  • The logic of voting power gives more voting power to recently locked positions than previous existing positions, creating the ideal scenario for governance manipulation.

  • Malicious actors can force through ANY governance proposal by acquiring and locking enough tokens.

  • The entire protocol's security model is compromised as critical parameters, contracts, and funds can be controlled through governance.

  • Long-term token holders' voting power is effectively nullified by last-minute large token locks.

Tools Used

Manual Review

Recommendations

  1. The governance should use a voting power snapshot at proposal creation time, similar to how Compound and other major governance systems work. OZ contracts(Governance.sol and ERC20Votes.sol) are recommended.

  2. Consider not using VotingPowerLib accounting for the votes, as users' voting power decreases over time due to the decay logic and new locks have a higher amount of voting power.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Governance.castVote uses current voting power instead of proposal creation snapshot, enabling vote manipulation through token transfers and potential double-voting

Governance::quorum uses current total voting power instead of proposal creation snapshot, allowing manipulation of threshold requirements to force proposals to pass or fail

inallhonesty Lead Judge about 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Governance.castVote uses current voting power instead of proposal creation snapshot, enabling vote manipulation through token transfers and potential double-voting

Governance::quorum uses current total voting power instead of proposal creation snapshot, allowing manipulation of threshold requirements to force proposals to pass or fail

Support

FAQs

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