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: 7/84
Findings: 3
Award: $391.06
🌟 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
Currently, on any mints, burns or transfers, the _beforeTokenTransfer() function hook is called. The _beforeTokenTransfer() function pushes the "to" address to the holders
array if the balance of "to" is zero.
The issue is that an approved holder or an attacker (neither approved nor holding tokens) can make 0 value burn function calls using functions burn() and burnFrom() in ERC20Burnable to inflate the holders array with zero addresses. This would cause the mint, burn, transfer functionalities to permanently DOS and distributions to temporarily DOS.
Although the distribute() function can be called directly with numDistributions as a parameter, it would still require the caller to loop through all the 0 addresses in order to end the distribution and resume regular transfers, mints and burns.
Impacts:
Note: This issue is also an attack idea suggested in the README here.
Here is the whole process:
The attack can be launched from functions burn() and burnFrom() in the ERC20Burnable contract. For simplicity, I'll be demonstrating the attack path through function burn(). The attacker could also use burnFrom() to somewhat disguise himself under another account's burn calls.
File: ERC20Burnable.sol 19: function burn(uint256 amount) public virtual { 20: _burn(_msgSender(), amount); 21: }
File: ERC20.sol 274: function _burn(address account, uint256 amount) internal virtual { 275: require(account != address(0), "ERC20: burn from the zero address"); 276: 277: _beforeTokenTransfer(account, address(0), amount); 278: 279: uint256 accountBalance = _balances[account]; 280: require(accountBalance >= amount, "ERC20: burn amount exceeds balance"); 281: unchecked { 282: _balances[account] = accountBalance - amount; 283: } 284: _totalSupply -= amount; 285: 286: emit Transfer(account, address(0), amount); 287: 288: _afterTokenTransfer(account, address(0), amount); 289: }
from
= attacker's address, to
= address(0), amount = 0to
= address(0)MinDistributionPeriod
has passed. Once again, we assume the attacker makes the call before the next distribution period. Note: Distribution periods will be occurring weekly/monthly, so there would be enough time to execute this attack.to
address i.e. address(0). Since the balance is 0 (because during burns tokens are not actually sent to the zero address but deducted from the caller's balance), exists
is set to false.File: LiquidInfrastructureERC20.sol 133: function _beforeTokenTransfer( 134: address from, 135: address to, 136: uint256 amount 137: ) internal virtual override { 138: require(!LockedForDistribution, "distribution in progress"); 139: if (!(to == address(0))) { 140: require( 141: isApprovedHolder(to), 142: "receiver not approved to hold the token" 143: ); 144: } 145: if (from == address(0) || to == address(0)) { 146: _beforeMintOrBurn(); 147: } 148: bool exists = (this.balanceOf(to) != 0); 149: if (!exists) { 150: holders.push(to); 151: } 152: }
Following this, the remaining execution continues in the _burn function(). The attacker can continue this attack and every time address(0) would be pushed to the holders array since address(0) always has 0 balance.
Functions mint(), burn() and transfer() would permanently DOS in some scenarios. This is because the _afterTokenTransfer() function hook runs a for loop upto the length of the holders array.
to
address has 0 balance. If true, set stillHolding
to false else true.from
= address(0), to
= some address. Since the from
address is always address(0), which has 0 balance, stillHolding
is set to false. Due to this, we enter the if block and the for loop would revert due to OOG exception.from
= some address, to
= address(0). If the from
address does not burn all its tokens, then it's stillHolding
= true. This means we do not enter the if block and do not run the for loop. But if the from
address tries to burn all tokens, it would revert since stillHolding
= false due to it having no balance.from
= user A, to
= user B. If user A decides to transfer only some of it's tokens to B, then stillHolding
= true since A still has a balance. This means we do not enter the if block and for loop. But if A at some point transfers it's remaining balance or whole balance to B or another address, it would revert since stillHolding
= false due to it having no balance.File: LiquidInfrastructureERC20.sol 173: function _afterTokenTransfer( 174: address from, 175: address to, 176: uint256 amount 177: ) internal virtual override { 178: bool stillHolding = (this.balanceOf(from) != 0); 179: if (!stillHolding) { 180: for (uint i = 0; i < holders.length; i++) { 181: if (holders[i] == from) { 182: // Remove the element at i by copying the last one into its place and removing the last element 183: holders[i] = holders[holders.length - 1]; 184: holders.pop(); 185: } 186: } 187: } 188: }
numDistributions
as a parameter to divide the array into batches. But this would waste considerable amount of funds behind gas fees during every distribution phase.File: LiquidInfrastructureERC20.sol 210: function distribute(uint256 numDistributions) public nonReentrant { 211: require(numDistributions > 0, "must process at least 1 distribution"); 212: if (!LockedForDistribution) { 213: require( 214: _isPastMinDistributionPeriod(), 215: "MinDistributionPeriod not met" 216: ); 217: _beginDistribution(); 218: } 219: 220: uint256 limit = Math.min( 221: nextDistributionRecipient + numDistributions, 222: holders.length 223: ); 224: 225: uint i; 226: for (i = nextDistributionRecipient; i < limit; i++) { 227: address recipient = holders[i]; 228: if (isApprovedHolder(recipient)) { 229: uint256[] memory receipts = new uint256[]( 230: distributableERC20s.length 231: ); 232: for (uint j = 0; j < distributableERC20s.length; j++) { 233: IERC20 toDistribute = IERC20(distributableERC20s[j]); 234: uint256 entitlement = erc20EntitlementPerUnit[j] * 235: this.balanceOf(recipient); 236: if (toDistribute.transfer(recipient, entitlement)) { 237: receipts[j] = entitlement; 238: } 239: } 240: 241: emit Distribution(recipient, distributableERC20s, receipts); 242: } 243: } 244: nextDistributionRecipient = i; 245: 246: if (nextDistributionRecipient == holders.length) { 247: _endDistribution(); 248: } 249: }
Manual Review
In function _beforeTokenTransfer(), check if the "to" address is the zero address. If true, do not push it. This would prevent the holders array from being inflated during burn() function calls.
There is nothing to worry about mints and transfers since ERC20Burnable disallows minting/transferring to the zero address.
DoS
#0 - c4-pre-sort
2024-02-20T06:40:17Z
0xRobocop marked the issue as duplicate of #77
#1 - c4-judge
2024-03-04T13:10:30Z
0xA5DF marked the issue as satisfactory
🌟 Selected for report: ZanyBonzy
Also found by: 0xSmartContractSamurai, DarkTower, MrPotatoMagic, SpicyMeatball, TheSavageTeddy, jesjupyter, lsaudit, peanuts, zhaojohnson
83.634 USDC - $83.63
ID | Gas Optimization |
---|---|
[L-01] | Function setThresholds() might DOS due to OOG error |
[L-02] | Missing address(0) check when approving holder through approveHolder() function |
[L-03] | Consider restricting transfers once minDistributionPeriod has passed |
[L-04] | Distribution would DOS if erc20EntitlementPerUnit array grows too large |
[L-05] | Convenience functions can be frontrun with distribute() function calls |
[L-06] | Function addManagedNFT() does not check if nftContract already exists |
[L-07] | Function releaseManagedNFT() could permanently DOS due to out-of-gas error |
[N-01] | Consider using block.timestamp instead of block.number |
New thresholds cannot be set if the previous thresholds set through this function make the array thresholdErc20s or thresholdAmounts too long to delete. This will cause a DOS when the delete operation is being performed due to out-of-gas error.
File: LiquidInfrastructureNFT.sol 109: function setThresholds( 110: address[] calldata newErc20s, 111: uint256[] calldata newAmounts 112: ) public virtual onlyOwnerOrApproved(AccountId) { 113: require( 114: newErc20s.length == newAmounts.length, 115: "threshold values must have the same length" 116: ); 117: // Clear the thresholds before overwriting 119: delete thresholdErc20s; 120: delete thresholdAmounts; 121: 122: for (uint i = 0; i < newErc20s.length; i++) { 123: thresholdErc20s.push(newErc20s[i]); 124: thresholdAmounts.push(newAmounts[i]); 125: } 126: emit ThresholdsChanged(newErc20s, newAmounts); 127: }
If the owner forgets to pass in the holder address, the value is defaulted to address(0).
File: LiquidInfrastructureERC20.sol 107: function approveHolder(address holder) public onlyOwner { 108: require(!isApprovedHolder(holder), "holder already approved"); 109: HolderAllowlist[holder] = true; 110: }
The below check ensures that once the minimum distribution period passes, mints and burns are prevented ensuring that supply changes happen after any potential distributions. Consider also restricting transfers by adding this function call here.
This would ensure no major/minor transfers can occur before distributions occur. The liquid infrastructure would be used in several way externally and each design/model would require it's own restrictions.
For example consider a case where the approvedHolder is an address that provides some kind of service to its users. The users (non-technical) are not aware of the actual revenue they should be receiving and are instead fooled by the approvedHolder who informs them of lesser revenue. In this case, the approved holder transfers the tokens to another address right before distribution occurs (but after pastMintDistribution time). He receives the revenue on the other approved address. When the distribution ends, he transfers only a % of tokens to the users, who believe him since the tokens were to be considered non-transferrable once a distribution period begins.
File: LiquidInfrastructureERC20.sol 151: function _beforeMintOrBurn() internal view { 152: require( 153: !_isPastMinDistributionPeriod(), 154: "must distribute before minting or burning" 155: ); 156: }
Consider limiting the amount of distributableERC20s to ensure erc20EntitlementPerUnit does not grow too large to delete. This would cause the distribution to DOS (even though batch distributions are implemented) and cause approved holders revenue to be permanently stuck in the contract.
The erc20EntitlementPerUnit grows large based on distributableERC20s as seen here.
File: LiquidInfrastructureERC20.sol 286: function _endDistribution() internal { 287: require( 288: LockedForDistribution, 289: "cannot end distribution when not locked" 290: ); 291: delete erc20EntitlementPerUnit; 292: LockedForDistribution = false; 293: LastDistribution = block.number; 294: emit DistributionFinished(); 295: }
If the owner or approved holders call the convenience functions below to start distributions and mint/burn tokens, someone can frontrun their call with a distribute() function call with numDistributions set to a value less than holders.length. This would make them devoid of immediately minting or burning tokens after distributions.
This could be an issue where the approvedHolder provides a service on Althea L1 that requires this kind of functionality to work.
File: LiquidInfrastructureERC20.sol 304: function mintAndDistribute( 305: address account, 306: uint256 amount 307: ) public onlyOwner { 308: if (_isPastMinDistributionPeriod()) { 309: distributeToAllHolders(); 310: } 311: mint(account, amount); 312: } File: LiquidInfrastructureERC20.sol 332: function burnAndDistribute(uint256 amount) public { 333: if (_isPastMinDistributionPeriod()) { 334: distributeToAllHolders(); 335: } 336: burn(amount); 337: } 338: 339: /** 340: * Convenience function that allows an approved sender to distribute when necessary and then burn the approved tokens right after 341: * 342: * @notice attempts to distribute to every holder in this block, which may exceed the block gas limit 343: * if this fails then first call distribute() enough times to finish a distribution and then call burnFrom() 344: */ 345: function burnFromAndDistribute(address account, uint256 amount) public { 346: if (_isPastMinDistributionPeriod()) { 347: distributeToAllHolders(); 348: } 349: burnFrom(account, amount); 350: }
Consider implementing a mapping from nftContract to bool status that tracks if an nftContract has already been added. Then add a check in the function addManagedNFT() and state updates to true/false in addManagedNFT() and releaseManagedNFT().
This would avoid duplication of an nftContract that has already been added.
File: LiquidInfrastructureERC20.sol 396: function addManagedNFT(address nftContract) public onlyOwner { 397: LiquidInfrastructureNFT nft = LiquidInfrastructureNFT(nftContract); 398: address nftOwner = nft.ownerOf(nft.AccountId()); 399: require( 400: nftOwner == address(this), 401: "this contract does not own the new ManagedNFT" 402: ); 403: ManagedNFTs.push(nftContract); 404: emit AddManagedNFT(nftContract); 405: }
If the ManagedNFTs array grows too large, the for loop below would DOS due to OOG exception, causing the removal of managed nfts not possible. This is because we iterate upto the length of the array if the nftContract is towards the end of the array.
File: LiquidInfrastructureERC20.sol 414: function releaseManagedNFT( 415: address nftContract, 416: address to 417: ) public onlyOwner nonReentrant { 418: LiquidInfrastructureNFT nft = LiquidInfrastructureNFT(nftContract); 419: nft.transferFrom(address(this), to, nft.AccountId()); 420: 421: // Remove the released NFT from the collection 422: for (uint i = 0; i < ManagedNFTs.length; i++) { 423: address managed = ManagedNFTs[i]; 424: if (managed == nftContract) { 425: // Delete by copying in the last element and then pop the end 426: ManagedNFTs[i] = ManagedNFTs[ManagedNFTs.length - 1]; 428: ManagedNFTs.pop(); 429: break; 430: } 431: } 432: // By this point the NFT should have been found and removed from ManagedNFTs 433: require(true, "unable to find released NFT in ManagedNFTs"); 434: 435: emit ReleaseManagedNFT(nftContract, to); 436: }
Although distributions would be weekly/monthly, it is generally recommended to use block.timestamp to ensure that shorter distribution periods (if set in the future or by other team using these contracts) are considered.
File: LiquidInfrastructureERC20.sol 465: LastDistribution = block.number;
#0 - c4-pre-sort
2024-02-22T07:44:05Z
0xRobocop marked the issue as sufficient quality report
#1 - 0xA5DF
2024-03-08T11:44:47Z
+L from #363 +L from #371
#2 - 0xA5DF
2024-03-08T14:16:43Z
L R R R R L R R
4L, 6R
#3 - c4-judge
2024-03-08T14:24:59Z
0xA5DF marked the issue as grade-a
🌟 Selected for report: 0xAadi
Also found by: 0xbrett8571, DarkTower, JcFichtner, JrNet, MrPotatoMagic, PoeAudits, TheSavageTeddy, ZanyBonzy, clara
300.2473 USDC - $300.25
This audit report should be approached with the following points in mind:
0-2 hours:
2-5 hours:
5-10 hours:
10-12 hours:
12-14 hours:
The protocol is structured in a simplistic manner. There are 3 contracts: LiquidInfrastructureERC20, LiquidInfrastructureNFT and OwnableApprovableERC721. The LiquidInfrastructureERC20 can own/manage multiple LiquidInfrastructureNFT contracts. The LiquidInfrastructureNFT contracts could be standalone as well depending on their use case.
A new model:
The protocol could be made more simplistic if the ERC1155 standard is used. In this case, the contract that inherits ERC1155 would hold the LiquidInfrastructureNFTs. The LiquidInfrastructureNFT would be represented using the tokenIds, which itself could be further fractionalizable due to tokenIds being semi-fungible. All functions related to LiquidInfrastructureNFT would be included in the contract that inherits the ERC1155 contract.
Where does the revenue come in from?
How does revenue distribution occur?
What will the ERC20 token holders hold?
This model would provide the same features/benefits as the original model, with an add-on of the tokenId/LiquidInfrastructureNFT being fractionalizable.
The biggest centralization vector in the codebase is the owner. The owner has the following privileges currently:
Consider using a multi-sig for the owner role. If possible, consider rotating the owner role to new multi-sigs every 6 months for additional security.
Contracts marked other than blue are out of scope.
14 hours
#0 - c4-pre-sort
2024-02-22T18:57:17Z
0xRobocop marked the issue as high quality report
#1 - c4-sponsor
2024-03-02T04:20:38Z
ChristianBorst (sponsor) acknowledged
#2 - c4-judge
2024-03-08T14:31:12Z
0xA5DF marked the issue as grade-a