Reserve Protocol - Invitational - ronnyx2017's results

A permissionless platform to launch and govern asset-backed stable currencies.

General Information

Platform: Code4rena

Start Date: 15/06/2023

Pot Size: $79,800 USDC

Total HM: 14

Participants: 6

Period: 14 days

Judge: 0xean

Total Solo HM: 11

Id: 248

League: ETH

Reserve

Findings Distribution

Researcher Performance

Rank: 6/6

Findings: 5

Award: $0.00

QA:
grade-a

🌟 Selected for report: 5

🚀 Solo Findings: 3

Findings Information

🌟 Selected for report: ronnyx2017

Labels

bug
2 (Med Risk)
satisfactory
selected for report
sponsor disputed
rainout
M-05

Awards

Data not available

External Links

Lines of code

https://github.com/reserve-protocol/protocol/blob/c4ec2473bbcb4831d62af55d275368e73e16b984/contracts/p1/RevenueTrader.sol#L83-L104

Vulnerability details

There is a dev comment in the Assert.sol:

DEPRECATED: claimRewards() will be removed from all assets and collateral plugins

The claimRewards is moved to the TradingP1.claimRewards/claimRewardsSingle.

But when the RevenueTraderP1 trade and distribute revenues by manageToken, it only calls the refresh function of the asserts:

if (erc20 != IERC20(address(rToken)) && tokenToBuy != IERC20(address(rToken))) { IAsset sell_ = assetRegistry.toAsset(erc20); IAsset buy_ = assetRegistry.toAsset(tokenToBuy); if (sell_.lastSave() != uint48(block.timestamp)) sell_.refresh(); if (buy_.lastSave() != uint48(block.timestamp)) buy_.refresh(); }

The claimRewards is left out.

Impact

Loss a part of rewards.

Tools Used

Manual review

Add claimRewardsSingle when refresh assert in the manageToken.

Assessed type

Context

#0 - tbrent

2023-06-12T20:10:30Z

This is similar to an (unmitigated) issue from an earlier contest: https://github.com/code-423n4/2023-02-reserve-mitigation-contest-findings/issues/22

However in this case it has to do with RevenueTraderP1.manageToken(), as opposed to BackingManagerP1.manageTokens().

I think that difference matters, because the loss of the rewards for this auction does not have serious long-term consequences. This is not like the BackingManager where it's important that all capital always be available else an unnecessarily large haircut could occur. Instead, the worst that can happen is for the revenue auction to complete at high slippage, and for a second reward token revenue auction to complete afterwards at high slippage yet again, when it could have been a single revenue auction with less slippage.

#1 - tbrent

2023-06-12T20:11:40Z

The recommended mitigation would not succeed, because recall, we may be selling token X but any number of additional assets could have token X as a reward token. We would need to call claimRewards(), which is simply too gas-costly to do everytime for revenue auctions.

#2 - c4-sponsor

2023-07-04T23:03:49Z

tbrent marked the issue as sponsor disputed

#3 - 0xean

2023-07-12T19:44:49Z

Instead, the worst that can happen is for the revenue auction to complete at high slippage, and for a second reward token revenue auction to complete afterwards at high slippage yet again, when it could have been a single revenue auction with less slippage.

The impact sounds like a "leak of value" and therefore I think M is the correct severity per the c4 docs. (cc @tbrent - open to additional comment here)

#4 - c4-judge

2023-07-15T13:05:01Z

0xean marked the issue as satisfactory

Findings Information

🌟 Selected for report: ronnyx2017

Labels

bug
2 (Med Risk)
satisfactory
selected for report
sponsor confirmed
rainout
M-06

Awards

Data not available

External Links

Lines of code

https://github.com/reserve-protocol/protocol/blob/c4ec2473bbcb4831d62af55d275368e73e16b984/contracts/p1/mixins/RecollateralizationLib.sol#L394-L413

Vulnerability details

When creating the trade for rebalance, the RecollateralizationLibP1.nextTradePair uses (uint192 low, uint192 high) = rsrAsset.price(); // {UoA/tok} to get the rsr sell price. And the rsr assert is a pure Assert contract, which price() function will just return (0, FIX_MAX) if oracle is timeout:

function price() public view virtual returns (uint192, uint192) { try this.tryPrice() returns (uint192 low, uint192 high, uint192) { assert(low <= high); return (low, high); } catch (bytes memory errData) { ... return (0, FIX_MAX); } }

The trade.sellAmount will be all the rsr in the BackingManager and stRSR:

uint192 rsrAvailable = rsrAsset.bal(address(ctx.bm)).plus( rsrAsset.bal(address(ctx.stRSR)) ); trade.sellAmount = rsrAvailable;

It will be cut down to a normal amount fit for buying UoA amount in the trade.prepareTradeToCoverDeficit function.

But if the rsr oracle is timeout and returns a 0 low price. The trade req will be made by trade.prepareTradeSell, which will sell all the available rsr at 0 price.

Note that the SOUND colls won't be affected by the issue because the sell amount has already been cut down by basketsNeeded.

Impact

Loss huge amount of rsr in the auction. When huge amounts of assets are auctioned off at zero, panic and insufficient liquidity make the outcome unpredictable.

Proof of Concept

POC git diff test/Recollateralization.test.ts

diff --git a/test/Recollateralization.test.ts b/test/Recollateralization.test.ts
index 86cd3e88..15639916 100644
--- a/test/Recollateralization.test.ts
+++ b/test/Recollateralization.test.ts
@@ -51,7 +51,7 @@ import {
 import snapshotGasCost from './utils/snapshotGasCost'
 import { expectTrade, getTrade, dutchBuyAmount } from './utils/trades'
 import { withinQuad } from './utils/matchers'
-import { expectRTokenPrice, setOraclePrice } from './utils/oracles'
+import { expectRTokenPrice, setInvalidOracleTimestamp, setOraclePrice } from './utils/oracles'
 import { useEnv } from '#/utils/env'
 import { mintCollaterals } from './utils/tokens'
 
@@ -797,6 +797,166 @@ describe(`Recollateralization - P${IMPLEMENTATION}`, () => {
   })
 
   describe('Recollateralization', function () {
+    context('With simple Basket - Two stablecoins', function () {
+      let issueAmount: BigNumber
+      let stakeAmount: BigNumber
+
+      beforeEach(async function () {
+        // Issue some RTokens to user
+        issueAmount = bn('100e18')
+        stakeAmount = bn('10000e18')
+
+        // Setup new basket with token0 & token1
+        await basketHandler.connect(owner).setPrimeBasket([token0.address, token1.address], [fp('0.5'), fp('0.5')])
+        await basketHandler.connect(owner).refreshBasket()
+
+        // Provide approvals
+        await token0.connect(addr1).approve(rToken.address, initialBal)
+        await token1.connect(addr1).approve(rToken.address, initialBal)
+
+        // Issue rTokens
+        await rToken.connect(addr1).issue(issueAmount)
+
+        // Stake some RSR
+        await rsr.connect(owner).mint(addr1.address, initialBal)
+        await rsr.connect(addr1).approve(stRSR.address, stakeAmount)
+        await stRSR.connect(addr1).stake(stakeAmount)
+      })
+      
+      it('C4M7', async () => {
+        // Register Collateral
+        await assetRegistry.connect(owner).register(backupCollateral1.address)
+  
+        // Set backup configuration - USDT as backup
+        await basketHandler
+          .connect(owner)
+          .setBackupConfig(ethers.utils.formatBytes32String('USD'), bn(1), [backupToken1.address])
+        
+        // Set Token0 to default - 50% price reduction
+        await setOraclePrice(collateral0.address, bn('0.5e8'))
+  
+        // Mark default as probable
+        await assetRegistry.refresh()
+        // Advance time post collateral's default delay
+        await advanceTime((await collateral0.delayUntilDefault()).toString())
+  
+        // Confirm default and trigger basket switch
+        await basketHandler.refreshBasket()
+  
+        // Advance time post warmup period - SOUND just regained
+        await advanceTime(Number(config.warmupPeriod) + 1)
+  
+        const initToken1B = await token1.balanceOf(backingManager.address);
+        // rebalance
+        const token1Decimal = 6;
+        const sellAmt: BigNumber = await token0.balanceOf(backingManager.address)
+        const buyAmt: BigNumber = sellAmt.div(2)
+        await facadeTest.runAuctionsForAllTraders(rToken.address);
+        // bid
+        await backupToken1.connect(addr1).approve(gnosis.address, sellAmt)
+        await gnosis.placeBid(0, {
+          bidder: addr1.address,
+          sellAmount: sellAmt,
+          buyAmount: buyAmt,
+        })
+        await advanceTime(config.batchAuctionLength.add(100).toString())
+        // await facadeTest.runAuctionsForAllTraders(rToken.address);
+        const rsrAssert = await assetRegistry.callStatic.toAsset(rsr.address);
+        await setInvalidOracleTimestamp(rsrAssert);
+        await expectEvents(facadeTest.runAuctionsForAllTraders(rToken.address), [
+          {
+            contract: backingManager,
+            name: 'TradeSettled',
+            args: [anyValue, token0.address, backupToken1.address, sellAmt, buyAmt],
+            emitted: true,
+          },
+          {
+            contract: backingManager,
+            name: 'TradeStarted',
+            args: [anyValue, rsr.address, backupToken1.address, stakeAmount, anyValue], // sell 25762677277828792981
+            emitted: true,
+          },
+        ])
+  
+        // check
+        console.log(await token0.balanceOf(backingManager.address));
+        const currentToken1B = await token1.balanceOf(backingManager.address);
+        console.log(currentToken1B);
+        console.log(await backupToken1.balanceOf(backingManager.address));
+        const rsrB = await rsr.balanceOf(stRSR.address);
+        console.log(rsrB);
+  
+        // expect
+        expect(rsrB).to.eq(0);
+      })
+    })
+
     context('With very simple Basket - Single stablecoin', function () {
       let issueAmount: BigNumber
       let stakeAmount: BigNumber

run test:

PROTO_IMPL=1 npx hardhat test --grep 'C4M7' test/Recollateralization.test.ts

log:

Recollateralization - P1 Recollateralization With simple Basket - Two stablecoins BigNumber { value: "0" } BigNumber { value: "50000000" } BigNumber { value: "25000000000000000000" } BigNumber { value: "0" }

Tools Used

Manual review

Using lotPrice or just revert for rsr oracle timeout might be a good idea.

Assessed type

Context

#0 - tbrent

2023-06-12T20:32:13Z

Hmm, interesting case.

There are two types of auctions that can occur: batch auctions via GnosisTrade, and dutch auctions via DutchTrade.

Batch auctions via GnosisTrade are good at discovering prices when price is unknown. It would require self-interested parties to be offline for the entire duration of the batch auction (default: 15 minutes) in order for someone to get away with buying the RSR for close to 0.

Dutch auctions via DutchTrade do not have this problem because of an assert that reverts at the top of the contract.

I'm inclined to dispute validity, but I also agree it might be strictly better to use the lotPrice(). When trading out backing collateral it is important to sell it quickly and not have to wait for lotPrice() to decay sufficiently, but this is not true with RSR. For RSR it might be fine to wait as long as a week for the lotPrice() to fall to near 0.

This would then allow dutch auctions via DutchTrade to be used when RSR's oracle is offline.

#1 - c4-sponsor

2023-07-04T23:04:14Z

tbrent marked the issue as sponsor confirmed

#2 - c4-judge

2023-07-12T19:45:36Z

0xean marked the issue as satisfactory

Findings Information

🌟 Selected for report: ronnyx2017

Labels

bug
2 (Med Risk)
satisfactory
selected for report
sponsor confirmed
rainout
M-07

Awards

Data not available

External Links

Lines of code

https://github.com/reserve-protocol/protocol/blob/c4ec2473bbcb4831d62af55d275368e73e16b984/contracts/p1/RevenueTrader.sol#L100-L104

Vulnerability details

Impact

The reward rToken sent to RevenueTrader will be sold at a low price. RSR stakers will lose some of their profits.

Proof of Concept

RevenueTraderP1.manageToken function is used to launch auctions for any erc20 tokens sent to it. For the RevenueTrader of the rsr stake, the tokenToBuy is rsr and the token to sell is reward rtoken.

There is the refresh code in the manageToken function:

} else if (assetRegistry.lastRefresh() != uint48(block.timestamp)) { // Refresh everything only if RToken is being traded assetRegistry.refresh(); furnace.melt(); }

It refreshes only when the assetRegistry has not been refreshed in the same block.

So if the actor calls the assetRegistry.refresh() before calling manageToken function, the furnace.melt() won't been called. And the BU exchange rate of the RToken will be lower than actual value. So the sellPrice is also going to be smaller.

(uint192 sellPrice, ) = sell.price(); // {UoA/tok} TradeInfo memory trade = TradeInfo({ sell: sell, buy: buy, sellAmount: sell.bal(address(this)), buyAmount: 0, sellPrice: sellPrice, buyPrice: buyPrice });

Tools Used

Manual review

Refresh everything before sell rewards.

Assessed type

Context

#0 - c4-sponsor

2023-07-04T23:11:04Z

tbrent marked the issue as sponsor confirmed

#1 - c4-judge

2023-07-12T19:46:35Z

0xean marked the issue as satisfactory

Findings Information

🌟 Selected for report: ronnyx2017

Also found by: 0xA5DF, rvierdiiev

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
rainout
M-08

Awards

Data not available

External Links

Lines of code

https://github.com/reserve-protocol/protocol/blob/c4ec2473bbcb4831d62af55d275368e73e16b984/contracts/p1/StRSR.sol#L222-L232

Vulnerability details

If the system is frozen, the only allowed operation is stRST.stake. And the _payoutRewards is not called during freeze period:

if (!main.frozen()) _payoutRewards(); function payoutRewards() external { requireNotFrozen(); _payoutRewards(); }

So the payoutLastPaid stays before the freeze period. But when the system is unfreezed, accumulated rewards will be released all at once because the block.timestamp leapt the whole freeze period.

Impact

A front runner can stake huge proportion rsr before admin unfreezes the system. And the attacker can get most of rsr rewards in the next block. And he only takes the risk of the unstakingDelay period.

Proof of Concept

Assumption: there are 2000 rsr stake in the stRSR, and there are 1000 rsr rewards in the rsrRewardsAtLastPayout with a 1 year half-life period.

And at present, the LONG_FREEZER freezeLong system for 1 year(default).

After 1 year, at the unfreeze point, a front runner stake 2000 rsr into stRSR. And then the system is unfreeze. And in the next blcok,the front runner unstakes all the stRSR he has for 2250 rsr = 2000 principal + 1000 / 2 / 2 rsr rewards.

The only risk he took is unstakingDelay. The original rsr stakers took the risk of the whole freeze period + unstakingDelay but only got a part of rewards back.

Tools Used

Manual review

payoutRewards before freeze and update payoutLastPaid before unfreeze.

Assessed type

Access Control

#0 - c4-judge

2023-06-12T12:54:42Z

0xean marked the issue as duplicate of #24

#1 - c4-judge

2023-07-12T20:04:55Z

0xean marked the issue as satisfactory

#2 - c4-judge

2023-07-15T13:06:49Z

0xean marked the issue as selected for report

Findings Information

🌟 Selected for report: ronnyx2017

Also found by: 0xA5DF, rvierdiiev

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
rainout
M-09

Awards

Data not available

External Links

Lines of code

https://github.com/reserve-protocol/protocol/blob/c4ec2473bbcb4831d62af55d275368e73e16b984/contracts/p1/StRSR.sol#L341-L380

Vulnerability details

cancelUnstake will cancel the withdrawal request in the queue can mint shares as the current stakeRate. But it doesn't payoutRewards before mintStakes. Therefor it will mint stRsr as a lower rate, which means it will get more rsr.

Impact

Withdrawers in the unstake queue can cancelUnstake without calling payoutRewards to get more rsr rewards that should not belong to them.

Proof of Concept

POC test/ZZStRSR.test.ts git patch

diff --git a/test/ZZStRSR.test.ts b/test/ZZStRSR.test.ts
index ecc31f68..b2809129 100644
--- a/test/ZZStRSR.test.ts
+++ b/test/ZZStRSR.test.ts
@@ -1333,6 +1333,46 @@ describe(`StRSRP${IMPLEMENTATION} contract`, () => {
       expect(await stRSR.exchangeRate()).to.be.gt(initialRate)
     })
 
+    it('cancelUnstake', async () => {
+      const amount: BigNumber = bn('10e18')
+
+      // Stake
+      await rsr.connect(addr1).approve(stRSR.address, amount)
+      await stRSR.connect(addr1).stake(amount)
+      await rsr.connect(addr2).approve(stRSR.address, amount)
+      await stRSR.connect(addr2).stake(amount)
+      await rsr.connect(addr3).approve(stRSR.address, amount)
+      await stRSR.connect(addr3).stake(amount)
+
+      const  initExchangeRate = await stRSR.exchangeRate();
+      console.log(initExchangeRate);
+
+      // Unstake addr2 & addr3 at same time (Although in different blocks, but timestamp only 1s)
+      await stRSR.connect(addr2).unstake(amount)
+      await stRSR.connect(addr3).unstake(amount)
+
+      // skip 1000 block PERIOD / 12000s
+      await setNextBlockTimestamp(Number(ONE_PERIOD.mul(1000).add(await getLatestBlockTimestamp())))
+
+      // Let's cancel the unstake in normal
+      await expect(stRSR.connect(addr2).cancelUnstake(1)).to.emit(stRSR, 'UnstakingCancelled')
+      let exchangeRate = await stRSR.exchangeRate();
+      expect(exchangeRate).to.equal(initExchangeRate)
+      
+      // addr3 cancelUnstake after payoutRewards
+      await stRSR.payoutRewards()
+      await expect(stRSR.connect(addr3).cancelUnstake(1)).to.emit(stRSR, 'UnstakingCancelled')
+
+      // Check balances addr2 & addr3
+      exchangeRate = await stRSR.exchangeRate();
+      expect(exchangeRate).to.be.gt(initExchangeRate)
+      const addr2NowAmount = exchangeRate.mul(await stRSR.balanceOf(addr2.address)).div(bn('1e18'));
+      console.log("addr2", addr2NowAmount.toString());
+      const addr3NowAmount = exchangeRate.mul(await stRSR.balanceOf(addr3.address)).div(bn('1e18'));
+      console.log("addr3",addr3NowAmount.toString());
+      expect(addr2NowAmount).to.gt(addr3NowAmount)
+    })
+
     it('Rewards should not be handed out when paused but staking should still work', async () => {
       await main.connect(owner).pauseTrading()
       await setNextBlockTimestamp(Number(ONE_PERIOD.add(await getLatestBlockTimestamp())))

The test simulates two users unstake and cancelUnstake operations at the same time.But the addr2 calls payoutRewards after his cancelUnstake. And addr3 calls cancelUnstake after payoutRewards. Addr2 gets more rsr than addr3 in the end.

run test:

PROTO_IMPL=1 npx hardhat test --grep cancelUnstake test/ZZStRSR.test.ts

log:

StRSRP1 contract Add RSR / Rewards BigNumber { value: "1000000000000000000" } addr2 10005345501258588240 addr3 10000000000000000013

Tools Used

Manual review

Call _payoutRewards before mint shares.

Assessed type

Math

#0 - c4-judge

2023-06-11T20:50:27Z

0xean marked the issue as primary issue

#1 - tbrent

2023-06-13T19:40:30Z

Agree with severity and proposed mitigation.

#2 - c4-sponsor

2023-07-04T23:12:43Z

tbrent marked the issue as sponsor confirmed

#3 - c4-judge

2023-07-12T19:48:24Z

0xean marked the issue as satisfactory

#4 - c4-judge

2023-07-15T13:07:18Z

0xean marked the issue as selected for report

Findings Information

🌟 Selected for report: 0xA5DF

Also found by: RaymondFam, carlitox477, hihen, ronnyx2017, rvierdiiev

Labels

bug
grade-a
QA (Quality Assurance)
rainout
Q-06

Awards

Data not available

External Links

LOW

code lines: https://github.com/reserve-protocol/protocol/blob/c4ec2473bbcb4831d62af55d275368e73e16b984/contracts/p1/BackingManager.sol#L66-L71

BackingManagerP1.grantRTokenAllowance should be limited by notTradingPausedOrFrozen instead of notFrozen. Because the asserts approval for RToken are used to redeem, which is disabled when TradingPaused.

code lines: https://github.com/reserve-protocol/protocol/blob/c4ec2473bbcb4831d62af55d275368e73e16b984/contracts/plugins/trading/DutchTrade.sol#L202-L204

https://github.com/reserve-protocol/protocol/blob/c4ec2473bbcb4831d62af55d275368e73e16b984/contracts/plugins/trading/DutchTrade.sol#L179-L181

Time check is inconsistent in DutchTrade CanSettle:

|| block.timestamp > endTime);

But in settle():

require(block.timestamp >= endTime,

QA

  1. UI display error in https://register.app/#/settings?token=0xA0d69E286B938e21CBf7E51D71F6A4c8918f482F

Reward ratio is 0.0000032090147 = 0.00032090147% ,instead of 0.0000032090147% .

#0 - 0xean

2023-07-12T20:04:25Z

Awarding as grade A due to wardens other (downgraded) findings that are included as part of this

#1 - c4-judge

2023-07-12T20:04:30Z

0xean marked the issue as grade-a

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter