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: 42/84
Findings: 3
Award: $57.21
🌟 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
https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L127-L146 https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L164-L179
The Althea protocol overrides the _beforeTokenTransfer
& _afterTokenTransfer
functions of the ERC20.sol
implementation contract of Openzeppelin. These overrides are in place to allow parent contracts inheriting to implement something unique within the transfers of ERC20 tokens. These two functions present a vulnerability for an attacker to DoS LiquidInfrastructureERC20
contract's core functions e.g the mint
function which does the logic of allowing the owner mint to a specified holder address. Plus users will not be able to transfer their tokens if they wish to do so.
An attacker can force the complete DoS of the contract's core functions as specified above and below in the LiquidInfrastructureERC20
contract.
These are the vulnerable lines of code from the contract relating to this issue:
function _beforeTokenTransfer( address from, address to, uint256 amount ) internal virtual override { require(!LockedForDistribution, "distribution in progress"); if (!(to == address(0))) { require( isApprovedHolder(to), "receiver not approved to hold the token" ); } if (from == address(0) || to == address(0)) { _beforeMintOrBurn(); } @1 bool exists = (this.balanceOf(to) != 0); if (!exists) { holders.push(to); // @note then pushes to them to the array of holders >> inflates size } }
function _afterTokenTransfer( address from, address to, uint256 amount ) internal virtual override { @2 bool stillHolding = (this.balanceOf(from) != 0); if (!stillHolding) { for (uint i = 0; i < holders.length; i++) { if (holders[i] == from) { // Remove the element at i by copying the last one into its place and removing the last element holders[i] = holders[holders.length - 1]; holders.pop(); // @note no longer a holder? okay! remove them from the holders array } } } }
@1 This checks if the reciever is a first time holder but doesn't check if the value being transferred is 0.
@2 This checks if the sender still has a balance above 0 after this transfer but doesn't do the same logic for the receiver
Let's take a look at a visual scenario for this DoS issue below along with a coded POC. First off, keep in mind one of the call paths that include this vulnerability is mint
> _mint
> _beforeTokenTransfer
> _afterTokenTransfer
:
Since we only get rid of previous holders with 0 balances, we never really check if the new holder is a 0 value holder. So, it's still possible to have holders in the array even though they hold nothing - you can always make a 0-value token transfer with the OZ ERC20 specification.
holders
array exceeds this limit.Once this is done, the _afterTokenTransfer
loop over the holders
array will cause the LiquidInfrastructureERC20
contract's transfer
, mint
, burn
functions to fail resulting in a DoS of the protocol.
You'll need to setup foundry then place the following code in the repo\file test\foundry\LiquidERC20.t.sol. You can run the following test by using forge test --mt testDosLiquidERC20 -vv
// SPDX-License-Identifier: MIT pragma solidity 0.8.12; import {Test, console} from "forge-std/Test.sol"; import {LiquidInfrastructureERC20} from "../../contracts/LiquidInfrastructureERC20.sol"; import {LiquidInfrastructureNFT} from "../../contracts/LiquidInfrastructureNFT.sol"; import {TestERC20B} from "../../contracts/TestERC20B.sol"; contract LiquidERC20Test is Test { LiquidInfrastructureERC20 liquidERC20; LiquidInfrastructureNFT liquidNFTs; TestERC20B E2H; address nftOwnerOne = makeAddr("nftOwnerOne"); address erc20Owner = makeAddr("erc20Owner"); address lc20Owner = makeAddr("lc20Owner"); address Bob = makeAddr("Bob"); address Alice = makeAddr("Alice"); address George = makeAddr("George"); address holder4 = makeAddr("holder4"); address holder5 = makeAddr("holder5"); address holder6 = makeAddr("holder6"); address holder7 = makeAddr("holder7"); address holder8 = makeAddr("holder8"); address holder9 = makeAddr("holder9"); address holder10 = makeAddr("holder10"); function setUp() external { vm.prank(erc20Owner); E2H = new TestERC20B(); vm.prank(nftOwnerOne); liquidNFTs = new LiquidInfrastructureNFT("Vending Mac 1"); address[] memory nfts = new address[](1); address[] memory holders = new address[](10); address[] memory stableCoins = new address[](1); nfts[0] = address(liquidNFTs); holders[0] = Bob; holders[1] = Alice; holders[2] = George; holders[3] = holder4; holders[4] = holder5; holders[5] = holder6; holders[6] = holder7; holders[7] = holder8; holders[8] = holder9; holders[9] = holder10; stableCoins[0] = address(E2H); vm.prank(lc20Owner); liquidERC20 = new LiquidInfrastructureERC20("Vending Mac 1 ERC20", "VM1", nfts, holders, 1, stableCoins); } function testDosLiquidERC20() public { address attacker = makeAddr("Attacker"); bytes32 holdersSlot = vm.load(address(liquidERC20), bytes32(uint256(9))); assertEq(uint256(holdersSlot), 0); console.log("Holder array length pre zero value transfers: ", uint256(holdersSlot)); // Check that array has 0 element // Attacker submits 0 amount transfers to a gigantic number of addresses maby 2**224 (depending on the chain) to a point where it causes a DOS for the protocol vm.startPrank(attacker); liquidERC20.transfer(Bob, 0); liquidERC20.transfer(Alice, 0); liquidERC20.transfer(George, 0); vm.stopPrank(); bytes32 holdersSlot2 = vm.load(address(liquidERC20), bytes32(uint256(9))); assertEq(uint256(holdersSlot2), 3); console.log("Holder array length post zero value transfers: ", uint256(holdersSlot2)); // Holders array has increased // Now users cannot perform any transfers and protocol cannot mint anymore // DOS for protocol and funds are stuck + gas wastage when calling any protocol related function or transfer } }
Output of inflated holders array with 0 values:
[PASS] testDosLiquidERC20() (gas: 144772) Logs: Holder array length pre zero value transfers: 0 Holder array length post zero value transfers: 3 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 15.98ms
Manual review
Prevent 0 value transfers in all cases in the _beforeTokenTransfer
overriden function of LiquidInfrastructureERC20
contract.
function _beforeTokenTransfer( address from, address to, uint256 amount ) internal virtual override { require(!LockedForDistribution, "distribution in progress"); + if (amount == 0) { + revert(); + } if (!(to == address(0))) { require( isApprovedHolder(to), "receiver not approved to hold the token" ); } if (from == address(0) || to == address(0)) { _beforeMintOrBurn(); } bool exists = (this.balanceOf(to) != 0); if (!exists) { holders.push(to); } }
It is also preferable from our point of view, to add a minimum amount of tokens that can be transfered. We recommand this amount to be 1e18
. This should discourage any user from trying to DOS the protocol using this method.
DoS
#0 - c4-pre-sort
2024-02-21T03:58:34Z
0xRobocop marked the issue as duplicate of #77
#1 - c4-judge
2024-03-04T13:15:27Z
0xA5DF marked the issue as satisfactory
🌟 Selected for report: ZanyBonzy
Also found by: 0xSmartContractSamurai, DarkTower, MrPotatoMagic, SpicyMeatball, TheSavageTeddy, jesjupyter, lsaudit, peanuts, zhaojohnson
11.348 USDC - $11.35
There are a bunch of centralization risk issues in the LiquidInfrastructureERC20
and LiquidInfrastructureNFT
contract that poses malicious owner risks for users of the protocol. These scenarios are listed below:
An owner of the NFT can choose to sell their NFT in Opensea marketplace, thereby locking funds in the contract.
The LiquidInfrastructureERC20
owner can add a massive array of ERC20 tokens as distributable tokens as there's no way to enforce strict adhering to a whitelisted ERC20 token list by the protocol which could DoS the distribute
function. He can easily do this by creating an array of the same ERC20 token.
The instance of code for 2. is seen below:
function setDistributableERC20s( address[] memory _distributableERC20s // @audit any ERC20 token can be set ) public onlyOwner { distributableERC20s = _distributableERC20s; }
LiquidInfrastructureERC20
owner can honeypot users by removing those users from the allowlist after minting the Liquid ERC20 token to them initially.releaseManagedNFT
should revert if a managedNft
is not foundThe releaseManagedNFT
function is used to release a NFT which is defined by the parameter address nftContract
but the function doesn't revert when the NFT is not found inside the array ManagedNFTs
. The function should revert if the nftContract
to remove is non-existent. Else, this means that the wrong NFT is being sent as it wasnt added the right way.
function releaseManagedNFT( address nftContract, address to ) public onlyOwner nonReentrant { LiquidInfrastructureNFT nft = LiquidInfrastructureNFT(nftContract); nft.transferFrom(address(this), to, nft.AccountId()); // Remove the released NFT from the collection for (uint i = 0; i < ManagedNFTs.length; i++) { address managed = ManagedNFTs[i]; if (managed == nftContract) { // @audit loops for nothing if the `nftContract` to remove is not found. // Delete by copying in the last element and then pop the end ManagedNFTs[i] = ManagedNFTs[ManagedNFTs.length - 1]; ManagedNFTs.pop(); break; } } // By this point the NFT should have been found and removed from ManagedNFTs require(true, "unable to find released NFT in ManagedNFTs"); emit ReleaseManagedNFT(nftContract, to); }
LiquidInfrastructureNFT
can be frontrunThe contructor in the LiquidInfrastructureNFT
contract constructs the underlying ERC721 with a URI such as "althea://liquid-infrastructure-account/{accountName}"
, and a symbol "LIA:{accountName}"
. But the accountName
can be frontrun by others resulting in a case where the name is taken by another project. This can cause confusion for users as both contract have the URI
.
constructor( string memory accountName ) // @audit this can be frontrun ERC721( string.concat( "althea://liquid-infrastructure-account/", accountName ), string.concat("LIA:", accountName) ) { _mint(msg.sender, AccountId); }
Tokens like USDC and USDT utilize upgradeable proxies for their contracts and can become fee-on-transfer tokens in the future if they choose to. On depositing, such fee on transfer tokens can reduce the revenue of holders. The recommendation is to reduce internal transfers as this can eat up into the holder rewards. Rather than sending the revenue from the LiquidInfrastructureNFT
contract to the LiquidInfrastructureERC20
allow a direct calculation and transfer from the NFT contract to the holders from the LiquidInfrastructureERC20
contract.
(address[] memory withdrawERC20s, ) = withdrawFrom.getThresholds(); withdrawFrom.withdrawBalancesTo(withdrawERC20s, address(this)); emit Withdrawal(address(withdrawFrom));
Add a getter function to retrieve list of all holder address. In the current implementation of the LiquidInfrastructureERC20
contract, the holders
array is private. Imagine the owner of the LiquidInfrastructureERC20
token tells Bob they're an approved holder after a mint of the token but how does Bob verify this information onchain without being tech-savvy to access the private data. There is no way for him to do that. Expose a getter function for the holders
array.
address[] private holders;
Using an old version can lead to worse gas performance, known contract issues, and potential 0-day issues.
@> "@openzeppelin/contracts": "4.3.1",
withdrawFromManagedNFTs
should restrict access to only the owner the LiquidInfrastructureERC20
contractWith the current implementation of the withdrawFromManagedNFTs
function, a managed NFT owner can choose to withdraw at any time. This function is better suited to be restricted to the owner of the LiquidInfrastructureERC20
contract. The owner of LiquidInfrastructureNFT
can thus easily decide with owner of LiquidInfrastructureERC20
to withdraw the tokens to himself for a round for example using LiquidInfrastructureNFT::withdrawBalances
. Currently, anyone can run LiquidInfrastructureERC20::withdrawFromManagedNFTs
to withdraw the tokens to the LiquidInfrastructureERC20
contract.
function withdrawFromManagedNFTs(uint256 numWithdrawals) public { require(!LockedForDistribution, "cannot withdraw during distribution"); if (nextWithdrawal == 0) { emit WithdrawalStarted(); } uint256 limit = Math.min( numWithdrawals + nextWithdrawal, ManagedNFTs.length ); uint256 i; for (i = nextWithdrawal; i < limit; i++) { LiquidInfrastructureNFT withdrawFrom = LiquidInfrastructureNFT( ManagedNFTs[i] ); (address[] memory withdrawERC20s, ) = withdrawFrom.getThresholds(); withdrawFrom.withdrawBalancesTo(withdrawERC20s, address(this)); emit Withdrawal(address(withdrawFrom)); } nextWithdrawal = i; if (nextWithdrawal == ManagedNFTs.length) { nextWithdrawal = 0; emit WithdrawalFinished(); } }
The ERC20
contract does not need to be imported again when it is already imported in the ERC20Burnable
contract. Similarly, OwnableApprovableERC721
already imports the ERC721
implementation. Thereby re-importing is redundant.
contract LiquidInfrastructureERC20 is @> ERC20, ERC20Burnable, Ownable, ERC721Holder, ReentrancyGuard {...}
@> contract LiquidInfrastructureNFT is ERC721, OwnableApprovableERC721 {...}
There are many instances in which unnecessary checks are implemented which just increases the gas cost and byte code of the contracts
_isPastMinDistributionPeriod
and mint
can never be run together. The check highlighted in the code is unnecessary.function mintAndDistribute( address account, uint256 amount ) public onlyOwner { @> if (_isPastMinDistributionPeriod()) { distributeToAllHolders(); } mint(account, amount); }
_isPastMinDistributionPeriod
and burnFrom
can never be run together. The check highlighted in the code is unnecessary.function burnFromAndDistribute(address account, uint256 amount) public { @> if (_isPastMinDistributionPeriod()) { distributeToAllHolders(); } burnFrom(account, amount); }
require
check in the highlighted code does nothing. It should be removed from the function.function releaseManagedNFT( address nftContract, address to ) public onlyOwner nonReentrant { LiquidInfrastructureNFT nft = LiquidInfrastructureNFT(nftContract); nft.transferFrom(address(this), to, nft.AccountId()); // Remove the released NFT from the collection for (uint i = 0; i < ManagedNFTs.length; i++) { address managed = ManagedNFTs[i]; if (managed == nftContract) { // Delete by copying in the last element and then pop the end ManagedNFTs[i] = ManagedNFTs[ManagedNFTs.length - 1]; ManagedNFTs.pop(); break; } } // By this point the NFT should have been found and removed from ManagedNFTs @> require(true, "unable to find released NFT in ManagedNFTs"); emit ReleaseManagedNFT(nftContract, to); }
thresholdAmounts
are not always equal to the amount received by the LiquidNFT
contract.Right now, nothing prevents the contract LiquidInfrastructureNFT
from receiving more funds than the thresholdAmounts
. This array is not useful as it is used right now.
#0 - c4-pre-sort
2024-02-22T17:04:47Z
0xRobocop marked the issue as sufficient quality report
#1 - 0xA5DF
2024-03-08T11:34:20Z
+L from #317
#2 - 0xA5DF
2024-03-08T12:54:08Z
Analysis L L OOS R L Analysis R F F
4L, 2R
#3 - c4-judge
2024-03-08T14:25:47Z
0xA5DF marked the issue as grade-b
🌟 Selected for report: 0xAadi
Also found by: 0xbrett8571, DarkTower, JcFichtner, JrNet, MrPotatoMagic, PoeAudits, TheSavageTeddy, ZanyBonzy, clara
38.6789 USDC - $38.68
This analysis report has been approached with the following key points and goals in mind:
D1-2:
D2-4:
D4-6:
The first difference between Althea and NMKR is that the former tokenizes real world assets while the latter tokenizes just about anything and a marketplace ensues to sell the NFTs.
There's no ERC20 token representing holding of an NFT in the NMKR protocol. You hold the direct NFT instead. In Althea, you don't hold the NFT. You hold the ERC20 tokens. You are more like an investor instead of a price speculator.
Each holder of the ERC20 token in Althea gets rewarded in the reward tokens e.g USDC relative to their shares. No such incentive exists within the NMKR protocol which is instead a one and done protocol.
Decentralization of Holders: Currently, the one single biggest risk of this protocol is centralization. Becoming a holder of the ERC20 typically happens in a whitelist process. Allowing any address to become a holder of the ERC20 is a good way to increase adoption.
Whitelisted distributable ERC20 reward tokens: As is, the owner of the deployed ERC20 determines which token the rewards for holders will be distributed in. This is good but can be better because just as easy as it is to tokenize the reward NFT, deploy ERC20 for the underlying assets, it's also easy to setup fake tokens that cannot be distributed or even tokens with privileges that can render reward distribution to fail at any time. It's better the distributable tokens are set by the protocol and when an ERC20 owner tries to add a new distributable token, the token being added should be in the whitelisted array of tokens the protocol sets as accepted. This enforces a check and verify structure for the reward token.
There are two centralization points in the protocol:
The LiquidInfrastructureERC20
owner who has control over the ERC20 contract. They have privileged actions such as setting the list of distributable reward tokens, removing and adding addresses who can hold the ERC20. This can be utilized as a multisig address to further decentralize the privileges of this single owner.
The LiquidInfrastructureNFT
presents the similar risks but we think the more interesting contracts are always prone to weird behaviors which means the centralization risks of the NFT owner is far less compared to the ERC20 owner. For example, the withrawal of funds to yourself (owner) can be considered design and won't compare to the ability to enlist fake reward tokens in the Liquid ERC20 contract.
With just 3 contracts in scope, the call traces of the codebase are relatively simple to understand.
Removal of zero-value holders - Currently if after you make a transfer to another holder, your balance drops to zero, you're no longer a holder but it doesn't check if the receiving holder's address has a non-zero value. It sets them up as a holder if they weren't previously regardless. Anyone can use this oversight to inflate the holders array size.
Push technique for rewards - Currently rewards are distributed to holders of the ERC20. You can set an index to start distribution at but another way of implementing a better reward distribution process is by having the reciever take it themselves and keep track of each accumulated reward for all holders.
Frontruns - The protocol seems to be vulnerable to frontrun attacks. Be it for grabbing another name of an NFT or ERC20 token causing a scenario of confusion for which address is real or not with more than one project using the same name and symbols.
Phishing attacks - Anyone can deploy LiquidInfrastructureERC20
& LiquidInfrastructureNFT
tokens. Attackers can create similar tokens with names and symbols resulting in different owners. This can fool users via social engineering tactics in public communities.
24 hours
24 hours
#0 - c4-pre-sort
2024-02-22T19:05:32Z
0xRobocop marked the issue as sufficient quality report
#1 - c4-judge
2024-03-08T14:32:28Z
0xA5DF marked the issue as grade-b