Platform: Code4rena
Start Date: 25/01/2023
Pot Size: $36,500 USDC
Total HM: 11
Participants: 173
Period: 5 days
Judge: kirk-baird
Total Solo HM: 1
Id: 208
League: ETH
Rank: 12/173
Findings: 5
Award: $476.40
π Selected for report: 1
π Solo Findings: 0
π Selected for report: adriro
Also found by: 0xRobocop, 0xmrhoodie, 0xngndev, AkshaySrivastav, ArmedGoose, Atarpara, Bauer, CodingNameKiki, ElKu, Garrett, HollaDieWaldfee, IllIllI, Iurii3, KIntern_NA, KmanOfficial, Lotus, M4TZ1P, MiniGlome, Ruhum, SovaSlava, bin2chen, bytes032, carrotsmuggler, cccz, chaduke, codeislight, cryptonue, doublesharp, evan, fs0c, glcanvas, gzeon, hansfriese, hihen, hl_, holme, horsefacts, ladboy233, lukris02, mahdikarimi, manikantanynala97, martin, mert_eren, mrpathfindr, omis, peakbolt, peanuts, prestoncodes, rbserver, rvierdiiev, sashik_eth, timongty, tnevler, trustindistrust, usmannk, wait, yixxas, zadaru13, zaskoh
0.7512 USDC - $0.75
The user may not able to claim the eligible amount after the end timestamp if the admin only calls withdrawFee multiple times
In the current implementation, the user is expects to claim the reward after the start timestamp
function claim() public virtual onlyQuestActive {
the onlyQuestActive is coded below:
/// @notice Checks if quest has started both at the function level and at the start time modifier onlyQuestActive() { if (!hasStarted) revert NotStarted(); if (block.timestamp < startTime) revert ClaimWindowNotStarted(); _; }
Basically after the startTime the user can the claim the reward.
After the endTime, the admin can claim the fee as well
/// @notice Sends the protocol fee to the protocolFeeRecipient /// @dev Only callable when the quest is ended function withdrawFee() public onlyAdminWithdrawAfterEnd { IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); }
which calls:
/// @notice Function that calculates the protocol fee function protocolFee() public view returns (uint256) { return (receiptRedeemers() * rewardAmountInWeiOrTokenId * questFee) / 10_000; }
and
/// @notice Call the QuestFactory contract to get the amount of receipts that have been minted /// @return The amount of receipts that have been minted for the given quest function receiptRedeemers() public view returns (uint256) { return questFactoryContract.getNumberMinted(questId); }
note the onlyAdminWithdrawAfterEnd modifier:
/// @notice Prevents reward withdrawal until the Quest has ended modifier onlyAdminWithdrawAfterEnd() { if (block.timestamp < endTime) revert NoWithdrawDuringClaim(); _; }
The issue is that the owner can call the withdrawFee multiple fee intentionally or even unintentionally drain the token from the ERC20Quest, and the NFT owner cannot fully claim the reward they are eligible for.
Manual Review
Only let the admin withdrawal the fee onces and the withdrawable fee to 0 after the initial withdraw.
#0 - c4-judge
2023-02-05T02:17:17Z
kirk-baird marked the issue as duplicate of #23
#1 - c4-judge
2023-02-14T08:54:38Z
kirk-baird changed the severity to 3 (High Risk)
#2 - c4-judge
2023-02-14T09:00:31Z
kirk-baird marked the issue as satisfactory
π Selected for report: carlitox477
Also found by: 0xRobocop, 0xbepresent, ArmedGoose, Atarpara, IllIllI, Tointer, UdarTeam, adriro, betweenETHlines, cryptojedi88, evan, glcanvas, gzeon, horsefacts, ladboy233, libratus, lukris02, luxartvinsec, manikantanynala97, minhquanym, mookimgo, p4st13r4, simon135, thekmj, trustindistrust
18.6976 USDC - $18.70
Unbounded loop when claiming the NFT reward.
The code contains two unbounded loop that can consume all gas and revert the tranasction when claming the reward
/// @notice Allows user to claim the rewards entitled to them /// @dev User can claim based on the (unclaimed) number of tokens they own of the Quest function claim() public virtual onlyQuestActive { if (isPaused) revert QuestPaused(); uint[] memory tokens = rabbitHoleReceiptContract.getOwnedTokenIdsOfQuest(questId, msg.sender); if (tokens.length == 0) revert NoTokensToClaim(); uint256 redeemableTokenCount = 0; for (uint i = 0; i < tokens.length; i++) { if (!isClaimed(tokens[i])) { redeemableTokenCount++; } } if (redeemableTokenCount == 0) revert AlreadyClaimed(); uint256 totalRedeemableRewards = _calculateRewards(redeemableTokenCount); _setClaimed(tokens); _transferRewards(totalRedeemableRewards); redeemedTokens += redeemableTokenCount; emit Claimed(msg.sender, totalRedeemableRewards); }
The first one is:
uint[] memory tokens = rabbitHoleReceiptContract.getOwnedTokenIdsOfQuest(questId, msg.sender);
iterate over the nft owned by msg.sender given the questId,
/// @dev get the token ids for a quest owned by an address /// @param questId_ the quest id /// @param claimingAddress_ the address claiming to own the tokens function getOwnedTokenIdsOfQuest( string memory questId_, address claimingAddress_ ) public view returns (uint[] memory) { uint msgSenderBalance = balanceOf(claimingAddress_); uint[] memory tokenIdsForQuest = new uint[](msgSenderBalance); uint foundTokens = 0; for (uint i = 0; i < msgSenderBalance; i++) { uint tokenId = tokenOfOwnerByIndex(claimingAddress_, i); if (keccak256(bytes(questIdForTokenId[tokenId])) == keccak256(bytes(questId_))) { tokenIdsForQuest[i] = tokenId; foundTokens++; } } uint[] memory filteredTokens = new uint[](foundTokens); uint filterTokensIndexTracker = 0; for (uint i = 0; i < msgSenderBalance; i++) { if (tokenIdsForQuest[i] > 0) { filteredTokens[filterTokensIndexTracker] = tokenIdsForQuest[i]; filterTokensIndexTracker++; } } return filteredTokens; }
the owner coulld have 10 NFT but purchase 100 NFT, the loop needs to run 110 times.
The second unbounded loop is when we mark all NFT as used, the same amout of loop needs to run again.
_setClaimed(tokens);
Which can be very gas costly.
Manual Review
We recommend the protocol set upper limit for the generated and claimable NFT number
#0 - c4-judge
2023-02-05T04:52:17Z
kirk-baird marked the issue as primary issue
#1 - c4-sponsor
2023-02-07T20:44:18Z
waynehoover marked the issue as disagree with severity
#2 - waynehoover
2023-02-07T20:44:36Z
#3 - c4-judge
2023-02-14T09:18:51Z
kirk-baird marked the issue as satisfactory
#4 - c4-judge
2023-02-14T09:21:11Z
kirk-baird marked issue #552 as primary and marked this issue as a duplicate of 552
#5 - kirk-baird
2023-02-16T06:50:42Z
Discussion about severity can be seen in #403
π Selected for report: ladboy233
Also found by: 0x4non, 0xmrhoodie, CodingNameKiki, ElKu, StErMi, Tricko, adriro, rbserver
421.054 USDC - $421.05
Buyer on secondary NFT market can lose fund if they buy a NFT that is already used to claim the reward
Let us look closely into the Quest.sol#claim function
/// @notice Allows user to claim the rewards entitled to them /// @dev User can claim based on the (unclaimed) number of tokens they own of the Quest function claim() public virtual onlyQuestActive { if (isPaused) revert QuestPaused(); uint[] memory tokens = rabbitHoleReceiptContract.getOwnedTokenIdsOfQuest(questId, msg.sender); if (tokens.length == 0) revert NoTokensToClaim(); uint256 redeemableTokenCount = 0; for (uint i = 0; i < tokens.length; i++) { if (!isClaimed(tokens[i])) { redeemableTokenCount++; } } if (redeemableTokenCount == 0) revert AlreadyClaimed(); uint256 totalRedeemableRewards = _calculateRewards(redeemableTokenCount); _setClaimed(tokens); _transferRewards(totalRedeemableRewards); redeemedTokens += redeemableTokenCount; emit Claimed(msg.sender, totalRedeemableRewards); }
After the NFT is used to claim, the _setClaimed(token) is called to mark the NFT as used to prevent double claiming.
/// @notice Marks token ids as claimed /// @param tokenIds_ The token ids to mark as claimed function _setClaimed(uint256[] memory tokenIds_) private { for (uint i = 0; i < tokenIds_.length; i++) { claimedList[tokenIds_[i]] = true; } }
The NFT is also tradeable in the secondary marketplace, I woud like to make a reasonable assumption that user wants to buy the NFT because they can use the NFT to claim the reward, which means after the reward is claimed, the NFT lose value.
Consider the case below:
User A can intentionally front-run User B's buy transaction by monitoring the mempool in polygon using the service
https://www.blocknative.com/blog/polygon-mempool
Or it could be just two user submit transaction at the same and User A's claim transaction happens to execute first.
Manual Review
Disable NFT transfer and trade once the NFT is used to claim the reward.
#0 - c4-judge
2023-02-06T22:51:19Z
kirk-baird marked the issue as duplicate of #201
#1 - c4-judge
2023-02-14T09:15:02Z
kirk-baird marked the issue as satisfactory
#2 - c4-judge
2023-02-14T09:15:17Z
kirk-baird marked the issue as selected for report
#3 - c4-sponsor
2023-02-22T23:09:57Z
waynehoover marked the issue as sponsor acknowledged
π Selected for report: AkshaySrivastav
Also found by: KIntern_NA, SovaSlava, Tointer, Tricko, V_B, __141345__, betweenETHlines, bin2chen, cccz, critical-or-high, glcanvas, halden, hihen, jesusrod15, ladboy233, libratus, m9800, minhquanym, omis, peakbolt, rbserver, romand, rvierdiiev, wait, zaskoh
18.6976 USDC - $18.70
Signature schema in QuestFactory is too simple
In the current implementation of the QueryFactory, the signature schema is too Simple
/// @dev recover the signer from a hash and signature /// @param hash_ The hash of the message /// @param signature_ The signature of the hash function recoverSigner(bytes32 hash_, bytes memory signature_) public pure returns (address) { bytes32 messageDigest = keccak256(abi.encodePacked('\x19Ethereum Signed Message:\n32', hash_)); return ECDSAUpgradeable.recover(messageDigest, signature_); } /// @dev mint a RabbitHole Receipt. Note: this contract must be set as Minter on the receipt contract /// @param questId_ The id of the quest /// @param hash_ The hash of the message /// @param signature_ The signature of the hash function mintReceipt(string memory questId_, bytes32 hash_, bytes memory signature_) public { if (quests[questId_].numberMinted + 1 > quests[questId_].totalParticipants) revert OverMaxAllowedToMint(); if (quests[questId_].addressMinted[msg.sender] == true) revert AddressAlreadyMinted(); if (keccak256(abi.encodePacked(msg.sender, questId_)) != hash_) revert InvalidHash(); if (recoverSigner(hash_, signature_) != claimSignerAddress) revert AddressNotSigned(); quests[questId_].addressMinted[msg.sender] = true; quests[questId_].numberMinted++; emit ReceiptMinted(msg.sender, questId_); rabbitholeReceiptContract.mint(msg.sender, questId_); }
Only the questId and msg.sender is included, the chain id is missing, the nonce is missing, the timestamp is missing as well, meaning the signature does not expire.
Chain id is missing -> potential cross-chain transaction replay. Timestamp is missing -> the signature does not expire. for example, the a transaction mintReceipt with msg.sender signature is issued, this transaction is pending, however, the owner realize that he should change the claimSignerAddress, he issue another transaciton trying to change the claimSignerAddress,
Because the the signature does not expire, after a period of time, the mintReceipt landed first before the change claimSignerAddress transaction is landed and because old claimSIgnerAddress is used, the NFT still mint to old claimSignerAddress.
If the transaction has deadline check, by the time the mintReceipt landed, the signature expires and revert, which is revert, then the change old claimSignerAddress transaction comes in and the claimSignerAddress is updated.
The nonce incremented nonce is missing -> transaction may be vulnerable to replay if the smart contract is upgraded, the QuestFactory is certainly a upgradeable contract.
Manual Review
We recommend the protocol make the signature schema more sophicated and including nonce and timesetamp and chain id.
#0 - c4-judge
2023-02-03T11:36:33Z
kirk-baird marked the issue as duplicate of #45
#1 - c4-judge
2023-02-14T09:39:08Z
kirk-baird marked the issue as satisfactory