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: 10/67
Findings: 3
Award: $1,210.97
🌟 Selected for report: 1
🚀 Solo Findings: 0
370.4481 USDC - $370.45
https://github.com/jbx-protocol/juice-nft-rewards/blob/f9893b1497098241dd3a664956d8016ff0d0efd0/contracts/JBTiered721DelegateStore.sol#L550 https://github.com/jbx-protocol/juice-nft-rewards/blob/f9893b1497098241dd3a664956d8016ff0d0efd0/contracts/JBTiered721DelegateStore.sol#L563 https://github.com/jbx-protocol/juice-nft-rewards/blob/f9893b1497098241dd3a664956d8016ff0d0efd0/contracts/JBTiered721DelegateStore.sol#L566
After NFT minted, the NFT has redemption weight based on the contribution floor price,
the logic that calculate the total redemption weight is
_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);
However, I think the calculation is incorrect. we are trying to calcuate the total weight, but _numberOfReservedTokensOutstandingFor(_nft, _i, _storedTier) just return us the number of reserved token.
We are using contribute floor price * minted quanity + number of reserved token, we want to get weight, but we are using the weight to add with a number.
We should use contribute floor price * (minted quanity + number of reserved token) to get total weight.
The number of reserved token is 6, the we set the contributionFloor to 1 ether, and initial supply to 10,
the reserved token weight is negilible comparing to contribute floor price * minted quanity.
let us say,
all the inital supply is minted, the total contribution power
(_storedTier.contributionFloor * (_storedTier.initialQuantity - _storedTier.remainingQuantity))
=
1 ether * 10 = 10 ether = 10 ** 19
the _numberOfReservedTokensOutstandingFor(_nft, _i, _storedTier) is just 6
6 is negilible comparing to 10 ** 19 for sure.
Manual Review
I think use weight * (minted quanity + number of reserved token) to get total weight should be valid.
uint256 nftNumbers = _storedTier.initialQuantity - _storedTier.remainingQuantity + _numberOfReservedTokensOutstandingFor(_nft, _i, _storedTier); weight += nftNumbers * _storedTier.contributionFloor
#0 - trust1995
2022-10-23T22:46:11Z
imo valid, dup of #194
#1 - c4-judge
2022-12-03T19:15:34Z
Picodes marked the issue as not a duplicate
#2 - c4-judge
2022-12-03T19:16:00Z
Picodes marked the issue as duplicate of #129
#3 - c4-judge
2022-12-03T19:21:02Z
Picodes marked the issue as satisfactory
802.6375 USDC - $802.64
https://github.com/jbx-protocol/juice-nft-rewards/blob/f9893b1497098241dd3a664956d8016ff0d0efd0/contracts/JBTiered721Delegate.sol#L240 https://github.com/jbx-protocol/juice-nft-rewards/blob/f9893b1497098241dd3a664956d8016ff0d0efd0/contracts/JBTiered721DelegateStore.sol#L628 https://github.com/jbx-protocol/juice-nft-rewards/blob/f9893b1497098241dd3a664956d8016ff0d0efd0/contracts/JBTiered721DelegateStore.sol#L689
The tier setting parameter are unsafely downcasted from uint256 to uint80 / uint48 / uint16
the tier is setted by owner is crucial because the parameter affect how nft is minted.
the
the callstack is
JBTiered721Delegate.sol#initialize -> Store#recordAddTiers
function recordAddTiers(JB721TierParams[] memory _tiersToAdd)
what does the struct JB721TierParams look like? all parameter in JB721TierParams is uint256 type
struct JB721TierParams { uint256 contributionFloor; uint256 lockedUntil; uint256 initialQuantity; uint256 votingUnits; uint256 reservedRate; address reservedTokenBeneficiary; bytes32 encodedIPFSUri; bool allowManualMint; bool shouldUseBeneficiaryAsDefault; }
however in side the function
// Record adding the provided tiers. if (_pricing.tiers.length > 0) _store.recordAddTiers(_pricing.tiers);
all uint256 parameter are downcasted.
// Add the tier with the iterative ID. _storedTierOf[msg.sender][_tierId] = JBStored721Tier({ contributionFloor: uint80(_tierToAdd.contributionFloor), lockedUntil: uint48(_tierToAdd.lockedUntil), remainingQuantity: uint40(_tierToAdd.initialQuantity), initialQuantity: uint40(_tierToAdd.initialQuantity), votingUnits: uint16(_tierToAdd.votingUnits), reservedRate: uint16(_tierToAdd.reservedRate), allowManualMint: _tierToAdd.allowManualMint });
uint256 contributionFloor is downcasted to uint80,
uint256 lockedUntil is downcasted to uint48
uint256 initialQuantity and initialQuantity are downcasted to uint40
uint256 votingUnits and uint256 reservedRate are downcasted to uint16
this means the original setting is greatly trancated.
For example, the owner wants to set the initial supply to a number larger than uint40, but the supply is trancated to type(uint40).max
The owner wants to set the contribution floor price above uint80,but the contribution floor price is trancated to type(uint80).max, the user may underpay the price and get the NFT price at a discount.
We can add POC
function testJBTieredNFTRewardDelegate_mintFor_mintArrayOfTiers_downcast_POC() public { uint256 nbTiers = 1; vm.mockCall( mockJBProjects, abi.encodeWithSelector(IERC721.ownerOf.selector, projectId), abi.encode(owner) ); JB721TierParams[] memory _tiers = new JB721TierParams[](nbTiers); uint16[] memory _tiersToMint = new uint16[](nbTiers); // Temp tiers, will get overwritten later (pass the constructor check) uint256 originalFloorPrice = 10000000000000000000000000 ether; for (uint256 i; i < nbTiers; i++) { _tiers[i] = JB721TierParams({ contributionFloor: originalFloorPrice, lockedUntil: uint48(0), initialQuantity: 20, votingUnits: uint16(0), reservedRate: uint16(0), reservedTokenBeneficiary: reserveBeneficiary, encodedIPFSUri: tokenUris[i], allowManualMint: true, // Allow this type of mint shouldUseBeneficiaryAsDefault: false }); _tiersToMint[i] = uint16(i)+1; _tiersToMint[_tiersToMint.length - 1 - i] = uint16(i)+1; } ForTest_JBTiered721DelegateStore _ForTest_store = new ForTest_JBTiered721DelegateStore(); ForTest_JBTiered721Delegate _delegate = new ForTest_JBTiered721Delegate( projectId, IJBDirectory(mockJBDirectory), name, symbol, IJBFundingCycleStore(mockJBFundingCycleStore), baseUri, IJBTokenUriResolver(mockTokenUriResolver), contractUri, _tiers, IJBTiered721DelegateStore(address(_ForTest_store)), JBTiered721Flags({ lockReservedTokenChanges: false, lockVotingUnitChanges: false, lockManualMintingChanges: true, pausable: true }) ); _delegate.transferOwnership(owner); uint256 floorPrice = _delegate.test_store().tier(address(_delegate), 1).contributionFloor; console.log("original floor price"); console.log(originalFloorPrice); console.log("truncated floor price"); console.log(floorPrice); }
note, our initial contribution floor price setting is
uint256 originalFloorPrice = 10000000000000000000000000 ether; for (uint256 i; i < nbTiers; i++) { _tiers[i] = JB721TierParams({ contributionFloor: originalFloorPrice,
then we run our test
forge test -vv --match testJBTieredNFTRewardDelegate_mintFor_mintArrayOfTiers_downcast_POC
the result is
[PASS] testJBTieredNFTRewardDelegate_mintFor_mintArrayOfTiers_downcast_POC() (gas: 7601212) Logs: original floor price 10000000000000000000000000000000000000000000 truncated floor price 863278115882885135204352 Test result: ok. 1 passed; 0 failed; finished in 10.43ms
clearly the floor price is unsafed downcasted and trancated.
Foundry, Manual Review
We recommend the project either change the data type in the struct
struct JB721TierParams { uint256 contributionFloor; uint256 lockedUntil; uint256 initialQuantity; uint256 votingUnits; uint256 reservedRate; address reservedTokenBeneficiary; bytes32 encodedIPFSUri; bool allowManualMint; bool shouldUseBeneficiaryAsDefault; }
or safely downcast the number to make sure the number is not shortened unexpectedly.
https://docs.openzeppelin.com/contracts/3.x/api/utils#SafeCast
#0 - drgorillamd
2022-10-24T11:21:00Z
Duplicate #225
Thank you for the real poc:)
#1 - Picodes
2022-11-04T11:30:32Z
The warden showed how due to casting the original parameters could be truncated
#2 - c4-judge
2022-11-04T11:31:05Z
Picodes marked the issue as selected for report
#3 - c4-judge
2022-11-08T16:21:49Z
Picodes marked the issue as not a duplicate
#4 - c4-judge
2022-11-08T16:21:56Z
Picodes marked the issue as primary issue
🌟 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
37.8829 USDC - $37.88
https://github.com/jbx-protocol/juice-nft-rewards/blob/f9893b1497098241dd3a664956d8016ff0d0efd0/contracts/JBTiered721Delegate.sol#L461 https://github.com/jbx-protocol/juice-nft-rewards/blob/f9893b1497098241dd3a664956d8016ff0d0efd0/contracts/JBTiered721Delegate.sol#L504
the safeTransfer function for ERC721 tokens is inside the codebase,
However, the nft mint in mintFor and in mintReservesFor uses _mint rather than _safeMint and does not check that the receiver accepts ERC721 token transfers
// Mint the token. _mint(_reservedTokenBeneficiary, _tokenId); emit MintReservedToken(_tokenId, _tierId, _reservedTokenBeneficiary, msg.sender);
and
// Mint the token. _mint(_beneficiary, _tokenId); emit Mint(_tokenId, _tierIds[_i], _beneficiary, 0, msg.sender);
In mintReservesFor, the _reservedTokenBeneficiary can be a ERC20 token, which is not designed to receive NFT, then the NFT sent to that address will be locked ans lost forever.
_mint(_reservedTokenBeneficiary, _tokenId);
Manual Review
we recommend the project implement and use _safeMint to make sure the NFT recipient is capable of handling the received NFT.
function _safeMint(address to, uint256 id) internal virtual { _mint(to, id); require( to.code.length == 0 || ERC721TokenReceiver(to).onERC721Received(msg.sender, address(0), id, "") == ERC721TokenReceiver.onERC721Received.selector, "UNSAFE_RECIPIENT" ); }
the safeMint introduce re-entrancy risk, please handling the state change properly if using safeMint.
#0 - drgorillamd
2022-10-24T11:32:16Z
Duplicate #222
#1 - c4-judge
2022-12-03T19:16:56Z
Picodes marked the issue as not a duplicate
#2 - c4-judge
2022-12-03T19:17:05Z
Picodes changed the severity to QA (Quality Assurance)
#3 - c4-judge
2022-12-03T19:17:23Z
Picodes marked the issue as grade-b