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: 56/173
Findings: 3
Award: $44.45
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
When users claim from any Quest contract, it will call to function getOwnedTokenIdsOfQuest()
of RabbitHoleReceipt contract to get all token ids for that quest owned by caller. Function getOwnedTokenIdsOfQuest()
will loop through all receipt owned by caller and filter token for that quest
function getOwnedTokenIdsOfQuest( string memory questId_, address claimingAddress_ ) public view returns (uint[] memory) { uint msgSenderBalance = balanceOf(claimingAddress_); uint[] memory tokenIdsForQuest = new uint[](msgSenderBalance); uint foundTokens = 0; // @audit unbounded loop for (uint i = 0; i < msgSenderBalance; i++) { uint tokenId = tokenOfOwnerByIndex(claimingAddress_, i); if (keccak256(bytes(questIdForTokenId[tokenId])) == keccak256(bytes(questId_))) { tokenIdsForQuest[i] = tokenId; foundTokens++; } } uint[] memory filteredTokens = new uint[](foundTokens); uint filterTokensIndexTracker = 0; for (uint i = 0; i < msgSenderBalance; i++) { if (tokenIdsForQuest[i] > 0) { filteredTokens[filterTokensIndexTracker] = tokenIdsForQuest[i]; filterTokensIndexTracker++; } } return filteredTokens; }
For each quest, normally each user can only mint 1 receipt, therefore only have 1 token per quest, but users can purchase more receipt in open market and have more than 2 tokens per quest. In addition, RabbitHoleReceipt contract hold receipts for all quests.
So if attacker somehow can create a quest where value of each receipt is neglectible or zero, he can transferred all these spam receipts to victim, making the claim call of victim more expensive in term of gas cost. Technically, it is even possible to DOS victim claim when the gas cost is break the block gas limit.
Function getOwnedTokenIdsOfQuest()
is used in Quest.claim()
function
function claim() public virtual onlyQuestActive { if (isPaused) revert QuestPaused(); uint[] memory tokens = rabbitHoleReceiptContract.getOwnedTokenIdsOfQuest(questId, msg.sender); if (tokens.length == 0) revert NoTokensToClaim(); ... }
Manual Review
Consider using a data structure that can query all token for a quest quickly. For example, a mapping of owner to questId to array.
#0 - c4-judge
2023-02-06T22:35:23Z
kirk-baird marked the issue as duplicate of #135
#1 - c4-judge
2023-02-15T21:53:51Z
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
There are 2 type of Quest, Erc20Quest and Erc1155Quest. Both contracts have common property that they should hold enough reward token for totalParticipants
. However, if number of receipt minted is less than totalParticipants
, owner is allowed to withdraw remaining token to an address he specified. This check is implemented correctly in Erc20Quest
where owner can only withdraw nonClaimableTokens
.
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); }
However, it did not have the same check in Erc1155Quest
.
function withdrawRemainingTokens(address to_) public override onlyOwner { // @audit owner should not be able to withdraw all tokens before users claimed super.withdrawRemainingTokens(to_); IERC1155(rewardToken).safeTransferFrom( address(this), to_, rewardAmountInWeiOrTokenId, IERC1155(rewardToken).balanceOf(address(this), rewardAmountInWeiOrTokenId), '0x00' ); }
As the result, all tokens in Erc1155Quest can be withdraw before any user claim their receipts.
Please add this test to Erc1155Quest.spec.ts
it.only('revert claiming when owner withdraw all tokens', async () => { await deployedRabbitholeReceiptContract.mint(owner.address, questId) await deployedQuestContract.start() await ethers.provider.send('evm_increaseTime', [86400]) expect(await deployedSampleErc1155Contract.balanceOf(owner.address, rewardAmount)).to.equal(0) const totalTokens = await deployedRabbitholeReceiptContract.getOwnedTokenIdsOfQuest(questId, owner.address) expect(totalTokens.length).to.equal(1) expect(await deployedQuestContract.isClaimed(1)).to.equal(false) // owner withdraw await deployedQuestContract.connect(owner).withdrawRemainingTokens(owner.address) // will revert here await deployedQuestContract.claim() expect(await deployedSampleErc1155Contract.balanceOf(owner.address, rewardAmount)).to.equal(1) await ethers.provider.send('evm_increaseTime', [-86400]) })
Manual Review
Consider only allowing owner to withdraw non claimable tokens in Erc1155Quest
#0 - c4-judge
2023-02-06T22:35:36Z
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:14Z
This previously downgraded issue has been upgraded by kirk-baird
#3 - c4-judge
2023-02-14T09:27:44Z
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: 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
In QuestFactory contract, users need to provide a signature signed by claimSignerAddress
to mint a RabbitHole receipt. It has been seen that signed data is only msg.sender
and questId_
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(); // @audit signature replay on other chain quests[questId_].addressMinted[msg.sender] = true; quests[questId_].numberMinted++; emit ReceiptMinted(msg.sender, questId_); rabbitholeReceiptContract.mint(msg.sender, questId_); }
Because of lacking the domain of the contract that is going to consume the signature (chainId, contract address, version,...), the signature can be reused for another verification on another contract or different chain.
In mintReceipt()
function, hash_
is only has msg.sender
and questId_
as can be seen on the code snippet on Impact section.
In recoverSigner()
function, there is no additional info about domain is added
function recoverSigner(bytes32 hash_, bytes memory signature_) public pure returns (address) { bytes32 messageDigest = keccak256(abi.encodePacked('\x19Ethereum Signed Message:\n32', hash_)); return ECDSAUpgradeable.recover(messageDigest, signature_); }
Manual Review
Consider adding domain of contract to prevent signature being used on different chain/contract
#0 - c4-judge
2023-02-06T22:35:09Z
kirk-baird marked the issue as duplicate of #45
#1 - c4-judge
2023-02-14T09:35:41Z
kirk-baird marked the issue as satisfactory