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: 65/173
Findings: 3
Award: $39.56
🌟 Selected for report: 0
🚀 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 ERC20 quest protocol fee can be repeatedly withdrawn. Although the fee recipient's address is immutable and fees protected from theft, the lack of access control in the "withdrawFee()"
function means that anyone can call it multiple times, potentially causing damage to the claim process.
The Erc20Quest
contract has the ability to account protocol fees based on the number of participants in a quest. There is a "withdrawFee()"
function for transferring these fees to a recipient address, which is immutable and set in the constructor.
File: Erc20Quest.sol 100: /// @notice Sends the protocol fee to the protocolFeeRecipient 101: /// @dev Only callable when the quest is ended 102: function withdrawFee() public onlyAdminWithdrawAfterEnd { 103: IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); 104: }
The "withdrawFee()" function lacks proper checks to ensure that the fee has not already been withdrawn, and also lacks access control. This creates a potential vulnerability where a malicious actor could repeatedly call the function, leading to a reduced token balance in the contract and potentially disrupting the claim functionality.
Following test shows how multiple withdraw bleaks claim process:
quest-protocol/test/Erc20Quest.spec.ts
describe('withdrawFee() multiple times', async () => { it.only('Could transfer protocol fees back to owner multiple times', async () => { const beginningContractBalance = await deployedSampleErc20Contract.balanceOf(deployedQuestContract.address) await deployedFactoryContract.connect(firstAddress).mintReceipt(questId, messageHash, signature) await deployedQuestContract.start() await ethers.provider.send('evm_increaseTime', [86400]) expect(await deployedSampleErc20Contract.balanceOf(protocolFeeAddress)).to.equal(0) expect(await deployedQuestContract.protocolFee()).to.equal(200) expect(await deployedSampleErc20Contract.balanceOf(deployedQuestContract.address)).to.equal( totalRewardsPlusFee * 100 ) await ethers.provider.send('evm_increaseTime', [100001]) for (var i = 0; i < totalParticipants * 6 * 100; i++) { await deployedQuestContract.withdrawFee() } expect(await deployedSampleErc20Contract.balanceOf(deployedQuestContract.address)).to.equal(0) await expect(deployedQuestContract.connect(firstAddress).claim()).to.be.revertedWith( 'ERC20: transfer amount exceeds balance' ) await ethers.provider.send('evm_increaseTime', [-100001]) await ethers.provider.send('evm_increaseTime', [-86400]) }) })
Consider adding a boolean variable that tracks the status of the protocol fee and check it in "withdrawFee()" function:
function withdrawFee() public onlyAdminWithdrawAfterEnd { require(!feeWithdrawn, "Fee has already been withdrawn."); feeWithdrawn = true; IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); }
#0 - c4-judge
2023-02-06T08:54:13Z
kirk-baird marked the issue as duplicate of #23
#1 - c4-judge
2023-02-14T08:54:39Z
kirk-baird changed the severity to 3 (High Risk)
#2 - c4-judge
2023-02-14T08:56:20Z
kirk-baird marked the issue as satisfactory
🌟 Selected for report: adriro
Also found by: 0xRobocop, 0xbepresent, Breeje, CodingNameKiki, HollaDieWaldfee, Kenshin, M4TZ1P, Ruhum, Tricko, badman, bin2chen, carrotsmuggler, cccz, csanuragjain, glcanvas, joestakey, lukris02, m9800, mert_eren, peakbolt, peanuts, prestoncodes, rvierdiiev, sashik_eth
21.6061 USDC - $21.61
Judge has assessed an item in Issue #621 as 2 risk. The relevant finding follows:
L2 - mintReceipt() function lacks a check to verify if the quest has already ended mintReceipt() function missing check for ended quest. This could result in a scenario where a receipt is minted after the quest has ended and the remaining tokens and fee have been withdrawn from the quest contract. This would result in a situation where there are more claimable receipts than there are tokens available in the quest balance to redeem them.
File: QuestFactory.sol 215: /// @dev mint a RabbitHole Receipt. Note: this contract must be set as Minter on the receipt contract 216: /// @param questId_ The id of the quest 217: /// @param hash_ The hash of the message 218: /// @param signature_ The signature of the hash 219: function mintReceipt(string memory questId_, bytes32 hash_, bytes memory signature_) public { 220: if (quests[questId_].numberMinted + 1 > quests[questId_].totalParticipants) revert OverMaxAllowedToMint(); 221: if (quests[questId_].addressMinted[msg.sender] == true) revert AddressAlreadyMinted(); 222: if (keccak256(abi.encodePacked(msg.sender, questId_)) != hash_) revert InvalidHash(); 223: if (recoverSigner(hash_, signature_) != claimSignerAddress) revert AddressNotSigned(); 224: 225: quests[questId_].addressMinted[msg.sender] = true; 226: quests[questId_].numberMinted++; 227: emit ReceiptMinted(msg.sender, questId_); 228: rabbitholeReceiptContract.mint(msg.sender, questId_); 229: } Recommended Mitigation Steps
Consider adding check for quest not ended in mintReceipt() function:
function mintReceipt(string memory questId_, bytes32 hash_, bytes memory signature_) public { if (IQuest(quests[questId_].address).ended < block.timestamp) revert QuestEnded(); ... }
#0 - c4-judge
2023-02-06T23:05:20Z
kirk-baird marked the issue as duplicate of #22
#1 - c4-judge
2023-02-14T08:42:25Z
kirk-baird marked the issue as satisfactory
🌟 Selected for report: CodingNameKiki
Also found by: 0x1f8b, 0x4non, 0x5rings, 0xAgro, 0xMirce, 0xRobocop, 0xSmartContract, 0xackermann, AkshaySrivastav, ArmedGoose, Aymen0909, BClabs, Breeje, Dewaxindo, Diana, HollaDieWaldfee, IceBear, IllIllI, Iurii3, Josiah, Krayt, PaludoX0, Phenomana, PrasadLak, RaymondFam, Rolezn, SaeedAlipoor01988, SaharDevep, SleepingBugs, adriro, arialblack14, bin2chen, brevis, btk, carlitox477, carrotsmuggler, catellatech, chaduke, chrisdior4, cryptonue, cryptostellar5, csanuragjain, ddimitrov22, fellows, glcanvas, halden, hl_, horsefacts, jat, joestakey, kenta, libratus, lukris02, luxartvinsec, manikantanynala97, martin, matrix_0wl, nadin, nicobevi, oberon, peanuts, prestoncodes, rbserver, sakshamguruji, sashik_eth, sayan, seeu, thekmj, tnevler, trustindistrust, tsvetanovv, xAriextz, zadaru13, zaskoh
17.196 USDC - $17.20
withdrawRemainingTokens()
function in the Erc1155Quest
contract allows the owner to withdraw all remaining tokens, including unclaimed ones that may still be claimable in the future. This could result in the accidental withdrawal of tokens that are meant to remain on the contract balance until claimed by users.
File: Erc1155Quest.sol 52: /// @dev Withdraws the remaining tokens from the contract. Only able to be called by owner 53: /// @param to_ The address to send the remaining tokens to 54: function withdrawRemainingTokens(address to_) public override onlyOwner { 55: super.withdrawRemainingTokens(to_); 56: IERC1155(rewardToken).safeTransferFrom( 57: address(this), 58: to_, 59: rewardAmountInWeiOrTokenId, 60: IERC1155(rewardToken).balanceOf(address(this), rewardAmountInWeiOrTokenId), 61: '0x00' 62: ); 63: }
Recommended Mitigation Steps
Consider adding tracking flow in Erc1155Quest
contract withdrawRemainingTokens
function similar to Erc20Quest
withdrawing function:
function withdrawRemainingTokens(address to_) public override onlyOwner { super.withdrawRemainingTokens(to_); uint256 remainingTokens = totalParticipants - questFactoryContract.getNumberMinted(questId); IERC1155(rewardToken).safeTransferFrom( address(this), to_, rewardAmountInWeiOrTokenId, remainingTokens, '0x00' ); }
mintReceipt()
function lacks a check to verify if the quest has already endedmintReceipt()
function missing check for ended quest. This could result in a scenario where a receipt is minted after the quest has ended and the remaining tokens and fee have been withdrawn from the quest contract. This would result in a situation where there are more claimable receipts than there are tokens available in the quest balance to redeem them.
File: QuestFactory.sol 215: /// @dev mint a RabbitHole Receipt. Note: this contract must be set as Minter on the receipt contract 216: /// @param questId_ The id of the quest 217: /// @param hash_ The hash of the message 218: /// @param signature_ The signature of the hash 219: function mintReceipt(string memory questId_, bytes32 hash_, bytes memory signature_) public { 220: if (quests[questId_].numberMinted + 1 > quests[questId_].totalParticipants) revert OverMaxAllowedToMint(); 221: if (quests[questId_].addressMinted[msg.sender] == true) revert AddressAlreadyMinted(); 222: if (keccak256(abi.encodePacked(msg.sender, questId_)) != hash_) revert InvalidHash(); 223: if (recoverSigner(hash_, signature_) != claimSignerAddress) revert AddressNotSigned(); 224: 225: quests[questId_].addressMinted[msg.sender] = true; 226: quests[questId_].numberMinted++; 227: emit ReceiptMinted(msg.sender, questId_); 228: rabbitholeReceiptContract.mint(msg.sender, questId_); 229: }
Recommended Mitigation Steps
Consider adding check for quest not ended in mintReceipt()
function:
function mintReceipt(string memory questId_, bytes32 hash_, bytes memory signature_) public { if (IQuest(quests[questId_].address).ended < block.timestamp) revert QuestEnded(); ... }
true
valueFile: QuestFactory.sol 221: if (quests[questId_].addressMinted[msg.sender] == true) revert AddressAlreadyMinted(); File: Quest.sol 136: return claimedList[tokenId_] == true;
claim()
function in Quest
contract breaks "Checks Effects Interactions" pattern, function calls to external contracts on line 114 (in child implementations) and change contract state after this call:
File: Quest.sol 094: /// @notice Allows user to claim the rewards entitled to them 095: /// @dev User can claim based on the (unclaimed) number of tokens they own of the Quest 096: function claim() public virtual onlyQuestActive { 097: if (isPaused) revert QuestPaused(); 098: 099: uint[] memory tokens = rabbitHoleReceiptContract.getOwnedTokenIdsOfQuest(questId, msg.sender); 100: 101: if (tokens.length == 0) revert NoTokensToClaim(); 102: 103: uint256 redeemableTokenCount = 0; 104: for (uint i = 0; i < tokens.length; i++) { 105: if (!isClaimed(tokens[i])) { 106: redeemableTokenCount++; 107: } 108: } 109: 110: if (redeemableTokenCount == 0) revert AlreadyClaimed(); 111: 112: uint256 totalRedeemableRewards = _calculateRewards(redeemableTokenCount); 113: _setClaimed(tokens); 114: _transferRewards(totalRedeemableRewards); 115: redeemedTokens += redeemableTokenCount; 116: 117: emit Claimed(msg.sender, totalRedeemableRewards); 118: }
It does not lead to vulnerabilities now, but could lead in future, better to prevent it now by updating state before external interactions:
112: uint256 totalRedeemableRewards = _calculateRewards(redeemableTokenCount); 113: _setClaimed(tokens); 114: redeemedTokens += redeemableTokenCount; 115: _transferRewards(totalRedeemableRewards);
File: IQuest.sol 4: // TODO clean this whole thing up
#0 - c4-judge
2023-02-06T23:06:29Z
kirk-baird marked the issue as grade-b
#1 - c4-sponsor
2023-02-07T22:14:56Z
waynehoover marked the issue as sponsor acknowledged