Platform: Code4rena
Start Date: 18/10/2022
Pot Size: $50,000 USDC
Total HM: 13
Participants: 67
Period: 5 days
Judge: Picodes
Total Solo HM: 7
Id: 172
League: ETH
Rank: 1/67
Findings: 5
Award: $13,376.28
🌟 Selected for report: 3
🚀 Solo Findings: 2
370.4481 USDC - $370.45
This is another bug in the redemption weight calculation mechanism discussed in an issue with the same title. I recommend to read that first for context.
Let's look at DelegateStore's totalRedemptionWeight implementation again:
for (uint256 _i; _i < _maxTierId; ) { // Keep a reference to the stored tier. _storedTier = _storedTierOf[_nft][_i + 1]; // Add the tier's contribution floor multiplied by the quantity minted. weight += (_storedTier.contributionFloor * (_storedTier.initialQuantity - _storedTier.remainingQuantity)) + _numberOfReservedTokensOutstandingFor(_nft, _i, _storedTier); unchecked { ++_i; } }
We can see that the reservedTokensOutstanding is added to the product of contribution * minted tokens. This is a severe mistake, because the weight is meant to take into account the specific tier of the outstanding tokens. Currently all reserved tokens are added linearly to the result and will be negligible if contributionFloor is much larger than 1.
Note that after the reserved tokens are minted, the reservedTokensOutstanding and remainingQuantity count will be decremented, making the minted tokens multiply by contributionFloor. Therefore, we have conflicting weights while no money has been put in.
The impact of this bug is incorrect calculation of the weight of user's contributions. While reserve tokens are yet to be minted, users can redeem tokens and receive much more than they should have, at the expense of the reserved token beneficiary.
Project reserve tokens lose value when users redeem their NFTs before reserves were minted.Â
1. Suppose we have a project with 2 tiers, reserve ratio = 50%, redemption ratio = 100%:
Tier | Contribution | Initial quantity | Remaining quantity | Reserves minted | Reserves outstanding |
Tier 1 | 50 | 10 | 4 | 0 | 3 |
When calculating totalRedemptionWeight(), the correct result is
50 * (10 - 4 + 3) = 450
The wrong result will be:
50 * (10 - 4) + 3Â = 303
Therefore, when users redeem NFT rewards, they will get far more value than they are eligible for. This means eventually when the reserve tokens will be minted, they will be worth much less than they are supposed to.
Manual audit
Change the calculation to:
_storedTier.contributionFloor * (_storedTier.initialQuantity - _storedTier.remainingQuantity + _numberOfReservedTokensOutstandingFor(_nft, _i + 1, _storedTier) )
#0 - mejango
2022-10-24T17:20:14Z
doop #193
#1 - trust1995
2022-10-24T18:13:15Z
No, it's a completely different bug.
#2 - drgorillamd
2022-10-25T12:33:27Z
Duplicate #129 (which has a nicer presentation imo)
#3 - Picodes
2022-11-04T13:40:18Z
Duplicate of #129 and not #193 as @drgorillamd said
#4 - c4-judge
2022-12-03T19:00:25Z
Picodes marked the issue as not a duplicate
#5 - c4-judge
2022-12-03T19:00:51Z
Picodes marked the issue as duplicate of #129
#6 - c4-judge
2022-12-03T19:19:54Z
Picodes marked the issue as satisfactory
🌟 Selected for report: Trust
8918.1947 USDC - $8,918.19
When the project wishes to mint reserved tokens, they call mintReservesFor which allows minting up to the amount calculated by DelegateStore's _numberOfReservedTokensOutstandingFor. The function has this line:
// No token minted yet? Round up to 1. if (_storedTier.initialQuantity == _storedTier.remainingQuantity) return 1;
In order to ease calculations, if reserve rate is not 0 and no token has been minted yet, the function allows a single reserve token to be printed. It turns out that this introduces a very significant risk for users. Projects can launch with several tierIDs of similar contribution size, and reserve rate as low as 1%. Once a victim contributes to the project, it can instantly mint a single reserve token of all the rest of the tiers. They can then redeem the reserve token and receive most of the user's contribution, without putting in any money of their own.
Since this attack does not require setting "dangerous" flags like lockReservedTokenChanges or lockManualMintingChanges, it represents a very considerable threat to unsuspecting users. Note that the attack circumvents user voting or any funding cycle changes which leave time for victim to withdraw their funds.Â
Honeypot project can instantly take most of first user's contribution.
New project launches, with 10 tiers, of contributions 1000, 1050, 1100, ...
Reserve rate is set to 1% and redemption rate = 100%
User contributes 1100 and gets a Tier 3 NFT reward.Â
Project immediately mints Tier 1, Tier 2, Tier 4,... Tier 10 reserve tokens, and redeems all the reserve tokens.
Project's total weight = 12250
Reserve token weight = 11150
Malicious project cashes 1100 (overflow) * 11150 / 12250 = ~1001 tokens.
Manual audit
Don't round up outstanding reserve tokens as it represents too much of a threat.
#0 - Picodes
2022-11-07T17:49:09Z
The finding is valid and clearly demonstrates how project owners could bypass the flags and safeguards implemented to trick users into thinking that they'll be safe.
However, it falls within the "centralization risk" category, and within reports showing "a unique attack path which users were not told upfront about" (see this issue). So I believe Medium severity to be appropriate.
#1 - c4-judge
2022-11-07T17:49:20Z
Picodes marked the issue as change severity
#2 - c4-judge
2022-11-07T17:49:26Z
Picodes marked the issue as satisfactory
#3 - trust1995
2022-11-07T19:22:32Z
I would just like to state that the way I look at it, this is not a centralization risk, as the counterparty which can perform the exploit is some listed project on Juicebox, rather than Juicebox itself. It is very similar to a high severity finding in Enso Finance, where a strategy creator can rug funds sent to their strategy.
#4 - c4-judge
2022-11-08T15:39:25Z
Picodes changed the severity to 3 (High Risk)
#5 - Picodes
2022-11-21T11:56:12Z
Kept it high risk out of coherence with https://github.com/code-423n4/2022-05-enso-findings/issues/204, and because this attack would bypass all the safeguards implemented by Juicebox
1070.1834 USDC - $1,070.18
Redemption weight is a concept used in Juicebox to determine investor's eligible percentage of the non-locked funds. In redeemParams, JB721Delegate calculates user's share using:
uint256 _redemptionWeight = _redemptionWeightOf(_decodedTokenIds); uint256 _total = _totalRedemptionWeight(); uint256 _base = PRBMath.mulDiv(_data.overflow, _redemptionWeight, _total);
_totalRedemptionWeight eventually is implemented in DelegateStore:
for (uint256 _i; _i < _maxTierId; ) { // Keep a reference to the stored tier. _storedTier = _storedTierOf[_nft][_i + 1]; // Add the tier's contribution floor multiplied by the quantity minted. weight += (_storedTier.contributionFloor * (_storedTier.initialQuantity - _storedTier.remainingQuantity)) + _numberOfReservedTokensOutstandingFor(_nft, _i, _storedTier); unchecked { ++_i; } }
If we pay attention to _numberOfReservedTokensOutstandingFor() call, we can see it is called with tierId = i, yet storedTier of i+1. It is definitely not the intention as for example, recordMintReservesFor() uses the function correctly:
function recordMintReservesFor(uint256 _tierId, uint256 _count) external override returns (uint256[] memory tokenIds) { // Get a reference to the tier. JBStored721Tier storage _storedTier = _storedTierOf[msg.sender][_tierId]; // Get a reference to the number of reserved tokens mintable for the tier. uint256 _numberOfReservedTokensOutstanding = _numberOfReservedTokensOutstandingFor( msg.sender, _tierId, _storedTier ); ...
The impact of this bug is incorrect calculation of the weight of user's contributions. The initialQuantity
and remainingQuantity
values are taken from the correct tier, but _reserveTokensMinted
minted is taken from previous tier. In the case where _reserveTokensMinted
is smaller than correct value, for example tierID=0 which is empty, the outstanding value returned is larger, meaning weight is larger and redemptions are worth less. In the opposite case, where lower tierID has higher _reserveTokensMinted
, the redemptions will receive more payout than they should.
Users of projects can receive less or more funds than they are eligible for when redeeming NFT rewards.
1. Suppose we have a project with 2 tiers, reserve ratio = 50%, redemption ratio = 100%:
Tier | Contribution | Initial quantity | Remaining quantity | Reserves minted | Reserves outstanding |
Tier 1 | 50 | 10 | 3 | 1 | 2 |
Tier 2 | 100 | 30 | 2 | 8 | 2 |
When calculating totalRedemptionWeight(), the correct result is
50 * (10 - 3) + 2 + 100 * (30-2) + 2 = 3154
The wrong result will be:
50 * (10 -3) + 4 + 100 * (30-2) + 13Â = 3167
Therefore, when users redeem NFT rewards, they will get less value than they are eligible for. Note that totalRedemptionWeight() has an additional bug where the reserve amount is not multiplied by the contribution, which is discussed in another submission. If it would be calculated correctly, the correct weight would be 3450.
Manual audit
Change the calculation to:
_numberOfReservedTokensOutstandingFor(_nft, _i+1, _storedTier);
Likelihood of impact is very high, because the conditions will arise naturally (different tiers, different reserve minted count for each tier, user calls redeem). Severity of impact is high because users receive less or more tokens than they are eligible for.
Initially I thought this bug could allow attacker to steal entire unlocked project funds, using a mint/burn loop. However, this would not be profitable because their calculated share of the funds would always be at most what they put in, because reserve tokens are printed out of thin air.
#0 - c4-judge
2022-11-04T13:44:18Z
Picodes marked the issue as selected for report
#1 - c4-judge
2022-12-03T19:01:26Z
Picodes marked the issue as primary issue
🌟 Selected for report: Trust
2675.4584 USDC - $2,675.46
Tiers in Juicebox can be deactivated using the adjustTiers() function. It makes sense that reserve tokens may be minted in deactivated tiers, in order to be consistent with already minted tokens. However, the code allows the first reserve token to be minted in a deactivated tier, even though there was no previous minting of that tier.
function recordMintReservesFor(uint256 _tierId, uint256 _count) external override returns (uint256[] memory tokenIds) { // Get a reference to the tier. JBStored721Tier storage _storedTier = _storedTierOf[msg.sender][_tierId]; // Get a reference to the number of reserved tokens mintable for the tier. uint256 _numberOfReservedTokensOutstanding = _numberOfReservedTokensOutstandingFor( msg.sender, _tierId, _storedTier ); if (_count > _numberOfReservedTokensOutstanding) revert INSUFFICIENT_RESERVES(); ... for (uint256 _i; _i < _count; ) { // Generate the tokens. tokenIds[_i] = _generateTokenId( _tierId, _storedTier.initialQuantity - --_storedTier.remainingQuantity + _numberOfBurnedFromTier ); unchecked { ++_i; } }
function _numberOfReservedTokensOutstandingFor( address _nft, uint256 _tierId, JBStored721Tier memory _storedTier ) internal view returns (uint256) { // Invalid tier or no reserved rate? if (_storedTier.initialQuantity == 0 || _storedTier.reservedRate == 0) return 0; // No token minted yet? Round up to 1. // ******************* BUG HERE ********************* if (_storedTier.initialQuantity == _storedTier.remainingQuantity) return 1; ... }
Using the rounding mechanism is not valid when the tier has been deactivated, since we know there won't be any minting of this tier.
The reserve beneficiary receives an unfair NFT which may be used to withdraw tokens using the redemption mechanism.
Manual audit
If Juicebox intends to use rounding functionality, pass an argument isDeactivated which, if true, deactivated the rounding logic.
#0 - Picodes
2022-11-05T18:52:54Z
The finding illustrates how a reserve token could be minted for a removed tier, and this token used to redeem funds.
#1 - c4-judge
2022-11-05T18:53:06Z
Picodes marked the issue as satisfactory
#2 - thereksfour
2022-11-22T03:22:11Z
@Picodes This one seems to be a subset of this finding https://github.com/code-423n4/2022-10-juicebox-findings/issues/191
#3 - Picodes
2022-11-22T08:50:33Z
Thank you for flagging, I will think about it!
#4 - Picodes
2022-11-27T17:58:58Z
Although it is in the same lines and functionalities, I don't think this one is a subset of #191: this one is about the fact that you can still mint when it's deactivated, and #191 is about the rounding feature itself
🌟 Selected for report: berndartmueller
Also found by: 0x1f8b, 0x4non, 0xNazgul, 0xSmartContract, Aymen0909, BClabs, Diana, Jeiwan, Lambda, LeoS, RaoulSchaffranek, RaymondFam, RedOneN, ReyAdmirado, Rolezn, SaharAP, Trust, V_B, __141345__, a12jmx, bharg4v, brgltd, carlitox477, ch0bu, chaduke, cloudjunky, cryptostellar5, cryptphi, csanuragjain, d3e4, delfin454000, erictee, fatherOfBlocks, hansfriese, ignacio, joestakey, karanctf, ladboy233, lukris02, mcwildy, minhtrng, peanuts, ret2basic, seyni, slowmoses, svskaushik, tnevler, yixxas
341.9967 USDC - $342.00
The code in _clone() inserts a tiny initialization code before the deployed bytecode. It uses the deployed bytecode size in the create() call, which expects initialization bytecode size. This means 13 bytes, the initialization bytecode size, are trimmed from the source contract when copied to the target contract. These bytes contain compiler metadata as discussed here . The consequence is that contract verifiers like etherscan will fail to verify the source code of project delegates.
#0 - trust1995
2022-11-04T10:26:56Z
Dup of #172
#1 - c4-judge
2022-11-04T14:27:54Z
Picodes marked the issue as grade-a