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: 16/84
Findings: 2
Award: $267.84
š Selected for report: 0
š Solo Findings: 0
š Selected for report: nuthan2x
Also found by: 0x0bserver, AM, CaeraDenoir, DanielArmstrong, JrNet, Kirkeelee, KmanOfficial, Krace, Limbooo, Meera, SovaSlava, SpicyMeatball, TheSavageTeddy, agadzhalov, aslanbek, atoko, csanuragjain, d3e4, imare, jesjupyter, juancito, kartik_giri_47538, kutugu, max10afternoon, offside0011, pkqs90, turvy_fuzz, xchen1130, zhaojohnson, ziyou-
25.7286 USDC - $25.73
distribute()
, which will revert with a panic error. Bricks the contract as it will try to access an out of bound index in the erc20EntitlementPerUnit[]
array.The root of the vulnerability is setDistributableERC20s()
not checking that there is an ongoing distribution:
function setDistributableERC20s( address[] memory _distributableERC20s ) public onlyOwner { distributableERC20s = _distributableERC20s; }
LiquidInfrastructureERC20.sol#L441-L445
The scenario goes as follows:
distribute()
at any moment with a number of distributions lower than the total holders.erc20EntitlementPerUnit[]
array is set in storage with the same length as distributableERC20s[]
(this will be the cause of the revert).setDistributableERC20s()
, which will update the distributableERC20s[]
array.erc20EntitlementPerUnit[]
array will try to access an index out of bounds, since it will iterate over a larger array.Note: Someone could even frontrun the tx to purposefully perform the attack, although this is not necessary for the brick to happen.
Another scenario can be removing a token during distribution, or changing its order. This will mess up the distribution, as erc20EntitlementPerUnit
is first initiated expecting the same positions of the erc20 token array, but then when distribution continues it will have different values than expected, as the indexes will differ.
Add the following test to liquid-infrastructure/test/liquidERC20.ts
.
It will revert with panic code 0x32 (Array accessed at an out-of-bounds or negative index)
describe("POC", function() { it.only("bricks the contract when updating tokens during a distribution", async function() { const { infraERC20, holder1, holder2, holder3, badSigner, testERC20A, testERC20B, testERC20C } = await liquidErc20Fixture(); // Setup the contract with two ERC20 tokens const tokenAddresses = [await testERC20A.getAddress(), await testERC20B.getAddress()]; await infraERC20.setDistributableERC20s(tokenAddresses); // Approve holders in order to be able to mint them tokens await infraERC20.approveHolder(holder1.address); await infraERC20.approveHolder(holder2.address); await infraERC20.approveHolder(holder3.address); // Mint tokens to holders so there are users to distribute to await infraERC20.mint(holder1.address, 1000) await infraERC20.mint(holder2.address, 1000) await infraERC20.mint(holder3.address, 1000) // Advance blocks to a distributable time await mine(500); // Anyone can start a distribution at any time const infraERC20NotOwner = infraERC20.connect(badSigner); await infraERC20NotOwner.distribute(1); // The project adds a new ERC20 const newAddresses = [await testERC20A.getAddress(), await testERC20B.getAddress(), await testERC20C.getAddress()]; await infraERC20.setDistributableERC20s(newAddresses); // It will brick the contract // This will revert with "panic code 0x32 (Array accessed at an out-of-bounds or negative index)" await infraERC20.distribute(1); }) });
This lines prevents any possible brick:
function setDistributableERC20s( address[] memory _distributableERC20s ) public onlyOwner { + require(!LockedForDistribution, "cannot set erc20 tokens during distribution"); distributableERC20s = _distributableERC20s; }
Invalid Validation
#0 - c4-pre-sort
2024-02-20T04:23:32Z
0xRobocop marked the issue as duplicate of #151
#1 - c4-pre-sort
2024-02-20T04:38:27Z
0xRobocop marked the issue as duplicate of #260
#2 - c4-judge
2024-03-04T15:11:31Z
0xA5DF marked the issue as satisfactory
#3 - c4-judge
2024-03-08T15:13:03Z
0xA5DF changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-08T15:26:19Z
0xA5DF changed the severity to 2 (Med Risk)
š Selected for report: nuthan2x
Also found by: 0x0bserver, AM, CaeraDenoir, DanielArmstrong, JrNet, Kirkeelee, KmanOfficial, Krace, Limbooo, Meera, SovaSlava, SpicyMeatball, TheSavageTeddy, agadzhalov, aslanbek, atoko, csanuragjain, d3e4, imare, jesjupyter, juancito, kartik_giri_47538, kutugu, max10afternoon, offside0011, pkqs90, turvy_fuzz, xchen1130, zhaojohnson, ziyou-
25.7286 USDC - $25.73
https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L220-L227 https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L441-L445
Removed tokens that were not distributed before updating the ERC20 tokens list will be locked. Holders will not receive their expected tokens.
Tokens are only distributed based on the current distributableERC20s[]
array:
for (uint j = 0; j < distributableERC20s.length; j++) { IERC20 toDistribute = IERC20(distributableERC20s[j]); uint256 entitlement = erc20EntitlementPerUnit[j] * this.balanceOf(recipient); if (toDistribute.transfer(recipient, entitlement)) { receipts[j] = entitlement; } }
LiquidInfrastructureERC20.sol#L220-L227
The problem is that if a token is removed via setDistributableERC20s()
, they can't later be distributed and will remain locked in the contract.
function setDistributableERC20s( address[] memory _distributableERC20s ) public onlyOwner { distributableERC20s = _distributableERC20s; }
LiquidInfrastructureERC20.sol#L441-L445
Make sure tokens are distributed before removing any token.
function setDistributableERC20s( address[] memory _distributableERC20s ) public onlyOwner { + distributeToAllHolders(); distributableERC20s = _distributableERC20s; }
Invalid Validation
#0 - c4-pre-sort
2024-02-20T04:23:57Z
0xRobocop marked the issue as duplicate of #151
#1 - c4-pre-sort
2024-02-20T04:24:44Z
0xRobocop marked the issue as not a duplicate
#2 - c4-pre-sort
2024-02-20T04:25:03Z
0xRobocop marked the issue as sufficient quality report
#3 - c4-pre-sort
2024-02-20T04:25:07Z
0xRobocop marked the issue as primary issue
#4 - c4-pre-sort
2024-02-20T04:40:11Z
0xRobocop marked the issue as duplicate of #260
#5 - c4-pre-sort
2024-02-20T04:40:17Z
0xRobocop marked the issue as remove high or low quality report
#6 - c4-judge
2024-03-04T15:26:48Z
0xA5DF marked the issue as partial-50
#7 - 0xA5DF
2024-03-04T15:27:00Z
Didn't identify the full impact
#8 - c4-judge
2024-03-08T15:13:03Z
0xA5DF changed the severity to 3 (High Risk)
#9 - c4-judge
2024-03-08T15:26:19Z
0xA5DF changed the severity to 2 (Med Risk)
š Selected for report: SpicyMeatball
Also found by: BowTiedOriole, Breeje, CaeraDenoir, JohnSmith, Krace, Meera, PumpkingWok, SovaSlava, SpicyMeatball, d3e4, juancito, kutugu, nuthan2x, rokinot, rouhsamad, web3pwn
242.1143 USDC - $242.11
DOS of withdrawFromManagedNFTs()
and withdrawFromAllManagedNFTs()
functions
The problem is that withdrawFromManagedNFTs()
checks that nextWithdrawal
is exactly equal to the length of managed NFTs.
if (nextWithdrawal == ManagedNFTs.length) { nextWithdrawal = 0; emit WithdrawalFinished(); }
LiquidInfrastructureERC20.sol#L382-L385
nextWithdrawal
is an always increasing value (the only place where it is reset is in the previous code snippet).
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;
This means that if for some reason nextWithdrawal > ManagedNFTs.length
, the value will never be reset, leading to a DOS of the withdraw function, as it won't be able to iterate over the array again.
Releasing NFTs removes an item from the ManagedNFTs
array.
When two or more NFTs are released, it can lead to the previous scenario.
Someone could even frontrun the tx to purposefully perform the attack by withdrawing ManagedNFTs.length - 1
items, although this is not strictly necessary for the brick to happen.
One option is to reset nextWithdrawal
when nextWithdrawal >= ManagedNFTs.length
. This way it will reset the value on the next withdraw call.
- if (nextWithdrawal == ManagedNFTs.length) { + if (nextWithdrawal >= ManagedNFTs.length) { nextWithdrawal = 0; emit WithdrawalFinished(); }
Another option is to prevent releasing NFTs if there is an ongoing withdrawal. This check could be added to addManagedNFT()
for consistency as well.
function releaseManagedNFT( address nftContract, address to ) public onlyOwner nonReentrant { + require(nextWithdrawal == 0);
DoS
#0 - c4-pre-sort
2024-02-22T05:38:34Z
0xRobocop marked the issue as duplicate of #130
#1 - c4-judge
2024-03-03T13:02:32Z
0xA5DF marked the issue as satisfactory