Platform: Code4rena
Start Date: 20/09/2022
Pot Size: $30,000 USDC
Total HM: 12
Participants: 198
Period: 3 days
Judge: 0xean
Total Solo HM: 2
Id: 164
League: ETH
Rank: 18/198
Findings: 3
Award: $408.52
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Trust
Also found by: 0xSky, CertoraInc, KIntern_NA, bin2chen, hansfriese, neko_nyaa, neumo, rokinot, wastewa
https://github.com/code-423n4/2022-09-vtvl/blob/main/contracts/VTVLVesting.sol#L176 https://github.com/code-423n4/2022-09-vtvl/blob/main/contracts/VTVLVesting.sol#L147 https://github.com/code-423n4/2022-09-vtvl/blob/main/contracts/VTVLVesting.sol#L196-L199 https://github.com/code-423n4/2022-09-vtvl/blob/main/contracts/VTVLVesting.sol#L206-L209 https://github.com/code-423n4/2022-09-vtvl/blob/main/contracts/VTVLVesting.sol#L215-L218 https://github.com/code-423n4/2022-09-vtvl/blob/main/contracts/VTVLVesting.sol#L371 https://github.com/code-423n4/2022-09-vtvl/blob/main/contracts/VTVLVesting.sol#L422
In contract VTVLVesting.sol, the multiplication in function _baseVestedAmount can overflow for big enough values of truncatedCurrentVestingDurationSecs and linearVestAmount. This means the claim could be successfully created by the admin, but could NEVER be revoked or fully withdrawn (it could only be withdrawn as far as truncatedCurrentVestingDurationSecs is small enough to not make the multiplication overflow. In fact, ALL calls to function _baseVestedAmount passing in a value of _referenceTs greater than or equal to the claim's endTimestamp would result in a revert. This affects functions vestedAmount, finalVestedAmount, claimableAmount, withdraw and revokeClaim.
The test below, shows how if we use a release interval of 1 second and a linear vesting span of 10 years, we can create a claim of 16,500,000 tokens, but if we then call function revokeClaim, the call reverts because the multiplication would overflow.
/* eslint-disable prettier/prettier */ import { expect } from "chai"; import { ethers } from "hardhat"; import Chance from "chance"; // eslint-disable-next-line node/no-missing-import import { VTVLVesting } from "../typechain"; import { BigNumber } from "ethers"; import { PANIC_CODES } from "@nomicfoundation/hardhat-chai-matchers/panic"; import "@nomicfoundation/hardhat-chai-matchers"; const chance = new Chance(43153); // Make sure we have a predictable seed for repeatability const randomAddress = async () => { return await ethers.Wallet.createRandom().getAddress(); } const createContractFactory = async () => await ethers.getContractFactory("VTVLVesting"); const deployVestingContract = async (tokenAddress?: string) => { const factory = await createContractFactory(); // TODO: check if we need any checks that the token be valid, etc const contract = await factory.deploy(tokenAddress ?? await randomAddress()); return contract; } const dateToTs = (date: Date|string) => ethers.BigNumber.from(Math.floor((date instanceof Date ? date : new Date(date)).getTime() / 1000)); const createPrefundedVestingContractBN = async (props: {tokenName: string, tokenSymbol: string, initialSupply: BigNumber}) => { const { tokenName, tokenSymbol, initialSupply, } = props; // Create an example token const tokenContractFactory = await ethers.getContractFactory('TestERC20Token'); // const initialSupply = ethers.utils.parseUnits(initialSupplyTokens.toString(), decimals); const tokenContract = await tokenContractFactory.deploy(tokenName, tokenSymbol, initialSupply); // Create an example vesting contract const vestingContract = await deployVestingContract(tokenContract.address); await vestingContract.deployed(); expect(await vestingContract.tokenAddress()).to.be.equal(tokenContract.address); // Fund the vesting contract - transfer everything to the vesting contract (from the user) await tokenContract.transfer(vestingContract.address, await tokenContract.totalSupply()); return {tokenContract, vestingContract}; } type VestingContractType = VTVLVesting; describe("My tests", async function () { it('overflow test', async () => { const recipientAddress = await randomAddress(); const initialSupply = BigNumber.from("5100000000000000000000000000000000"); const tokenName = "TOKEN"; const tokenSymbol = "TK"; const {vestingContract} = await createPrefundedVestingContractBN({tokenName, tokenSymbol, initialSupply}); // Create and immediately revoke the claim const myLinearVestAmount = BigNumber.from("16500000000000000000000000"); const myStartTimestamp = dateToTs('2012-02-01'); const myEndTimestamp = dateToTs('2022-02-01'); const myCliffReleaseTimestamp = dateToTs(new Date('2010-02-01')); const cliffAmount = BigNumber.from("16500000000000000000000000"); const myReleaseIntervalSecs = 1; await vestingContract.createClaim(recipientAddress, myStartTimestamp, myEndTimestamp, myCliffReleaseTimestamp, myReleaseIntervalSecs, myLinearVestAmount, cliffAmount); await expect(vestingContract.vestedAmount(recipientAddress, myEndTimestamp)).to.be.revertedWithPanic(PANIC_CODES.ARITHMETIC_UNDER_OR_OVERFLOW); await expect(vestingContract.revokeClaim(recipientAddress)).to.be.revertedWithPanic(PANIC_CODES.ARITHMETIC_UNDER_OR_OVERFLOW); }); });
Hardhat
Devs are using uint112 to store the claim amounts thinking this is enough, and this certainly is. But to overcome the overflow caused by the multiplication in line 176, one solution would be casting. The line could be changed like this:
uint112 linearVestAmount = uint112(uint152(_claim.linearVestAmount) * truncatedCurrentVestingDurationSecs / finalVestingDurationSecs);
This way the multiplication would fit in a uint152 and after division by finalVestingDurationSecs we would downcast back to uint112. Note here that finalVestingDurationSecs is always greater than or equal to truncatedCurrentVestingDurationSecs, so the downcast will not give us any trouble.
#0 - 0xean
2022-09-24T19:19:38Z
dupe of #95
🌟 Selected for report: Czar102
Also found by: 0xDecorativePineapple, 0xNazgul, 0xSky, 0xbepresent, 0xmatt, Atarpara, Bahurum, DimitarDimitrov, Franfran, GimelSec, JGcarv, JLevick, Junnon, OptimismSec, Rolezn, Ruhum, Soosh, Tomo, Trust, __141345__, adriro, ajtra, bin2chen, cRat1st0s, cccz, cryptonue, d3e4, innertia, jag, joestakey, neumo, obront, pashov, pauliax, pcarranzav, peanuts, rajatbeladiya, rbserver, reassor, seyni, wagmi, zzykxx, zzzitron
0.7375 USDC - $0.74
Contract VariableSupplyERC20Token.sol can be initialized at creation with a value that specifies the maxSupply for the token. If 0 is passed to maxSupply in the constructor, it means there is no mint limit. But if a value greater than zero is passed in, the contract should never allow to mint tokens if the totalSupply after minting surpasses that maxSupply value.
In the test below, I deploy a VariableSupplyERC20Token contract with maxSupply 2M tokens and an initial mint of 1M. After deployment, the total supply is 1M (because of the initial mint) and the mintable tokens remaining are 1M of the 2M max supply. We mint the remaining 1M tokens and then the mintableSupply is equal to zero. Because of this, it passes the check at line 40 and does not check if we are surpassing the max supply.
import { expect } from "chai"; import { ethers } from "hardhat"; const createContractFactory = async () => await ethers.getContractFactory("VariableSupplyERC20Token"); const deployTokenContract = async () => { const factory = await createContractFactory(); const contract = await factory.deploy("TOKEN", "TK", 1_000_000, 2_000_000); return contract; } describe("VariableSupplyToken", async function () { it("can mint beyond maxSupply", async () => { const contract = await deployTokenContract(); await contract.deployed(); expect(await contract.totalSupply()).to.equal(1_000_000); expect(await contract.mintableSupply()).to.equal(1_000_000); await contract.mint(contract.address, 1_000_000); expect(await contract.totalSupply()).to.equal(2_000_000); await contract.mint(contract.address, 2_000_000); expect(await contract.totalSupply()).to.equal(4_000_000); }); });
Hardhat
Don't get track of the mintable supply. Instead of this, record the maxSupply in an immutable variable at contract creation:
uint256 public immutable maxSupply;
And change function mint to check if the amount to mint is less than or equal than the tokens left to get to the maxSupply:
function mint(address account, uint256 amount) public onlyAdmin { require(account != address(0), "INVALID_ADDRESS"); if(maxSupply > 0) { require(amount <= (maxSupply - totalSupply), "INVALID_AMOUNT"); } _mint(account, amount); }
#0 - 0xean
2022-09-24T00:33:03Z
dupe of #3
🌟 Selected for report: AkshaySrivastav
Also found by: 0v3rf10w, 0x040, 0x1f8b, 0x4non, 0x5rings, 0x85102, 0xA5DF, 0xDecorativePineapple, 0xNazgul, 0xSky, 0xSmartContract, 0xbepresent, 0xf15ers, 0xmatt, 2997ms, Aeros, Aymen0909, B2, Bahurum, Bnke0x0, CertoraInc, Chom, ChristianKuri, CodingNameKiki, Deivitto, Diana, Diraco, Dravee, ElKu, Funen, IllIllI, JC, JLevick, JohnSmith, JohnnyTime, KIntern_NA, Lambda, Margaret, MasterCookie, OptimismSec, RaymondFam, Respx, ReyAdmirado, RockingMiles, Rohan16, Rolezn, Ruhum, RustyRabbit, Sm4rty, SooYa, StevenL, TomJ, Tomo, V_B, Waze, Yiko, __141345__, a12jmx, ajtra, ak1, async, ayeslick, aysha, berndartmueller, bin2chen, bobirichman, brgltd, bulej93, c3phas, carrotsmuggler, cccz, ch13fd357r0y3r, chatch, cryptostellar5, cryptphi, csanuragjain, d3e4, datapunk, delfin454000, dic0de, djxploit, durianSausage, eighty, erictee, exd0tpy, fatherOfBlocks, gogo, got_targ, hansfriese, ignacio, ikbkln, indijanc, innertia, joestakey, karanctf, ladboy233, leosathya, lukris02, martin, medikko, millersplanet, nalus, natzuu, neko_nyaa, neumo, obront, oyc_109, pcarranzav, peanuts, pedr02b2, pedroais, peiw, peritoflores, prasantgupta52, rajatbeladiya, rbserver, reassor, ret2basic, rokinot, romand, rotcivegaf, rvierdiiev, sach1r0, seyni, sikorico, slowmoses, sorrynotsorry, supernova, tibthecat, tnevler, ubermensch, yongskiws, zzykxx, zzzitron
18.8577 USDC - $18.86
https://github.com/code-423n4/2022-09-vtvl/blob/main/contracts/VTVLVesting.sol#L53 https://github.com/code-423n4/2022-09-vtvl/blob/main/contracts/VTVLVesting.sol#L418-L437 https://github.com/code-423n4/2022-09-vtvl/blob/main/contracts/VTVLVesting.sol#L223-L225 https://github.com/code-423n4/2022-09-vtvl/blob/main/contracts/VTVLVesting.sol#L230-L232
If an admin revokes a claim of a recipient that has not withdrawn anything yet, the vestingRecipients array should not consider it as a valid recipient because it has not (and will not) received anything.
The contract VTVLVesting.sol has the array vestingRecipients to keep track of the addresses that have a claim. When the admin revokes the claim for one given recipient in function revokeClaim, in the case that the recipient has not withdrawn any amount (claim.amountWithdrawn == 0), the function should remove the address from the vestingRecipients array. Otherwise, functions allVestingRecipients and numVestingRecipients would return wrong values.
Manual analysis
Remove the address of the recipient from the vestingRecipients array in the call to revokeClaim. Add this code snippet before the event emission in function revokeClaim:
// If recipient has not withdrawn any amount, remove the address from the vestingRecipients array if(_claim.amountWithdrawn == 0){ address lastRecipient = vestingRecipients[vestingRecipients.length - 1]; for(uint i = 0;i<vestingRecipients.length;i++){ if(vestingRecipients[i] == _recipient){ vestingRecipients[i] = lastRecipient; vestingRecipients.pop(); break; } } }
#0 - 0xean
2022-09-24T18:53:24Z
Warden does not demonstrate what impact these values being incorrect has on the protocol itself. Downgrading to Low severity.