Open c4-bot-3 opened 3 months ago
This is a plausible issue. We would like to request a code PoC from the warden, since it is about a very specific numeric case.
Since the initial judging time is not enough to verify it, consider it valid first and keep open, and wait for the POC from warden. And may invalidate it without further proof.
thereksfour marked the issue as satisfactory
thereksfour marked the issue as selected for report
Just noting that we currently think this is not possible but might be plausible in super specific circumstances which is why we're requesting the PoC.
Hi @akshatmittal @tbrent
The PoC in the issue is wrong, however the issue exists, below I attach the updated PoC with a test proving the issue. Under those conditions seizeRSR
will be dosed due to a broken protocol invariant.
Let's assume the system becomes unhealthy, and the stakers will start unstaking their tokens in fear of an upcoming reset/seize. Then the following chain of action happens:
_payoutRewards
updates stakeRSR
5e17
- this can happen natuarally or forcefully (in our scenario everybody unstaked so we can influence it in some way)BackingManager
tries to seize RSR from the staking contract, but this transaction is frontran with another transaction in which
What will happen is that
unstake
call:
_payoutRewards
is called. Since totalStakes
is still above 1e18
, stakeRSR
can be increased and become greater than totalStakes
. stakeRate
. If this becomes less than 5e17
, the problem arisesstake
call:
payoutRewards
is skipped because it was already called in this blocknewTotalStakes
in mintStakes
is calculated as (stakeRate * newStakeRSR) / FIX_ONE
, which translates to (stakeRate * (stakeRSR + rsrAmount)) / FIX_ONE
which translates to (499999999999999999 * (0 + 2)) / 1e18
resulting in 0, meaning 0 tokens are minted and totalStakes
remains 0stakeRSR
is increased by 2499999999999999999
is the greatest possible value of stakeRate
when the issue arisestotalStakes
is 0, and stakeRSR
is 2 (note that this breaks this assumption)seizeRSR
call
stakeRSRToTake
is set to (2 * rsrAmount + (rsrBalance - 1)) / rsrBalance
, which will result in 1 every time the we are not seizing more than half of the total balance of the contractstakeRSR
is therefore set to 1, and since it is non-zero, stakeRate
is updated to uint192((FIX_ONE_256 * totalStakes + (stakeRSR - 1)) / stakeRSR)
, which translates to uint192((1e18 * 0 + (1 - 1)) / 1)
which comes to 0emit ExchangeRateSet(initRate, exchangeRate());
return (FIX_SCALE_SQ + (stakeRate / 2)) / stakeRate;
stakeRate
was updated to 0 before, the call will revert hereThe following test showcases the issue with different numbers:
it.only("Unable to seize RSR", async () => {
const stakeAmt: BigNumber = bn("5000e18");
const unstakeAmt: BigNumber = bn("4999e18");
const one: BigNumber = bn("1e18");
const rewards: BigNumber = bn("100e18");
const seizeAmount: BigNumber = bn("2499e18");
// 1. Stake
await rsr.connect(addr1).approve(stRSR.address, stakeAmt);
await stRSR.connect(addr1).stake(stakeAmt);
// 2. Decrease stakeRate to ~5e17 - 1
await rsr.connect(owner).mint(stRSR.address, rewards);
await advanceToTimestamp((await getLatestBlockTimestamp()) + 1);
await stRSR.payoutRewards();
await advanceToTimestamp((await getLatestBlockTimestamp()) + 86400);
await stRSR.payoutRewards();
// 3. Unstake everything but 1e18
await stRSR.connect(addr1).unstake(unstakeAmt);
await advanceToTimestamp((await getLatestBlockTimestamp()) + 172800);
// 4. Unstake the last token then stake 2 wei
// everything must happen in 1 tx thats why we deploy `SeizeAttacker`
const SeizeAttackerFactory = await ethers.getContractFactory("SeizeAttacker");
let seizeAttacker = await SeizeAttackerFactory.deploy();
// transfer stRSR to the seize attacker so it can unstake
await stRSR.connect(addr1).transfer(seizeAttacker.address, one);
await rsr.connect(owner).mint(seizeAttacker.address, 2);
await seizeAttacker.doIt(stRSR.address, rsr.address, one);
// 5. seize rsr fails
await setStorageAt(stRSR.address, 256, addr1.address); // set addr1 as backing manager so we can call seize rsr easily
await stRSR.connect(addr1).seizeRSR(seizeAmount);
});
We will see that the test will fail with the following output
Error: VM Exception while processing transaction: reverted with panic code 0x12 (Division or modulo division by zero)
Hey @coreggon11, thanks for that. Can you please share the code for SeizeAttacker
contract and replace the last call with impersonating BackingManager
instead? (So I'm able to reproduce this)
@akshatmittal mb, forgot about that
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
interface IStRSR {
function stake(uint256 rsrAmount) external;
function unstake(uint256 stakeAmount) external;
}
interface RSR {
function approve(address who, uint256 amount) external;
}
contract SeizeAttacker {
function doIt(address stRSR,address rsr, uint amount) external {
// unstake
IStRSR(stRSR).unstake(amount);
// approve
RSR(rsr).approve(stRSR, 2);
// stake 1
IStRSR(stRSR).stake(2);
}
}
In the test we set addr1
as backing manager so addr1
can call the method, the test should work)
Accepted.
Specifically, want to point out that following must be true:
stakeRate
is < 5e17 - 1
minTradeVolume
worth of RSR, or total drafts > minTradeVolume
. (Acceptable Values)Hey @thereksfour; thank so much for your time.
Since to this issue be possible attacker have to as the warden explain:
This is what im talking about in my report #79 validation repo that why i consider my report is duplicate of this or at least partial 50.
@akshatmittal the volume can be cumulated with other stakers who already unstaked) The main problem here is that we are breaking a protocol variant, as shown in the poc, hence the code behaves unexpectedly.
Lines of code
https://github.com/code-423n4/2024-07-reserve/blob/main/contracts/p1/StRSR.sol#L424
Vulnerability details
Description
The
seizeRSR
function takes RSR from the staking contract whenBackingManager
wants to sell RSR, but it does not have enough. In this case, the stakers can lose a portion of their stake in order to keep the system healthy. However, an issue arises from broken assumptions aboutstakeRSR
andtotalStakes
, which will make the contract unable to seize due to revert.Proof of concept
Let's assume the system becomes unhealthy, and the stakers will start unstaking their tokens in fear of an upcoming reset/seize. Then, the expected event comes, and the
BackingManager
tries to seize RSR from the staking contract.This action is frontran however, with the last staker who:
Let's examine how does this break
seizeRSR
.unstake
call:_payoutRewards
is called. SincetotalStakes
is still above1e18
,stakeRSR
can be increased and become greater thantotalStakes
. Let's assume thattotalStakes
would be1000e18
andstakeRSR
would end at1001e18
after this step.stakeRate
would be updated. SincestakeRSR
is not zero yet, and so is nottotalStakes
, it would be set towhich translates to
resulting in
999000999000999001
totalStakes
would be decreased in_burn
by1000e18
to zero, which would result instakeRSR
being set to zerostake
call:payoutRewards
is skipped because it was already called in this blocknewTotalStakes
inmintStakes
is calculated as(stakeRate * newStakeRSR) / FIX_ONE
, which translates to(stakeRate * (stakeRSR + rsrAmount)) / FIX_ONE
which translates to(999000999000999001 * (0 + 1)) / 1e18
resulting in 0, meaning 0 tokens are minted andtotalStakes
remains 0stakeRSR
is increased by 1stake
call will have the same effectsstakeRate
is999000999000999001
,totalStakes
is 0, andstakeRSR
is 2 (note that this breaks this assumption)seizeRSR
callstakeRSRToTake
is set to(2 * rsrAmount + (rsrBalance - 1)) / rsrBalance
, which will result in 1 every time the we are not seizing more than half of the total balance of the contractstakeRSR
is therefore set to 1, and since it is non-zero,stakeRate
is updated touint192((FIX_ONE_256 * totalStakes + (stakeRSR - 1)) / stakeRSR)
, which translates touint192((1e18 * 0 + (1 - 1)) / 1)
which comes to 0stakeRate
was updated to 0 before, the call will revert hereImpact and likelihood
The likelihood of this issue is LOW. The impact seems somewhere between HIGH and MEDIUM since a necessary rebalance action can be DoSed until it is noticed and an action is taken. Considering that the issue is possible due to a broken invariant, the severity should be judged as MEDIUM.
Recommendation
One way to fix the issue would be to enforce the invariant
if totalStakes == 0, then stakeRSR == 0
. This could be done by assuring thatamount
is not 0 in_mint
.Another mitigation could be to update the
seizeRSR
function to update thestakeRate
only if bothstakeRSR
andtotalStakes
are non-zero or update thestakeRate
toFIX_ONE
if either of these two is zero.Assessed type
DoS