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: 11/67
Findings: 2
Award: $1,108.06
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: yixxas
Also found by: cccz, minhquanym
1070.1834 USDC - $1,070.18
User will have their funds lost if they tries to pay the protocol with _dontMint = False
. A payment made with this parameter set should increase the creditsOf[]
balance of user.
In _processPayment()
, creditsOf[_data.beneficiary]
is updated at the end if there are leftover funds. However, If metadata
is provided and _dontMint == true
, it immediately returns.
JBTiered721Delegate.sol#L524-L590
function _processPayment(JBDidPayData calldata _data) internal override { // Keep a reference to the amount of credits the beneficiary already has. uint256 _credits = creditsOf[_data.beneficiary]; ... if ( _data.metadata.length > 36 && bytes4(_data.metadata[32:36]) == type(IJB721Delegate).interfaceId ) { ... // Don't mint if not desired. if (_dontMint) return; ... } ... // If there are funds leftover, mint the best available with it. if (_leftoverAmount != 0) { _leftoverAmount = _mintBestAvailableTier( _leftoverAmount, _data.beneficiary, _expectMintFromExtraFunds ); if (_leftoverAmount != 0) { // Make sure there are no leftover funds after minting if not expected. if (_dontOverspend) revert OVERSPENDING(); // Increment the leftover amount. creditsOf[_data.beneficiary] = _leftoverAmount; } else if (_credits != 0) creditsOf[_data.beneficiary] = 0; } else if (_credits != 0) creditsOf[_data.beneficiary] = 0; }
I've wrote a coded POC to illustrate this. It uses the same Foundry environment used by the project. Simply copy this function to E2E.t.sol
to verify.
function testPaymentNotAddedToCreditsOf() public{ address _user = address(bytes20(keccak256('user'))); ( JBDeployTiered721DelegateData memory NFTRewardDeployerData, JBLaunchProjectData memory launchProjectData ) = createData(); uint256 projectId = deployer.launchProjectFor( _projectOwner, NFTRewardDeployerData, launchProjectData ); // Get the dataSource IJBTiered721Delegate _delegate = IJBTiered721Delegate( _jbFundingCycleStore.currentOf(projectId).dataSource() ); address NFTRewardDataSource = _jbFundingCycleStore.currentOf(projectId).dataSource(); uint256 _creditBefore = IJBTiered721Delegate(NFTRewardDataSource).creditsOf(_user); // Project is initiated with 10 different tiers with contributionFee of 10,20,30,40, .... , 100 // Make payment to mint 1 NFT uint256 _payAmount = 10; _jbETHPaymentTerminal.pay{value: _payAmount}( projectId, 100, address(0), _user, 0, false, 'Take my money!', new bytes(0) ); // Minted 1 NFT assertEq(IERC721(NFTRewardDataSource).balanceOf(_user), 1); // Now, we make the payment but supply _dontMint metadata bool _dontMint = true; uint16[] memory empty; _jbETHPaymentTerminal.pay{value: _payAmount}( projectId, 100, address(0), _user, 0, false, 'Take my money!', //new bytes(0) abi.encode( bytes32(0), type(IJB721Delegate).interfaceId, _dontMint, false, false, empty ) ); // NFT not minted assertEq(IERC721(NFTRewardDataSource).balanceOf(_user), 1); // Check that credits of user is still the same as before even though we have made the payment assertEq(IJBTiered721Delegate(NFTRewardDataSource).creditsOf(_user),_creditBefore); }
Foundry
Update the creditsOf[]
in the if(_dontMint)
check.
- if(_dontMint) return; + if(_dontMint){ creditsOf[_data.beneficiary] += _value; }
#0 - trust1995
2022-10-23T22:44:53Z
Dup of #84
#1 - drgorillamd
2022-10-24T11:12:56Z
Thanks for the real poc:)
#2 - c4-judge
2022-11-04T12:50:19Z
Picodes marked the issue as selected for report
#3 - c4-judge
2022-11-07T17:57:25Z
Picodes marked the issue as not a duplicate
#4 - c4-judge
2022-11-07T17:58:23Z
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/7d7aec6f8642c6e1c5e55cfed67eb69b6fc8174a/contracts/JBTiered721DelegateStore.sol#L628 https://github.com/jbx-protocol/juice-nft-rewards/blob/7d7aec6f8642c6e1c5e55cfed67eb69b6fc8174a/contracts/JBTiered721DelegateStore.sol#L585-L588 https://github.com/jbx-protocol/juice-nft-rewards/blob/7d7aec6f8642c6e1c5e55cfed67eb69b6fc8174a/contracts/JBTiered721DelegateStore.sol#L1091-L1114
The protocol assumes that _tierIds.length
fits in uint16
, suggesting that max number of tiers should not exceed 2**16
. However, the lack of sanity check when new tiers are being added can lead to this assumption being untrue. For example, tiers can be burned wrongly in recordBurn()
.
recordAddTiers()
here does many checks on the params of JB721TierParams[]
but fails to check for _numberOfNewTiers + _currentMaxTierIdOf < 2**16
. Should tiers added exceed 2**16
, it will cause problems in tierIdOfToken()
.
JBTiered721DelegateStore.sol#L585-L588
function tierIdOfToken(uint256 _tokenId) public pure override returns (uint256) { // The tier ID is in the first 16 bits. return uint256(uint16(_tokenId)); }
tierIdOfToken()
does an explicit downcast on _tokenID
. If we query for the first token of the 2**16
th tier, it will return the wrong value of 1
due to the explicit downcast since higher-order bits are cut off as documented in solidity docs. Now, recordBurn()
relies on the return value of tierIdOfToken()
which causes the wrong value of token to be burned off as seen in the code snippet below.
JBTiered721DelegateStore.sol#L1091-L1114
function recordBurn(uint256[] memory _tokenIds) external override { // Get a reference to the number of token IDs provided. uint256 _numberOfTokenIds = _tokenIds.length; // Keep a reference to the token ID being iterated on. uint256 _tokenId; // Iterate through all tokens to increment the burn count. for (uint256 _i; _i < _numberOfTokenIds; ) { // Set the token's ID. _tokenId = _tokenIds[_i]; uint256 _tierId = tierIdOfToken(_tokenId); // Increment the number burned for the tier. numberOfBurnedFor[msg.sender][_tierId]++; _storedTierOf[msg.sender][_tierId].remainingQuantity++; unchecked { ++_i; } } }
Manual review
Add the sanity check _numberOfNewTiers + _currentMaxTierIdOf < 2**16
in recordAddTiers()
.
#0 - drgorillamd
2022-10-24T10:55:33Z
Duplicate #182
#1 - c4-judge
2022-11-05T17:54:36Z
Picodes marked the issue as selected for report
#2 - Picodes
2022-11-08T16:11:57Z
Downgrading to QA with all other issues related to tierId
being too large
#3 - c4-judge
2022-11-08T16:12:08Z
Picodes changed the severity to QA (Quality Assurance)
#4 - c4-judge
2022-11-08T16:12:30Z
Picodes marked the issue as not selected for report
#5 - c4-judge
2022-11-08T16:12:53Z
Picodes marked the issue as grade-b