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: 64/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
By default, the protocol receives a 20% cut per quest. After the quest ends, the admin is allowed to withdraw the protocol's share. But, there are no checks stopping them from withdrawing the fees multiple times. That allows them to drain any remaining funds inside the contract.
The withdrawFee()
function implements no checks on whether it was already called. Thus you can call it multiple times to drain the contract:
function withdrawFee() public onlyAdminWithdrawAfterEnd { IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); }
The protocol's fees are 20% of the actual claimed user rewards:
function protocolFee() public view returns (uint256) { // receiptRedeemers = number of minted receipts for this quest return (receiptRedeemers() * rewardAmountInWeiOrTokenId * questFee) / 10_000; }
The following scenario is possible:
50,000 * 0.2 = 10,000
tokenswithdrawFee()
to pull 10,000 tokens from the contract. By calling it 6 more times, they can also withdraw the remaining 60,000 tokens.After a contest has ended, the quest owner is supposed to withdraw the remaining tokens using withdrawRemainingTokens()
. To rug the quest owner, the admin has to withdraw the fees before them.
none
Add a check to withdrawFee()
so that it can only be called once.
#0 - c4-judge
2023-02-03T05:18:30Z
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:40Z
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
https://github.com/rabbitholegg/quest-protocol/blob/main/contracts/QuestFactory.sol#L219-L229 https://github.com/rabbitholegg/quest-protocol/blob/main/contracts/Erc20Quest.sol#L81-L87
The QuestFactory contract allows users to mint receipts for quests that have already ended. That can cause issues with the reward distribution because the quest contract might not have enough funds to cover all the existing receipts. A receipt holder might not be able to claim their reward.
The mintReceipt()
function doesn't verify that the quest hasn't ended yet:
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_); }
After a quest has ended, the owner of the quest is able to withdraw any remaining funds that are not reserved for existing receipts:
/// @notice Function that allows the owner to withdraw the remaining tokens in the contract /// @dev Every receipt minted should still be able to claim rewards (and cannot be withdrawn). This function can only be called after the quest end time /// @param to_ The address to send the remaining tokens to function withdrawRemainingTokens(address to_) public override onlyOwner { super.withdrawRemainingTokens(to_); uint unclaimedTokens = (receiptRedeemers() - redeemedTokens) * rewardAmountInWeiOrTokenId; uint256 nonClaimableTokens = IERC20(rewardToken).balanceOf(address(this)) - protocolFee() - unclaimedTokens; IERC20(rewardToken).safeTransfer(to_, nonClaimableTokens); }
The combination of both can cause the following issue:
The owner of the quest now calls withdrawRemainingTokens()
to withdraw the 1.6e18 tokens. This leaves the contract with exactly 8.4e18 tokens that are supposed to cover the 7 receipt holders as well the protocol fees.
At this point, Bob calls mintReceipt()
using the signature he has received at some point in the past. Since these signatures don't expire there's no deadline until which he has to submit it. This increases the number of receipts to 8. The 7e18 tokens reserved for the initial 7 receipt holders are now claimable by another user. This will lead to one of the receipt holders not being able to claim their reward since there are not enough tokens.
It also changes the protocol's fees. The protocol fees are calculated using the number of minted receipts. Since that value has increased, so do the protocol fees:
function protocolFee() public view returns (uint256) { return (receiptRedeemers() * rewardAmountInWeiOrTokenId * questFee) / 10_000; }
The admin is now able to withdraw 8 * 1e18 * 2_000 / 10_000 = 1.6e18
To summarize, minting receipts after a quest has ended breaks the reward distribution logic.
none
It should be impossible to mint receipts for quests that have already ended.
#0 - c4-judge
2023-02-03T11:18:38Z
kirk-baird marked the issue as duplicate of #22
#1 - c4-judge
2023-02-14T08:42:51Z
kirk-baird changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-02-14T08:48:15Z
kirk-baird marked the issue as satisfactory