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: 13/84
Findings: 3
Award: $275.02
🌟 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
One can add duplicates of an address to holders
, which address will then receive duplicate entitlements (stolen from other holders) during a distribution, and brick the contract.
Transfers of LiquidInfrastructureERC20 executes the following code in _beforeTokenTransfer(from, to, amount)
:
bool exists = (this.balanceOf(to) != 0); if (!exists) { holders.push(to); }
That is, if to
had a zero balance then to
is added to holders
. The expectation is that when a holder's balance goes from zero to non-zero the holder is added to holder
. But if amount == 0
his balance remains zero and if this zero amount is transferred again to to
then to
will be added again to holders
. This can be repeated to add an arbitrary number of copies of to
in holders
.
(Note that _afterTokenTransfer()
only pops the from
, which also implies that this zero transfer must be from an address with non-zero balance (which does not have to be controlled by the attacker since a zero amount is transferred).)
(After this duplication, a non-zero amount can be sent or minted to the address.)
distribute()
, over one or several calls,
function distribute(uint256 numDistributions) public nonReentrant { require(numDistributions > 0, "must process at least 1 distribution"); if (!LockedForDistribution) { require( _isPastMinDistributionPeriod(), "MinDistributionPeriod not met" ); _beginDistribution(); }
iterates over each address (recipient
) in holders
,
uint256 limit = Math.min( nextDistributionRecipient + numDistributions, holders.length ); uint i; for (i = nextDistributionRecipient; i < limit; i++) { address recipient = holders[i]; if (isApprovedHolder(recipient)) { uint256[] memory receipts = new uint256[]( distributableERC20s.length );
sends an entitlement in proportion to its balance of ,
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; } } emit Distribution(recipient, distributableERC20s, receipts); } } nextDistributionRecipient = i;
and ends the distribution only when all addresses in holders
are covered.
if (nextDistributionRecipient == holders.length) { _endDistribution(); } }
For the attacker's duplicated recipient
this.balanceOf(recipient)
will of course always retrieve the same value, but he will, each time, still receive a full entitlement
based on this balance. He will thus receive a multiple of what he should be entitled to. This will be at the expense of other holders, and since distribute()
attempts to distribute the entire balances of distributableERC20s
at some nextDistributionRecipient < holders.length
the funds will run out. This means that the distribution cannot end, and since the distribution process locks all transfers, mints, burns, and withdrawals, this leaves the contract permanently locked (unless funds are donated to the contract, in which case the attacker can just continue stealing from it).
bool exists = (this.balanceOf(to) != 0); - if (!exists) { + if (!exists && amount > 0) { holders.push(to); }
Invalid Validation
#0 - c4-pre-sort
2024-02-21T02:35:16Z
0xRobocop marked the issue as duplicate of #77
#1 - c4-judge
2024-03-04T13:13:10Z
0xA5DF marked the issue as satisfactory
🌟 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
distributableERC20s
can be modified during distribution, potentially causing distribute()
to revert or sending incorrect entitlements to holders.
A distribution may be split up in several calls to distribute()
. To avoid inconsistencies between these calls the variables outside the logical scope of this distribution, holders
and distributableERC20s
, must remain constant until the distribution is completed. holders
is kept constant by blocking _beforeTokenTransfer()
during a distribution. However, distributableERC20s
can always be modified:
function setDistributableERC20s( address[] memory _distributableERC20s ) public onlyOwner { distributableERC20s = _distributableERC20s; }
When a distribution is started a list erc20EntitlementPerUnit
is filled according to the indexing of the ERC20s in distributableERC20s
, in _beginDistribution()
.
// clear the previous entitlements, if any if (erc20EntitlementPerUnit.length > 0) { delete erc20EntitlementPerUnit; } // Calculate the entitlement per token held uint256 supply = this.totalSupply(); for (uint i = 0; i < distributableERC20s.length; i++) { uint256 balance = IERC20(distributableERC20s[i]).balanceOf( address(this) ); uint256 entitlement = balance / supply; erc20EntitlementPerUnit.push(entitlement); }
distribute()
contains the following:
if (isApprovedHolder(recipient)) { uint256[] memory receipts = new uint256[]( distributableERC20s.length ); 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; } } emit Distribution(recipient, distributableERC20s, receipts); }
We notice two problems.
If distributableERC20s
has been made longer, such that distributableERC20s.length > erc20EntitlementPerUnit.length
then erc20EntitlementPerUnit[j]
will revert due to out-of-bounds.
If the order of the ERC20s has changed in distributableERC20s
then distributableERC20s[j]
will not correspond to erc20EntitlementPerUnit[j]
and the wrong entitlement will be sent to the holder(s).
function setDistributableERC20s( address[] memory _distributableERC20s ) public onlyOwner { + require(!LockedForDistribution, "distribution in progress"); distributableERC20s = _distributableERC20s; }
Timing
#0 - c4-pre-sort
2024-02-20T04:17:01Z
0xRobocop marked the issue as high quality report
#1 - c4-pre-sort
2024-02-20T04:17:04Z
0xRobocop marked the issue as primary issue
#2 - c4-pre-sort
2024-02-20T04:38:36Z
0xRobocop marked the issue as duplicate of #260
#3 - c4-pre-sort
2024-02-20T04:38:48Z
0xRobocop marked the issue as remove high or low quality report
#4 - c4-judge
2024-03-04T15:19:44Z
0xA5DF marked the issue as satisfactory
#5 - c4-judge
2024-03-08T15:13:03Z
0xA5DF changed the severity to 3 (High Risk)
#6 - 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
If managed NFTs are released during a partial withdrawal from the managed NFTs it may happen that nextWithdrawal > ManagedNFTs.length
which blocks any further withdrawal attempts.
LiquidInfrastructureERC20.sol#L359-L386:
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(); } }
Suppose ManagedNFTs.length == 10
and that a partial withdrawal has been made, e.g. withdrawFromManagedNFTs(9)
has been called. This sets limit = 9
and thus leaves nextWithdrawal = 9
.
Then suppose two managed NFTs are released, by calling releaseManagedNFT()
twice. This pops two elements from ManagedNFTs
so that now ManagedNFTs.length == 8
.
Now, the next time withdrawFromManagedNFTs()
is called limit = 8
, so the for-loop is skipped.
But now nextWithdrawal == 9
and ManagedNFTs.length == 8
so nextWithdrawal != ManagedNFTs.length
and nextWithdrawal
fails to be reset to 0
. Calling withdrawFromManagedNFTs()
does nothing and is stuck in this state, preventing withdrawals.
- if (nextWithdrawal == ManagedNFTs.length) { + if (nextWithdrawal >= ManagedNFTs.length) { nextWithdrawal = 0; emit WithdrawalFinished(); }
DoS
#0 - c4-pre-sort
2024-02-21T05:45:25Z
0xRobocop marked the issue as duplicate of #130
#1 - c4-judge
2024-03-03T13:01:16Z
0xA5DF marked the issue as satisfactory