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: 23/173
Findings: 4
Award: $199.39
π 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
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L102-L104 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Quest.sol#L76-L79
The withdrawFee()
function, responsible for collecting the fees has two issues:
The function contains the modifier onlyAdminWithdrawAfterEnd
, however, when we look into the said modifier we see it only checks for the endTime, and not the msg.sender as the name would imply
modifier onlyAdminWithdrawAfterEnd() { if (block.timestamp < endTime) revert NoWithdrawDuringClaim(); _; }
So any user can call this function once the endTime has passed, and they can do so multiple times, deducting the fees from the contract and essentially draining it before the actual users who completed the quests are able to redeem their receipts. This is classified as high severity since it denies the users awards, and drains the contract.
The following test snippet shows how the fee can be deducted twice without any issue by a non-admin (firstAddress). This can be repeated until the contract is drained.
it('should pay fee multiple times', async () => { await deployedFactoryContract.connect(firstAddress).mintReceipt(questId, messageHash, signature) await deployedQuestContract.start() await ethers.provider.send('evm_increaseTime', [86400]) await deployedQuestContract.connect(firstAddress).claim() await ethers.provider.send('evm_increaseTime', [100001]) // Initial balances let initBal = await deployedSampleErc20Contract.balanceOf(deployedQuestContract.address) let protocolFee = await deployedQuestContract.protocolFee() // First fee deduction await deployedQuestContract.connect(firstAddress).withdrawFee() expect(await deployedSampleErc20Contract.balanceOf(deployedQuestContract.address)).to.equal( initBal.sub(protocolFee) ) // Second fee deduction await deployedQuestContract.connect(firstAddress).withdrawFee() expect(await deployedSampleErc20Contract.balanceOf(deployedQuestContract.address)).to.equal( initBal.sub(protocolFee).sub(protocolFee) ) await ethers.provider.send('evm_increaseTime', [-100001]) await ethers.provider.send('evm_increaseTime', [-86400]) })
Hardhat
Prevent fee from being deducted multiple times.
// Declare a bool bool feeDeducted; function withdrawFee() public onlyAdminWithdrawAfterEnd { // Check bool require(!feeDeducted, "Fee already deducted"); // Set bool feeDeducted = true; IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); }
Also, consider changing the modifier onlyAdminWithdrawAfterEnd
to also check msg.sender, or rename it to better represent its purpose since it is misleading in its current form.
#0 - c4-judge
2023-02-05T03:08:14Z
kirk-baird marked the issue as duplicate of #23
#1 - c4-judge
2023-02-14T09:00:27Z
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/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/QuestFactory.sol#L219-L229 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleReceipt.sol#L98-L104
The function withdrawRemainingTokens()
in ERC20Quest.sol as shown below shows the intent of saving tokens for users who have minted their receipt, but haven't claimed their tokens yet, when the owner tries to drain the contract.
/// @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); }
This intent is also included in the natspec, setting an expectation that a user who has completed the quest in the stipulated time will be able to claim their reward even if the owner tries to disable the quest. However, this expectation can be broken.
Receipts can be minted by calling the function mintReceipt()
in QuestFactory.sol
. However this function never chacks for the ending timestamps of any of the quests. Thus, a malicious user can complete the quest after the endtime, generate a signature and claim the prize tokens, denying a legitimate user who hasn't claimed yet from a payout since the contract will not have enough tokens. This breaks the intent specified by the natspec and the code, and is classified as high severity as it leads to denying of rewards by a malicious user.
The RabbitHoleReceipt.sol
contract also has a public mapping timestampForTokenId
which keeps track of mint times, again showing intent to control access based on timestamps but this is never used.
POC isn't provided since it is trivially shown. The mintReceipt
function is in a different contract (QuestFactory.sol) and has no information about the end timestamps of the quests, which are stored in ERC20Quest.sol.
Manual Review
Incorporate the timestampForTokenId
mapping to check for valid receipts in the claim function in Quest.sol. It is shown here in the if statement checking claimed tokens.
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]) && rabbitHoleReceiptContract.timestampForTokenId(i) < endTime ) { redeemableTokenCount++; } } if (redeemableTokenCount == 0) revert AlreadyClaimed(); uint256 totalRedeemableRewards = _calculateRewards(redeemableTokenCount); _setClaimed(tokens); _transferRewards(totalRedeemableRewards); redeemedTokens += redeemableTokenCount; emit Claimed(msg.sender, totalRedeemableRewards); }
Another option would be to include this check in the claim isClaimed()
function and set all expired tokens as claimed, which can be useful for the front end.
#0 - c4-judge
2023-02-05T03:16:31Z
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:47:59Z
kirk-baird marked the issue as satisfactory
π Selected for report: carrotsmuggler
Also found by: AkshaySrivastav, ElKu, HollaDieWaldfee, Iurii3, KmanOfficial, adriro, bin2chen, evan, hansfriese, hl_, mert_eren, omis, peanuts
159.8324 USDC - $159.83
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L102-L104 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L81-L87
The contract ERC20Quest.sol
has two functions of interest here. The first is withdrawFee()
, which is responsible for transferring out the fee amount from the contract once endTime has been passed, and the second is withdrawRemainingTokens()
which recovers the remaining tokens in the contract which haven't been claimed yet.
Function withdrawRemainingTokens()
:
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); }
As evident from this excerpt, calling this recovery function subtracts the tokens which are already assigned to someone who completed the quest, and the fee, and returns the rest. However, there is no check for whether the fee has already been paid or not. The owner is expected to first call withdrawRemainingTokens()
, and then call withdrawFee()
.
However, if the owner calls withdrawFee()
before calling the function withdrawRemainingTokens()
, the fee will be paid out by the first call, but the same fee amount will still be kept in the contract after the second function call, basically making it unrecoverable. Since there are no checks in place to prevent this, this is classified as a high severity since it is an easy mistake to make and leads to loss of funds of the owner.
This can be demonstrated with this test
describe('Funds stuck due to wrong order of function calls', async () => { it('should trap funds', async () => { await deployedFactoryContract.connect(firstAddress).mintReceipt(questId, messageHash, signature) await deployedQuestContract.start() await ethers.provider.send('evm_increaseTime', [86400]) await deployedQuestContract.connect(firstAddress).claim() await ethers.provider.send('evm_increaseTime', [100001]) await deployedQuestContract.withdrawFee() await deployedQuestContract.withdrawRemainingTokens(owner.address) expect(await deployedSampleErc20Contract.balanceOf(deployedQuestContract.address)).to.equal(200) expect(await deployedSampleErc20Contract.balanceOf(owner.address)).to.be.lessThan( totalRewardsPlusFee * 100 - 1 * 1000 - 200 ) await ethers.provider.send('evm_increaseTime', [-100001]) await ethers.provider.send('evm_increaseTime', [-86400]) }) })
Even though the fee is paid, the contract still retains the fee amount. The owner receives less than the expected amount. This test is a modification of the test should transfer non-claimable rewards back to owner
already present in ERC20Quest.spec.ts
.
Hardhat
Only allow fee to be withdrawn after the owner has withdrawn the funds.
// Declare a boolean to check if recovery happened bool recoveryDone; 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); // Set recovery bool recoveryDone = true; } function withdrawFee() public onlyAdminWithdrawAfterEnd { // Check recovery require(recoveryDone,"Recover tokens before withdrawing Fees"); IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); }
#0 - c4-judge
2023-02-05T03:07:13Z
kirk-baird marked the issue as duplicate of #42
#1 - c4-judge
2023-02-06T08:19:46Z
kirk-baird marked the issue as not a duplicate
#2 - c4-judge
2023-02-06T08:19:55Z
kirk-baird marked the issue as duplicate of #61
#3 - c4-judge
2023-02-14T10:01:20Z
kirk-baird marked the issue as satisfactory
#4 - c4-judge
2023-02-14T10:03:03Z
kirk-baird marked the issue as selected for report
#5 - c4-sponsor
2023-02-22T22:59:40Z
waynehoover marked the issue as disagree with severity
#6 - waynehoover
2023-02-22T23:00:14Z
While I agree that this is an issue, but not a high risk issue. I expect a high risk issues to be issues that can be called by anyone, not owners.
As owners there are plenty of ways we can sabotage our contracts (for example via the set* functions) it is an issue for an owner.
The owner understands how these functions work, so they can be sure to call them in the right order.
#7 - kirk-baird
2023-02-23T23:48:04Z
I agree with the sponsor that since this is an onlyOwner
function that medium severity is more appropriate.
The likelihood of this issue is reduced as it can only be called by the owner.
Note: the ineffective onlyAdminWithdrawAfterEnd
modifier not validating admin is raised in another issue.
#8 - c4-judge
2023-02-23T23:48:14Z
kirk-baird changed the severity to 2 (Med Risk)
π 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
ERC20Quest.sol implements this function in a way where users can still claim their rewards after the owner clears out the quest contract. Suggest implementing the same logic for ERC1155Quest as well.
ERC721 and ERC1155 transfer contracts generally have a recovery address option allowing the msg.sender to send the ERC721/1155 token to a different address. This is recommended since not all smart contract wallets have the required callback functions implemented, and thus might lead to stuck tokens/unexpected reverts. Advised to add recovery address option, or take a destination address as an input.
ERC721 and ERC1155 transfer contracts generally have a recovery address option allowing the msg.sender to send the ERC721/1155 token to a different address. This is recommended since not all smart contract wallets have the required callback functions implemented, and thus might lead to stuck tokens/unexpected reverts. Advised to add recovery address option, or take a destination address as an input.
Currently, start()
function can be called multiple times, and unpauses the contract as well. General practice is to have a start()
function which is callable once, and use pause/unpause
to control the contract instead of the start function.
The modifier name says onlyAdmin, but the modifier itself doesn't check for admin. Consider renaming the modifier.
Consider limiting maximum number of claims in a single transaction.
ERC1155Quest creator does not validate the passed ERC1155 address. It is easy to validate ERC1155 address since they support ERC165 so a simple supportInterface
call is advised.
Every change of storage state in a contract should be accompanied by an event. This is missing in a few places as listed below
#0 - kirk-baird
2023-02-05T03:28:59Z
Note issues 1 and 5 are high severity issues but not described in sufficient detail to justify upgrading these.
#1 - c4-judge
2023-02-05T03:29:02Z
kirk-baird marked the issue as grade-b
#2 - c4-sponsor
2023-02-08T14:39:46Z
GarrettJMU marked the issue as sponsor confirmed