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: 44/173
Findings: 5
Award: $69.59
π 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
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
It is possible to call the function withdrawFee()
many times. Note that even though the function has the modifier onlyAdminWithdrawAfterEnd
, actually anyone (not only admin) can call the function. So, when quest has ended and the Erc20Quest_contrcat_balance > 0 (possible if there are unclaimedTokens
left and/or the owner did not withdraw remaining tokens), attacker can withdraw fees until the balance of the contract runs out. Although the attacker will not directly benefit, the cost of the attack is relatively small, and the owner of the quest contract and users who have not yet claimed the reward can lose their assets.
For example:
protocolFee()
returns 5So, contract balance = 14.
function withdrawFee()
2 times. The contract balance becomes 4.function withdrawFee()
one more time. The contract balance becomes 0.As a result, 12 users can not claim rewards and owner can not withdraw any tokens.
Manual review.
Add the state variable bool isFeeWithdrawn
to Erc20Quest.sol and change the function:
function withdrawFee() public onlyAdminWithdrawAfterEnd { if(isFeeWithdrawn) revert FeeWithdrawn(); //+ isFeeWithdrawn = true; //+ IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); }
#0 - c4-judge
2023-02-06T09:04:47Z
kirk-baird marked the issue as duplicate of #23
#1 - c4-judge
2023-02-14T08:55:49Z
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 function mintReceipt
has no check that block.timestamp < quest endTime. So, it is possible to mint receipts after the quest has ended which can lead to loss of assets. If the owner of the quest contract withdraws the remaining tokens immediately after the end of the quest, assets can be lost by users, who did not claim rewards, or the protocol.
Several scenarios are possible:
Manual review.
Restrict the ability to mintReceipt
when block.timestamp > quest.endTime.
#0 - c4-judge
2023-02-06T09:18:23Z
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:43:33Z
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
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Quest.sol#L96-L118 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleReceipt.sol#L109-L135
If a user has many ERC-721 tokens and needs to claim a lot of quest tokens, the claim()
function can be DOSed due to out-of-gas error. User's rewards will be locked until they sell some tokens.
The function claim()
contains gas consuming loop, and also it calls two gas consuming functions: _setClaimed
(has 1 loop) and getOwnedTokenIdsOfQuest()
(has 2 loops).
Manual review.
Consider allowing users to specify what token ids they want to claim and just check that they own these tokens instead of iterating and filtering all user's tokens.
#0 - c4-judge
2023-02-06T22:31:05Z
kirk-baird marked the issue as duplicate of #135
#1 - c4-judge
2023-02-14T09:17:36Z
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
During the audit, 8 low and 7 non-critical issues were found.
β | Title | Risk Rating | Instance Count |
---|---|---|---|
L-1 | Misleading modifier | Low | 1 |
L-2 | Owner can renounce ownership | Low | 1 |
L-3 | Event should be emitted | Low | 19 |
L-4 | Quest startTime is not checked | Low | 1 |
L-5 | ECDSAUpgradeable.recover return value is not checked | Low | 2 |
L-6 | Max limit can be set | Low | 1 |
L-7 | Sign change | Low | 1 |
L-8 | Checks can be added | Low | 3 |
NC-1 | Unused named return variables | Non-Critical | 2 |
NC-2 | Order of Functions | Non-Critical | 1 |
NC-3 | Order of Layout | Non-Critical | 3 |
NC-4 | Missing leading underscores | Non-Critical | 2 |
NC-5 | Typos | Non-Critical | 1 |
NC-6 | Open TODOs | Non-Critical | 1 |
NC-7 | Maximum line length exceeded | Non-Critical | 1 |
The modifier onlyAdminWithdrawAfterEnd()
does not check that only admin can call a fucntion, actually anyone can call it.
modifier onlyAdminWithdrawAfterEnd() { if (block.timestamp < endTime) revert NoWithdrawDuringClaim(); _; }
Add a check in modifier or rename it.
Openzeppelin's Ownable.sol implements renounceOwnership()
function which leaves the contract without an owner and removes any functionality that is only available to the them.
Consider reimplementing the function to disable it.
The governor functions that change critical parameters should emit events. Events allow capturing the changed parameters so that off-chain tools/interfaces can register such changes and allow users to evaluate them and consider if they would like to engage/exit based on how they perceive the changes as affecting the trustworthiness of the protocol or profitability of the implemented financial services. The alternative of directly querying on-chain contract state for such changes is not considered practical for most users/usages.
function changeCreateQuestRole(address account_, bool canCreateQuest_) public onlyOwner {
function setClaimSignerAddress(address claimSignerAddress_) public onlyOwner {
function setProtocolFeeRecipient(address protocolFeeRecipient_) public onlyOwner {
function setRabbitHoleReceiptContract(address rabbitholeReceiptContract_) public onlyOwner {
function setRewardAllowlistAddress(address rewardAddress_, bool allowed_) public onlyOwner {
function setQuestFee(uint256 questFee_) public onlyOwner {
function setReceiptRenderer(address receiptRenderer_) public onlyOwner {
function setRoyaltyRecipient(address royaltyRecipient_) public onlyOwner {
function setQuestFactory(address questFactory_) public onlyOwner {
function mint(address to_, string memory questId_) public onlyMinter {
function start() public virtual onlyOwner {
function pause() public onlyOwner onlyStarted {
function unPause() public onlyOwner onlyStarted {
function setTicketRenderer(address ticketRenderer_) public onlyOwner {
function setRoyaltyRecipient(address royaltyRecipient_) public onlyOwner {
function mint(address to_, uint256 id_, uint256 amount_, bytes memory data_) public onlyMinter {
function mintBatch(
function start() public override {
function start() public override {
Consider adding events for these functions.
The function mintReceipt()
does not check that quest has started.
Consider adding the check.
ECDSAUpgradeable.recover may return address(0) if invoked with an invalid signature. So, it is possible to mintReceipt()
with invalid signature, if claimSignerAddress
accidentally will be set to address(0). claimSignerAddress
can be equal to address(0) if it is stay unset in initialize() or incorrectly set in setClaimSignerAddress() function.
ECDSAUpgradeable.recover return value must be checked in the function recoverSigner() to ensure that it is not zero address.
royaltyFee
is unlimited.
It is better to limit the maximum royaltyFee
to increase transparency.
It is possible that some users will participate in the quest at the last second, so the <
sign needs to be change to <=
.
Consider changing to:
if (block.timestamp <= endTime) revert NoWithdrawDuringClaim();
.
In Quest constructor it can be checked that:
uint256 totalParticipants_
> 0,endTime_ - startTime_
> minimum duration,endTime_ - startTime_
< maximum duration.Consider adding more check.
Both named return variable(s) and return statement are used.
To improve clarity use only named return variables.
For example, change:
function functionName() returns (uint id) { return x;
to
function functionName() returns (uint id) { id = x;
According to Style Guide, ordering helps readers identify which functions they can call and to find the constructor and fallback definitions easier.
Functions should be grouped according to their visibility and ordered:
function withdrawRemainingTokens(address to_) public override onlyOwner {
(public function should be placed before internal)Reorder functions where possible.
According to Order of Layout, inside each contract, library or interface, use the following order:
Place modifiers before constructor.
Internal and private state variables and functions should have a leading underscore:
function grantDefaultAdminAndCreateQuestRole(address account_) internal {
mapping(uint256 => bool) private claimedList;
Add leading underscores where needed.
Resolve issues.
According to Style Guide, maximum suggested line length is 120 characters. Longer lines make the code harder to read.
Make the lines shorter.
#0 - kirk-baird
2023-02-06T23:24:18Z
L-5 is invalid as the zero check is performed by OpenZeppelin here
#1 - c4-sponsor
2023-02-07T22:03:34Z
waynehoover marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-14T09:50:51Z
kirk-baird marked the issue as grade-b
π Selected for report: IllIllI
Also found by: 0x1f8b, 0x4non, 0xAgro, 0xSmartContract, 0xhacksmithh, 0xngndev, Aymen0909, Bnke0x0, Breeje, Deivitto, Diana, Dug, Iurii3, LethL, MiniGlome, NoamYakov, RaymondFam, ReyAdmirado, Rolezn, SAAJ, adriro, ali, arialblack14, atharvasama, c3phas, carlitox477, catellatech, chaduke, cryptonue, cryptostellar5, ddimitrov22, dharma09, doublesharp, favelanky, georgits, glcanvas, gzeon, halden, horsefacts, jasonxiale, joestakey, karanctf, lukris02, matrix_0wl, nadin, navinavu, saneryee, shark, thekmj
11.3269 USDC - $11.33
During the audit, 2 gas issues were found.
Total savings ~2415.
β | Title | Instance Count | Saved |
---|---|---|---|
G-1 | Extra sstore | 1 | 2100 |
G-2 | Use unchecked blocks for incrementing | 9 | 315 |
There is no need to make isPaused
false because it can not be true before quest start.
2100 gas
In Solidity 0.8+, thereβs a default overflow and underflow check on unsigned integers. In the loops or similar cases, "i" will not overflow because the loop will run out of gas before that or max value never be reached.
Change:
for (uint256 i; i < n; ++i) { // ... }
to:
for (uint256 i; i < n;) { // ... unchecked { ++i; } }
This saves ~30-40 gas per iteration.
So, ~35*9 = 315
#0 - c4-judge
2023-02-15T21:53:34Z
kirk-baird marked the issue as grade-b