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: 24/173
Findings: 6
Award: $188.26
🌟 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
Quest's rewardToken may be lost, or Quest may not work properly
When the quest expires, the following two actions are normally performed:
The implementation of Erc20Quest.withdrawFee() is as follows:
function withdrawFee() public onlyAdminWithdrawAfterEnd { IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); }
One problem with the current implementation is that it can be called repeatedly (as long as the time of quest is expires, any person can call it), which leads to the following possibilities:
The above two situations lead to: If the protocolFeeRecipient has been controlled by the hacker, then the transferred funds are lost If it is not controlled, it will also cause this Erc20Quest not to work properly, and the subsequent withdrawRemainingTokens() and claim() will fail, because there is no balance
Suggestion: add status, withdrawFee() can only be executed once
+ bool public hasWithdrawFee; function withdrawFee() public onlyAdminWithdrawAfterEnd { + require(!hasWithdrawFee,"Repeated withdraw"); IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); + hasWithdrawFee = true; }
#0 - c4-judge
2023-02-05T06:12:22Z
kirk-baird marked the issue as duplicate of #23
#1 - c4-judge
2023-02-14T08:57:39Z
kirk-baird marked issue #605 as primary and marked this issue as a duplicate of 605
#2 - c4-judge
2023-02-16T06:52: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
The malicious owner gets the claimSignerAddress signature, but does not call mintReceipt() first, waits until the Quest expires and executes withdrawRemainingTokens(), and then call mintReceipt(), so that the newly generated receipt call claim() will occupy the protocolFee
When the quest expires, the owner retrieves the remaining tokens by using withdrawRemainingTokens() nonClaimableTokens represents the number of tokens that can be retrieved, and is calculated as follows:
function withdrawRemainingTokens(address to_) public override onlyOwner { ... uint unclaimedTokens = (receiptRedeemers() - redeemedTokens) * rewardAmountInWeiOrTokenId; uint256 nonClaimableTokens = IERC20(rewardToken).balanceOf(address(this)) - protocolFee() - unclaimedTokens; IERC20(rewardToken).safeTransfer(to_, nonClaimableTokens); } function receiptRedeemers() public view returns (uint256) { return questFactoryContract.getNumberMinted(questId); }
withdrawRemainingTokens() leaves unclaim token of the already minted receipt and protocolFee in the contract, waiting for the unclaimed user and protocolFeeRecipient to get them.
but QuestFactory.mintReceipt() does not restrict the Quest expired can not be minted receipt
So the malicious owner can perform the following steps, resulting in the new minted receipt call claim(), will occupy the protocolFee
The example is as follows: Suppose have Erc20Quest: totalParticipants = 100 rewardAmountInWeiOrTokenId = 1 questFee = 10%
When Erc20Quest.start(), the balance of Erc20Quest is: 100 + 10 = 110
In the process, there are 100 people get offline signature, note: only 99 people to execute mintReceipt() and claim(), one of bob did not to execute QuestFactory.mintReceipt() (it is also possible that the bob was front-run by withdrawRemainingTokens() when executing mintReceipt()) so: numberMinted = 99 redeemedTokens = 99
After quest expires, the owner executes withdrawRemainingTokens(), the balance of Erc20Quest becomes: 9.9 (the remaining protocolFee)
After that bob can still execute QuestFactory.mintReceipt() and claim() to take away 1 token numberMinted = 100 redeemedTokens = 100
Erc20Quest's balance will be: 9.9 - 1 = 8.9 (i.e. bob occupies protocolFee)
protocolFeeRecipient executing Erc20Quest.withdrawFee() will fail, because the balance left is 8.9, but protocolFee()=10
So it is recommended that: mintReceipt() should check whether the quest expires, If it expires, it can't mint receipt
contract QuestFactory is Initializable, OwnableUpgradeable, AccessControlUpgradeable, IQuestFactory { function mintReceipt(string memory questId_, bytes32 hash_, bytes memory signature_) public { ... + require(block.timestamp < Quest(quests[questId_].questAddress).endTime(),"Quest Ended, can't mint receipt");
#0 - c4-judge
2023-02-05T06:12:58Z
kirk-baird marked the issue as duplicate of #42
#1 - c4-judge
2023-02-06T08:25:46Z
kirk-baird marked the issue as not a duplicate
#2 - c4-judge
2023-02-06T08:25:53Z
kirk-baird marked the issue as duplicate of #22
#3 - c4-judge
2023-02-14T08:42:51Z
kirk-baird changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-02-14T08:44:59Z
kirk-baird marked the issue as satisfactory
🌟 Selected for report: RaymondFam
Also found by: 0xMirce, AkshaySrivastav, AlexCzm, Aymen0909, BClabs, CodingNameKiki, ElKu, HollaDieWaldfee, Josiah, KIntern_NA, MiniGlome, StErMi, adriro, bin2chen, cccz, chaduke, csanuragjain, gzeon, hihen, holme, libratus, minhquanym, omis, peakbolt, peanuts, rbserver, rvierdiiev, timongty, ubermensch, usmannk, wait, zaskoh
7.046 USDC - $7.05
After Erc1155Quest.withdrawRemainingTokens(), unclaim user can't call claim()
It is described in Erc20Quest.withdrawRemainingTokens() as follows.
Every receipt minted should still be able to claim rewards (and cannot be withdrawn)
I think this is reasonable because:
Note - users could claim a receipt, sell it on secondary (not claim reward)
So when the receipt has been mint even if the Quest end time is up, it is still possible to claim(), because the user has sold the receipt, and claim() should be allowed as long as it has not been claimed
But the current Erc1155Quest.withdrawRemainingTokens() implementation does not retain this mechanism, it will Transfer the entire balance,No reserved unclaim token, which will result in the sold receipts not being able to claim()
The balance is fully transferred, not leaving unclaimed token
function withdrawRemainingTokens(address to_) public override onlyOwner { super.withdrawRemainingTokens(to_); IERC1155(rewardToken).safeTransferFrom( address(this), to_, rewardAmountInWeiOrTokenId, IERC1155(rewardToken).balanceOf(address(this), rewardAmountInWeiOrTokenId), //***@audit Take all the balance '0x00' ); }
Implement a mechanism similar to that in Erc20Quest, which has mint Receipt but no claim() , must leave the corresponding funds in the contract
#0 - c4-judge
2023-02-05T06:14:24Z
kirk-baird marked the issue as duplicate of #42
#1 - c4-judge
2023-02-10T05:03:11Z
kirk-baird changed the severity to QA (Quality Assurance)
#2 - c4-judge
2023-02-10T05:12:15Z
This previously downgraded issue has been upgraded by kirk-baird
#3 - c4-judge
2023-02-14T09:27:59Z
kirk-baird marked the issue as satisfactory
#4 - c4-judge
2023-02-23T23:49:21Z
kirk-baird changed the severity to 2 (Med Risk)
🌟 Selected for report: carrotsmuggler
Also found by: AkshaySrivastav, ElKu, HollaDieWaldfee, Iurii3, KmanOfficial, adriro, bin2chen, evan, hansfriese, hl_, mert_eren, omis, peanuts
122.948 USDC - $122.95
Erc20Quest.withdrawRemainingTokens() may leave additional token in the contract
After Quest expires, normally, the owner will take the remaining tokens and the protocol will take the protocolFee.
owner takes the remaining tokens via Erc20Quest.withdrawRemainingTokens(), and always leaves the protocolFee in the contract
function withdrawRemainingTokens(address to_) public override onlyOwner { super.withdrawRemainingTokens(to_); uint unclaimedTokens = (receiptRedeemers() - redeemedTokens) * rewardAmountInWeiOrTokenId; //****@audit subtract protocolFee(),so protocolFee will Stay in the contract uint256 nonClaimableTokens = IERC20(rewardToken).balanceOf(address(this)) - protocolFee() - unclaimedTokens; IERC20(rewardToken).safeTransfer(to_, nonClaimableTokens); }
anyone Call Erc20Quest.withdrawFee() to take the protocolFee and transfer it to the protocolFeeRecipient
function withdrawFee() public onlyAdminWithdrawAfterEnd { IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); }
But there is a problem here, the two methods do not limit the order, and withdrawRemainingTokens() does not check whether the protocolFee is taken or not, it always leaves the protocolFee() in the contract
This leads to a problem that if withdrawFee() is executed first, (this method can be called by anyone,Malicious users can front-run.) and the protocolFee has been transferred, then withdrawRemainingTokens() is executed afterwards, the protocolFee will still be left in the contract. This extra protocolFee will be locked in the contract.
So it is recommended that withdrawRemainingTokens() needs to check whether the protocolFee has been taken or not, and if it has been taken, the protocolFee does not need to be left in the contract.
+ bool public hasWithdrawFee; function withdrawRemainingTokens(address to_) public override onlyOwner { super.withdrawRemainingTokens(to_); uint unclaimedTokens = (receiptRedeemers() - redeemedTokens) * rewardAmountInWeiOrTokenId; + uint remainingProtocolFee = 0; + if (!hasWithdrawFee) remainingProtocolFee = protocolFee(); + uint256 nonClaimableTokens = IERC20(rewardToken).balanceOf(address(this)) - remainingProtocolFee - unclaimedTokens; - uint256 nonClaimableTokens = IERC20(rewardToken).balanceOf(address(this)) - protocolFee() - unclaimedTokens; IERC20(rewardToken).safeTransfer(to_, nonClaimableTokens); } function withdrawFee() public onlyAdminWithdrawAfterEnd { + require(!hasWithdrawFee,"Repeated withdraw"); //***@aduit Another problem IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); + hasWithdrawFee = true; }
#0 - c4-judge
2023-02-05T06:13:13Z
kirk-baird marked the issue as duplicate of #42
#1 - c4-judge
2023-02-06T08:26:11Z
kirk-baird marked the issue as not a duplicate
#2 - c4-judge
2023-02-06T08:26:18Z
kirk-baird marked the issue as duplicate of #61
#3 - c4-judge
2023-02-14T10:00:48Z
kirk-baird marked the issue as satisfactory
#4 - c4-judge
2023-02-23T23:48:11Z
kirk-baird changed the severity to 2 (Med Risk)
🌟 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
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/QuestFactory.sol#L211-L212 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/QuestFactory.sol#L219-L229
If claimSignerAddress is the same address, the signature can be reused in other chains to steal the token
mintReceipt() is used to generate Receipt, which is a very important function for the protocol, and the method internally verifies the legitimacy of the msg.sender signature The signature is verified as follows:
function mintReceipt(string memory questId_, bytes32 hash_, bytes memory signature_) public { .... //****@audit hash = keccak256(abi.encodePacked(msg.sender, questId_)) if (keccak256(abi.encodePacked(msg.sender, questId_)) != hash_) revert InvalidHash(); function recoverSigner(bytes32 hash_, bytes memory signature_) public pure returns (address) { //****@audit only add \x19Ethereum Signed Message:\ bytes32 messageDigest = keccak256(abi.encodePacked('\x19Ethereum Signed Message:\n32', hash_)); return ECDSAUpgradeable.recover(messageDigest, signature_); }
The above implementation does not add the chainId/address(this) to the signature, so the claimSignerAddress signature can be reused in multiple chains If claimSignerAddress is the same address
use OpenZeppelin's EIP712.sol
#0 - c4-judge
2023-02-05T06:13:52Z
kirk-baird marked the issue as duplicate of #45
#1 - c4-judge
2023-02-14T09:36:26Z
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
1.Erc1155Quest.getRewardAmount() Return error, the current implementation, each Receipt gets 1 Erc1155 It is recommended to override Quest.getRewardAmount() to return 1
contract Erc1155Quest is Quest, ERC1155Holder { ... + function getRewardAmount() public override view returns (uint256) { + return 1; + }
2.claim() does not follow the "Checks Effects Interactions" principle, resulting in external acquisition of redeemedTokens that may be inaccurate
function claim() public virtual onlyQuestActive { ... uint256 totalRedeemableRewards = _calculateRewards(redeemableTokenCount); _setClaimed(tokens); + redeemedTokens += redeemableTokenCount; _transferRewards(totalRedeemableRewards); - redeemedTokens += redeemableTokenCount;
3.modifier "onlyAdminWithdrawAfterEnd" Naming problems, this does not limit must be admin suggested to modify the method named :onlyWithdrawAfterEnd
#0 - kirk-baird
2023-02-05T06:16:25Z
Issues 2 and 3 are valid high issues but the warden has not provided sufficient detail for it to be duplicated with the other issues
#1 - c4-judge
2023-02-05T06:16:31Z
kirk-baird marked the issue as grade-b
#2 - c4-judge
2023-02-05T06:16:36Z
kirk-baird marked the issue as grade-c
#3 - kirk-baird
2023-02-16T06:52:07Z
Upgrading to grade-b due to #386
#4 - c4-judge
2023-02-16T06:52:11Z
kirk-baird marked the issue as grade-b