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: 4/73
Findings: 5
Award: $11,816.64
🌟 Selected for report: 3
🚀 Solo Findings: 2
🌟 Selected for report: unforgiven
4418.1679 USDC - $4,418.17
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L160-L188 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L496-L530 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L212-L237
code calculates amount of stake token and rsr token based on stakeRate
and if stakeRate
was near 1e18
then division error is small but attacker can cause stakeRate
to be 1 and that can cause users to loss up to 1e18
token during stake and unstake.
this is init()
code:
function init( IMain main_, string calldata name_, string calldata symbol_, uint48 unstakingDelay_, uint48 rewardPeriod_, uint192 rewardRatio_ ) external initializer { require(bytes(name_).length > 0, "name empty"); require(bytes(symbol_).length > 0, "symbol empty"); __Component_init(main_); __EIP712_init(name_, "1"); name = name_; symbol = symbol_; assetRegistry = main_.assetRegistry(); backingManager = main_.backingManager(); basketHandler = main_.basketHandler(); rsr = IERC20(address(main_.rsr())); payoutLastPaid = uint48(block.timestamp); rsrRewardsAtLastPayout = main_.rsr().balanceOf(address(this)); setUnstakingDelay(unstakingDelay_); setRewardPeriod(rewardPeriod_); setRewardRatio(rewardRatio_); beginEra(); beginDraftEra(); }
As you can see it sets the value of the rsrRewardsAtLastPayout
as contract balance when contract is deployed.
This is _payoutReward()
code:
function _payoutRewards() internal { if (block.timestamp < payoutLastPaid + rewardPeriod) return; uint48 numPeriods = (uint48(block.timestamp) - payoutLastPaid) / rewardPeriod; uint192 initRate = exchangeRate(); uint256 payout; // Do an actual payout if and only if stakers exist! if (totalStakes > 0) { // Paying out the ratio r, N times, equals paying out the ratio (1 - (1-r)^N) 1 time. // Apply payout to RSR backing // payoutRatio: D18 = FIX_ONE: D18 - FixLib.powu(): D18 // Both uses of uint192(-) are fine, as it's equivalent to FixLib.sub(). uint192 payoutRatio = FIX_ONE - FixLib.powu(FIX_ONE - rewardRatio, numPeriods); // payout: {qRSR} = D18{1} * {qRSR} / D18 payout = (payoutRatio * rsrRewardsAtLastPayout) / FIX_ONE; stakeRSR += payout; } payoutLastPaid += numPeriods * rewardPeriod; rsrRewardsAtLastPayout = rsrRewards(); // stakeRate else case: D18{qStRSR/qRSR} = {qStRSR} * D18 / {qRSR} // downcast is safe: it's at most 1e38 * 1e18 = 1e56 // untestable: // the second half of the OR comparison is untestable because of the invariant: // if totalStakes == 0, then stakeRSR == 0 stakeRate = (stakeRSR == 0 || totalStakes == 0) ? FIX_ONE : uint192((totalStakes * FIX_ONE_256 + (stakeRSR - 1)) / stakeRSR); emit RewardsPaid(payout); emit ExchangeRateSet(initRate, exchangeRate()); }
As you can see it sets the value of the stakeRate
to (totalStakes * FIX_ONE_256 + (stakeRSR - 1)) / stakeRSR
.
So to exploit this attacker needs to perform this steps:
200 * 1e18
RSR tokens (18 is the precision) to the StRSR address before its deployment by watching mempool and front running. the deployment address is calculable before deployment.init()
would get executed and would set 200 * 1e18
as rsrRewardsAtLastPayout
.stake()
and stake 1 RSR token (1 wei) in the contract and the value of stakeRSR
and totalStakes
would be 1.rewardPeriod
seconds and then call payoutReward()
and code would pay rewards based on rewardRatio
and rsrRewardsAtLastPayout
and as rewardRatio
is higher than 1% (default and normal mode) code would increase stakeRate
more than 2 * 1e18
amount. and then code would set stakeRate
as totalStakes * FIX_ONE_256 + (stakeRSR - 1)) / stakeRSR = 1
.stake()
would cause users to lose up to 1e18
RSR tokens as code calculates stake amount as newTotalStakes = (stakeRate * newStakeRSR) / FIX_ONE
and rounding error happens up to FIX_ONE
. because the calculated stake amount is worth less than deposited rsr amount up to 1e18
.1e18
RSR tokens. because of the rounding error in unstake()
so attacker can manipulate the stakeRate
in contract deployment time with sandwich attack which can cause other users to lose funds because of the big rounding error.
VIM
prevent early manipulation of the PPS
#0 - c4-judge
2023-01-24T16:16:18Z
0xean marked the issue as satisfactory
#1 - c4-sponsor
2023-01-26T18:06:59Z
tbrent marked the issue as sponsor confirmed
#2 - tbrent
2023-02-07T23:12:34Z
🌟 Selected for report: unforgiven
4418.1679 USDC - $4,418.17
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L563-L573 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L801-L814 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L219 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/Furnace.sol#L70-L84
Function melt()
melt a quantity of RToken from the caller's account, increasing the basket rate. basket rate should be between 1e9
and 1e27
and function requireValidBUExchangeRate()
checks that if it's not in interval the the code would revert. the call to requireValidBUExchangeRate()
happens in the function mint()
, melt()
and setBasketsNeeded()
which are used in issue()
and handoutExcessAssets()
and compromiseBasketsNeeded()
which are used in multiple functionality of the systems. early malicious user can call issue(1e18)
and melt(1e18 - 1)
and then set the ratio between baskets needed and total supply to 1e27
and then any new action that increase the ratio would fail. because during the issue()
code calls melt()
so the issue() would fail for sure and other functionalities can increase the ratio because of the ratio too because of the rounding error which result in revert. so by exploiting this attacker can make RToken to be in broken state and most of the functionalities of the system would stop working.
This is melt()
code:
function melt(uint256 amtRToken) external notPausedOrFrozen { _burn(_msgSender(), amtRToken); emit Melted(amtRToken); requireValidBUExchangeRate(); }
As you can see it allows anyone to burn their RToken balance. This is requireValidBUExchangeRate()
code:
function requireValidBUExchangeRate() private view { uint256 supply = totalSupply(); if (supply == 0) return; // Note: These are D18s, even though they are uint256s. This is because // we cannot assume we stay inside our valid range here, as that is what // we are checking in the first place uint256 low = (FIX_ONE_256 * basketsNeeded) / supply; // D18{BU/rTok} uint256 high = (FIX_ONE_256 * basketsNeeded + (supply - 1)) / supply; // D18{BU/rTok} // 1e9 = FIX_ONE / 1e9; 1e27 = FIX_ONE * 1e9 require(uint192(low) >= 1e9 && uint192(high) <= 1e27, "BU rate out of range"); }
As you can see it checks and makes sure that the BU to RToken exchange rate to be in [1e-9, 1e9]. so Attacker can perform this steps:
1e18
RToken as first issuer by calling issue()
melt()
and burn 1e18 - 1
of his RTokens.basketsNeeded
would be 1e18
and totalSupply()
of RTokens would be 1
and the BU to RToken exchange rate would be its maximum value 1e27
and requireValidBUExchangeRate()
won't allow increasing the ratio.melt()
would revert and because issue()
calls to furnace.melt()
which calls RToken.melt()
so all calls to issue()
would revert. other functionality which result in calling mint()
, melt()
and setBasketsNeeded()
if they increase the ratio would fail too. as there is rounding error when converting RToken amount to basket amount so burning and minting new RTokens and increase the ratio too because of those rounding errors and those logics would revert. (handoutExcessAssets()
would revert because it mint revenue RToken and update basketsNeeded
and it calculates new basket amount based on RToken amounts and rounds down so it would increase the BU to RToken ratio which cause code to revert in mint()
) (redeem()
would increase the ratio simillar to handoutExcessAssets()
because of rounding down)so attacker can make protocol logics to be broken and then RToken won't be useless and attacker can perform this attack to any newly deployed RToken.
VIM
don't allow everyone to melt their tokens or don't allow melting if totalSupply() become very small.
#0 - c4-sponsor
2023-01-25T17:39:14Z
tmattimore marked the issue as disagree with severity
#1 - tmattimore
2023-01-25T17:43:56Z
Understand that issue()
plus melt()
can brick an RTokens issuance, but redeem should still work.
So, the RToken would no longer function as expected but no RToken holder funds would be lost. And in fact, RToken holders now have more funds.
Believe this is severity 2 but we should mitigate so that an annoying person / entity cannot DDOS every RToken on deployment w/ small amounts of capital. RToken holders can always continue to redeem though.
#2 - c4-sponsor
2023-01-25T17:44:29Z
tmattimore requested judge review
#3 - tmattimore
2023-01-25T18:47:23Z
Shouldn't have done the "judge review requested", apologies for that. Getting used to process
#4 - 0xean
2023-01-30T22:30:26Z
Agreed on downgrading due to no direct loss of significant funds and this mostly being a griefing type attack
#5 - c4-judge
2023-01-30T22:30:31Z
0xean changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-01-30T22:30:39Z
0xean marked the issue as satisfactory
🌟 Selected for report: unforgiven
Also found by: hihen, unforgiven, ustas
1192.9053 USDC - $1,192.91
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L655-L714 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L532-L544 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L186-L280
Function refundSpan()
loops through tokens and transfer them to caller and if one of tokens were ERC777 or any other 3rd party protocol token with hook, attacker can perform reentrancy attack during token transfers. Attacker can cause multiple impacts by choosing the reentrancy function:
refundSpan()
's token transfer external calls, attacker can perform reentrancy and call issue() and set new basket nonce (because queue is empty) and erc20 list for queue and then refundSpan()
would transfer another tokens to attacker in the rest of the execution as it reads ERC20 tokens from storage. and attacker would steal other users funds.refundSpan()
sets the value of the liabilities[]
but during the token transfers, the balance of the tokens didn't get updated so attacker can call sweepRewards()
by reentrancy, and make contract send collateral vesting tokens as rewards to BackingManager and some users deposits would be lost by this.Function refundSpan()
refunds all the deposits in the specified span. This is refundSpan()
code:
function refundSpan( address account, uint256 left, uint256 right ) private { if (left >= right) return; // refund an empty span IssueQueue storage queue = issueQueues[account]; // compute total deposits to refund uint256 tokensLen = queue.tokens.length; uint256[] memory amt = new uint256[](tokensLen); uint256 amtRToken; // {qRTok} IssueItem storage rightItem = queue.items[right - 1]; // compute item(right-1) - item(left-1) // we could dedup this logic for the zero item, but it would take more SLOADS if (left == 0) { amtRToken = rightItem.amtRToken; for (uint256 i = 0; i < tokensLen; ++i) { amt[i] = rightItem.deposits[i]; // Decrement liabilities liabilities[IERC20(queue.tokens[i])] -= amt[i]; } } else { IssueItem storage leftItem = queue.items[left - 1]; amtRToken = rightItem.amtRToken - leftItem.amtRToken; for (uint256 i = 0; i < tokensLen; ++i) { amt[i] = rightItem.deposits[i] - leftItem.deposits[i]; // Decrement liabilities liabilities[IERC20(queue.tokens[i])] -= amt[i]; } } if (queue.left == left && right == queue.right) { // empty entire queue queue.left = 0; queue.right = 0; } else if (queue.left == left && right < queue.right) { queue.left = right; // remove span from beginning } else if (queue.left < left && right == queue.right) { queue.right = left; // refund span from end } else { // untestable: // All calls to refundSpan() use valid values for left and right. // queue.left <= left && right <= queue.right. // Any call to refundSpan() passes queue.left for left, // OR passes queue.right for right, OR both. revert("Bad refundSpan"); } // error: can't remove [left,right) from the queue, and leave just one interval emit IssuancesCanceled(account, left, right, amtRToken); // == Interactions == for (uint256 i = 0; i < queue.tokens.length; ++i) { IERC20Upgradeable(queue.tokens[i]).safeTransfer(account, amt[i]); } }
As you can see if user refunds all his spans then code would set queue.left = queue.right = 0
and also code would update liabilities[]
based on the user's deposits. in the last part code would try to send user deposited tokens based on the queue.tokens[]
values and because queue
is defined as storage
so code uses values from storage. if one of those tokens were ERC777 then that token would account
hook function in token transfer. there may be other protocol tokens that calls registered hook functions during the token transfer. as reserve protocol is permission less and tries to work with all tokens so the external call in the token transfer can hook account
functions. attacker can use this hook and perform reentrancy attack.
This is Part of issue()
function:
// overwrite intentionally: we may have stale values in `tokens` and `basketNonce` queue.basketNonce = basketNonce; queue.tokens = erc20s; queue.right++;
As you can see it sets the value of the queue.tokens
and queue.basketNonce
to new values.
This is part of sweepRewards()
code in RewardableLibP1 which is used in RTokens speedRewards()
:
for (uint256 i = 0; i < erc20sLen; ++i) { deltas[i] = erc20s[i].balanceOf(address(this)) - liabilities[erc20s[i]]; // {qTok} } // == Interactions == // Sweep deltas for (uint256 i = 0; i < erc20sLen; ++i) { if (deltas[i] > 0) { erc20s[i].safeTransfer(address(bm), deltas[i]); // Verify nothing has gone wrong -- we should keep this assert, even in P1 assert(erc20s[i].balanceOf(address(this)) >= liabilities[erc20s[i]]); } }
As you can see it calculated rewards based on balance of the contract and liabilities[]
and sends the difference to BackingManager contract as rewards.
To exploit this issue attacker can choose different functions to call during reentrancy and each choice has different impact and would cause different damage.
scenario #1: attacker use issue()
function to reentrancy. the steps are:
SOME_ERC777
, USDT
] with quantity [1, 1] are in the basket right now and basket nonce is BasketNonce1.SOME_ERC777
token to get called during transfers.issue()
to create 100 RToken and code would set attacker vesting queue values. queue.basketNonce
would be BasketNonce1 and queue.tokens
would be [SOME_ERC777
, USDT
] and queue.left = 0
and queue.right = 1
. also code would transfer 100 USDT
and 100 SOME_ERC777
tokens from attacker address to RToken address and set the items of the queue accordingly.USDT
, WETH
] with basket nonce as BasketNonce2. then attacker would wait for so users would call issue()
and contract would has some balance in USDT
and WETH
because of vesting.vest(attackerAddress, 1)
to refund his deposits. code would check and see that queue's nonce is not equal to basketHandler's nonce and would call refundSpan(attackerAddress, 0, 1)
.refundSpan()
would set queue.left = queue.right = 0
and also update liabilities[SOME_ERC77] -= 100
and liabilities[USDT] -= 100
and then tires to transfer user deposits and when contract tries to transfer SOME_ERC777
tokens to attacker address, the ERC777 token hook function would call attacker specified contract.issue()
to create new vesting for himself. issue()
code would set new values for queue.basketNonce
as BasketNonce2 and queue.tokens
as [USDT
, WETH
] and would return and attacker contract hook would return too.refundSpan()
and code would try to transfer the second tokens and read it from queue.tokens[1]
from storage and because storage value for queue.tokens[]
has been changes code would try to transfer 100 WETH
tokens to attacker address from other users deposited WETH
.SOME_ERC77
and USDT
but received 100 SOME_ERC777
and WETH
and stole the other users funds.**scenario #2: attacker can use sweepRewards()
function for reentrancy: (attacker can perform all of this in one transaction)
SOME_ERC777
, USDT
] with quantity [1, 1] are in the basket right now and basket nonce is BasketNonce1.SOME_ERC777
token to get called during transfers.issue()
to create 100K RToken and code would set attacker vesting queue values. queue.basketNonce
would be BasketNonce1 and queue.tokens
would be [SOME_ERC777
, USDT
] and queue.left = 0
and queue.right = 1
. also code would transfer 100K USDT
and 100K SOME_ERC777
tokens from attacker address to RToken address and set the items of the queue accordingly and increase liabilities[SOME_ERC777]
and liabilities[USDT]
by 100K.USDT
balance which belong to users deposits, attacker would call cancel(attackerAddress, 1)
to refund his deposits. code would call refundSpan(attackerAddress, 0, 1)
to cancel and refund attacker deposits.refundSpan()
would decrease the values of liabilities[SOME_ERC777]
and liabilities[USDT]
by 100K and then would try to transfer tokens and start with SOME_ERC777
token and during the transfer ERC777 token would call receiver hook function and attacker contract would get called.sweepRewardsSingle(USDT)
and the code would call RewardableLibP1.sweepRewardsSingle(liabilities, USDT, assetRegistry, backingManager)
which would transfer amt = USDT.balanceOf(address(this)) - liabilities[USDT]
amount to BackingManger as rewards. but as liabilities[USDT]
value is decreased by 100K but contract still hold 100K so code would calculated wrong amount for rewards would transfer 100K of users depsit funds to BackingManager as rewards which would get distributed among RSR stakers and RToken holders.refundSpan()
would transfer 100K USDT
to attacker address.VIM
one thing can be checking basketNonce
of the queue in refundSpan()
end line to not be changed. add reentrancy guard for important function like cancel() or issue().
#0 - c4-judge
2023-01-23T17:44:07Z
0xean marked the issue as duplicate of #318
#1 - c4-judge
2023-01-23T17:44:17Z
0xean changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-01-23T17:44:21Z
0xean marked the issue as satisfactory
🌟 Selected for report: unforgiven
Also found by: hihen, unforgiven, ustas
1192.9053 USDC - $1,192.91
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L186-L280 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/BackingManager.sol#L105-L150 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/BasketHandler.sol#L271-L275
Function issue()
loops through basket tokens and transfers them to BackingManager and mint RToken for user if issuance fit in one block, and transfer required tokens to protocol from user. because protocol is permission less and supports all ERC20 tokens it's possible for tokens to be ERC777 compliant or any other protocol tokens which have hook function during transfer, so transfer of tokens can cause external call to holder address. during the basket erc20 token list transfer's external call which triggers hook call, attacker can call backingManager.manageTokens()
and because basketsNeeded
updated but tokens balance in BackingManager is not yet updated code would detect that RTokens are not fully collateralized wrongly and would seize RSR stakers fund to become fully collateralized. This would cause RSR stakers to lose funds in favor of the RToken holders. attacker can drain RSR stakers funds totally and then withdraw his inflated RTokens.
This is reading reentrancy attack across contracts.
This is part of issue()
code where creates RTokens in the same block:
....... ....... // ==== If the issuance can fit in this block, and nothing is blocking it, then // just do a "quick issuance" of iss instead of putting the issuance in the queue: // effects and actions if we go this way are the combined actions to create and vest iss: // basketsNeeded += iss.amtBaskets // mint(recipient, iss.amtRToken) // for each token index i, erc20s[i].transferFrom(issuer, backingManager, iss.deposits[i]) if ( // D18{blocks} <= D18{1} * {blocks} vestingEnd <= FIX_ONE_256 * block.number && queue.left == queue.right && status == CollateralStatus.SOUND ) { // Fixlib optimization: // D18{BU} = D18{BU} + D18{BU}; uint192(+) is the same as Fix.plus uint192 newBasketsNeeded = basketsNeeded + amtBaskets; emit BasketsNeededChanged(basketsNeeded, newBasketsNeeded); basketsNeeded = newBasketsNeeded; // Note: We don't need to update the prev queue entry because queue.left = queue.right emit Issuance(recipient, amtRToken, amtBaskets); // == Interactions then return: transfer tokens == // Complete issuance _mint(recipient, amtRToken); for (uint256 i = 0; i < erc20s.length; ++i) { IERC20Upgradeable(erc20s[i]).safeTransferFrom( issuer, address(backingManager), deposits[i] ); } // All RTokens instantly issued return amtRToken; } ........... ...........
As you can see when issuance can fit in the same block code would increase basketsNeeded and then tries to transfer baskets erc20 tokens from issuer to BackingManager address. If one of those tokens were ERC777 then that token would call token holder hook function in token transfers. there may be other protocol tokens that calls registered hook functions of sender or receiver during the token transfer. as reserve protocol is permission less and tries to work with all tokens so the external call in the token transfer can hook sender registered hook functions. attacker can use this and perform reentrancy attack. This is fullyCollateralized()
code in BasketHandler contract:
function fullyCollateralized() external view returns (bool) { return basketsHeldBy(address(backingManager)) >= rToken.basketsNeeded(); }
As you can see it reads basketsNeeded
from RToken contract and calculated baskets held by BackingManager current token balances by comparing them decides that RTtoken is fully collateralized or not. This is _manageTokens()
code which is called by manageTokens()
which is callable by anyone:
function _manageTokens(IERC20[] calldata erc20s) private { ........ ....... if (basketHandler.fullyCollateralized()) { // == Interaction (then return) == handoutExcessAssets(erc20s); } else { (bool doTrade, TradeRequest memory req) = RecollateralizationLibP1 .prepareRecollateralizationTrade(this); if (doTrade) { // Seize RSR if needed if (req.sell.erc20() == rsr) { uint256 bal = req.sell.erc20().balanceOf(address(this)); if (req.sellAmount > bal) stRSR.seizeRSR(req.sellAmount - bal); } tryTrade(req); } else { // Haircut time compromiseBasketsNeeded(); } } }
As you can see when RTtoken is not fully collateralized code would seize RSR tokens from RSR stakers. so to perform the attack, attacker would do these steps: (in one transaction by writing a smart contract)
SOME_ERC777
, USDT
] with quantity [1, 1] are in the basket right now.SOME_ERC777
token to get called during transfers.issue()
to create AMOUNT1 RToken (which can be fit in current block) and code would increase basketsNeeded
value and then tries to transfer basket tokens from attacker address to BackingManager contract.SOME_ERC777
token, attacker registered hook function would get called and attacker contract would call backingManager.manageTokens()
and _manageTokens()
function would call fullyCollateralized()
and because basketsNeeded
updated but BackingManager token balances are not yet updated so code would detect that RToken is not fully collateralized and it would seize RSR stakers RSR tokens to trade.issue()
would transfer rest of the erc20 tokens from attacker address to contract address. even so RTokens were fully collateralized but code seized RSR stakers funds. after this attacker call manageTokens()
again and code would distribute those extra funds between RToken
holders by minting new RTokens and melting them.lastIssRate
was higher. but even if it was the default value, attacker can stole RSR token up to worth of lastIssRate * totalSupply()
RToken in each block from RSR holders.VIM
prevent reading reentrancy attack by central reentrancy guard or by proxy interface contract that has reentrancy guard. or create contract state (similar to basket nonce) which changes after each interaction and check for contracts states change during the call. (start and end of the call)
#0 - 0xean
2023-01-23T17:42:55Z
Given that issue() respects the check-effects pattern and all state is updated prior to the loop that transfers tokens I am a bit skeptical that this attack would work without a coded POC.
That being said, since the balances cannot be updated atomically if an ERC777 token is present, there may be some risk here.
Downgrading to M and will use this to aggregate all of the many re-entrancy concerns raised.
#1 - c4-judge
2023-01-23T17:43:02Z
0xean changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-01-23T17:43:07Z
0xean marked the issue as satisfactory
#3 - c4-judge
2023-01-23T17:43:11Z
0xean marked the issue as primary issue
#4 - 0xean
2023-01-23T17:49:36Z
#297 has a bit cleaner description and POC of the issue
#5 - tmattimore
2023-01-26T20:00:46Z
dupe of #347
#6 - c4-judge
2023-01-31T15:12:40Z
0xean marked the issue as duplicate of #347
🌟 Selected for report: unforgiven
Also found by: hihen, unforgiven, ustas
1192.9053 USDC - $1,192.91
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L439-L514 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/BackingManager.sol#L105-L150
Function redeem()
redeems RToken for basket collateral and it updated basketsNeeded
and transfers users basket ERC20 from BackingManager to user address. it loops through tokens and transfer them to caller and if one of tokens were ERC777 or any other 3rd party protocol token with hook, attacker can perform reentrancy attack during token transfers. Attacker can cause multiple impacts by choosing the reentrancy function:
redeem()
again and bypass "bounding each withdrawal by the prorata share when protocol is under-collateralized" because tokens balance of BackingManager is not updated yet.BackingManager.manageTokens()
and because basketsNeeded
gets decreased and basket tokens balances of BasketManager are not updated, code would detect those tokens as excess funds and would distribute them between RSR stakers and RToken holders and some of RToken deposits would get transferred to RSR holders as rewards.This is redeem()
code:
function redeem(uint256 amount) external notFrozen { ............... ............... (address[] memory erc20s, uint256[] memory amounts) = basketHandler.quote(baskets, FLOOR); uint256 erc20length = erc20s.length; uint192 prorate = uint192((FIX_ONE_256 * amount) / supply); // Bound each withdrawal by the prorata share, in case we're currently under-collateralized for (uint256 i = 0; i < erc20length; ++i) { uint256 bal = IERC20Upgradeable(erc20s[i]).balanceOf(address(backingManager)); uint256 prorata = (prorate > 0) ? (prorate * bal) / FIX_ONE // {qTok} = D18{1} * {qTok} / D18 : mulDiv256(bal, amount, supply); // {qTok} = {qTok} * {qRTok} / {qRTok} if (prorata < amounts[i]) amounts[i] = prorata; } basketsNeeded = basketsNeeded_ - baskets; emit BasketsNeededChanged(basketsNeeded_, basketsNeeded); // == Interactions == _burn(redeemer, amount); bool allZero = true; for (uint256 i = 0; i < erc20length; ++i) { if (amounts[i] == 0) continue; if (allZero) allZero = false; IERC20Upgradeable(erc20s[i]).safeTransferFrom( address(backingManager), redeemer, amounts[i] ); } if (allZero) revert("Empty redemption"); }
As you can see code calculates withdrawal amount of each basket erc20 tokens by calling basketHandler.quote()
and then bounds each withdrawal by the prorata share of token balance, in case protocol is under-collateralized. and then code updates basketsNeeded
and in the end transfers the tokens. if one of those tokens were ERC777 then that token would call receiver hook function in token transfer. there may be other 3rd party protocol tokens that calls registered hook functions during the token transfer. as reserve protocol is permission less and tries to work with all tokens so the external call in the token transfer can call hook functions. attacker can use this hook and perform reentrancy attack.
This is fullyCollateralized()
code in BasketHandler:
function fullyCollateralized() external view returns (bool) { return basketsHeldBy(address(backingManager)) >= rToken.basketsNeeded(); }
As you can see it calculates baskets that can be held by backingManager tokens balance and needed baskets by RToken contract and by comparing them determines that if RToken is fully collateralized or not. if RToken is fully collateralized then BackingManager.manageTokens()
would call handoutExcessAssets()
and would distributes extra funds between RToken holders and RSR stakers.
the root cause of the issue is that during tokens transfers in redeem()
not all the basket tokens balance of the BackingManager updates once and if one has hook function which calls attacker contract then attacker can use this updated token balance of the contract and perform his reentrancy attack. attacker can call different functions for reentrancy. these are two scenarios:
** scenario #1: attacker call redeem()
again and bypass prorata share bound check when protocol is under-collaterialized:
SOME_ERC777
, USDT
] with quantity [1, 1] are in the basket right now and basket nonce is BasketNonce1.SOME_ERC777
balance and 100K USDT
balance. basketsNeeded
in RToken is 150K and RToken supply is 150K and attacker address Attacker1 has 30k RToken. battery charge allows for attacker to withdraw 30K tokens in one block.SOME_ERC777
token to get called during transfers.redeem()
to redeem 15K RToken and code would updated basketsNeeded
to 135K and code would bounds withdrawal by prorata shares of balance of the BackingManager because protocol is under-collateralized and code would calculated withdrawal amouns as 15K SOME_ERC777
tokens and 10K USDT
tokens (instead of 15K USDT
tokens) for withdraws.SOME_ERC777
tokens first to attacker address and attacker contract would get called during the hook function and now basketsNeeded
is 135K and total RTokens is 135K and BackingManager balance is 185K SOME_ERC777
and 100K USDT
(USDT
is not yet transferred). then attacker contract can call redeem()
again for the remaining 15K RTokens.SOME_ERC777
and 11.1K USDT
(USDT_balance * rtokenAmount / totalSupply = 100K * 15K / 135K) and it would burn 15K RToken form caller and the new value of totalSupply of RTokens would be 120K and basketsNeeded
would be 120K too. then code would transfers 15K SOME_ERC777
and 11.1K USDT
for attacker address.redeem()
would transfer 10K USDT
to attacker in the rest of the execution. attacker would receive 30K SOME_ERC777
and 21.1K USDT
tokens for 15K redeemed RToken but attacker should have get (100 * 30K / 150K = 20K
) 20K USDT
tokens because of the bound each withdrawal by the prorata share, in case we're currently under-collateralized.charge/2
amount of RToken in each block and stole other users funds when protocol is under collaterlized.** scenario #2: attacker can call BackingManager.manageTokens()
for reentrancy call:
SOME_ERC777
, USDT
] with quantity [1, 1] are in the basket right now and basket nonce is BasketNonce1.SOME_ERC777
balance and 150K USDT
balance. basketsNeeded
in RToken is 150K and RToken supply is 150K and attacker address Attacker1 has 30k RToken. battery charge allows for attacker to withdraw 30K tokens in one block.SOME_ERC777
token to get called during transfers.redeem()
to redeem 30K RToken and code would updated basketsNeeded
to 120K and burn 30K RToken and code would calculated withdrawal amounts as 30K SOME_ERC777
tokens and 30K USDT
tokens for withdraws.SOME_ERC777
tokens first to attacker address and attacker contract would get called during the hook function and now basketsNeeded
is 120K and total RTokens is 120K and BackingManager balance is 170K SOME_ERC777
and 150K USDT
(USDT
is not yet transferred). then attacker contract can call BackingManager.manageTokens()
.manageTokens()
would calculated baskets can held by BackingManager and it would be higher than 150K and basketsNeeded
would be 130K and code would consider 60K SOME_ERC777
and 30K USDT
tokens as revenue and try to distribute it between RSR stakers and RToken holders. code would mint 30K RTokens and would distribute it.redeem()
would transfer 30K USDT
to attacker address in rest of the execution.VIM
prevent reading reentrancy attack by central reentrancy guard or by one main proxy interface contract that has reentrancy guard. or create contract state (similar to basket nonce) which changes after each interaction and check for contracts states change during the call. (start and end of the call)
#0 - 0xean
2023-01-22T01:53:49Z
Would like to get some sponsor comments on this once prior to final review.
#1 - c4-judge
2023-01-22T01:53:54Z
0xean marked the issue as satisfactory
#2 - c4-sponsor
2023-01-25T18:05:29Z
tmattimore marked the issue as sponsor confirmed
#3 - tmattimore
2023-01-25T18:05:52Z
We think its real.
other potential mitigation:
Will discuss more and decide on mitigation path with team.
#4 - 0xean
2023-01-30T22:26:29Z
We think its real.
other potential mitigation:
- governance level norm of excluding erc777 as collateral. Can't fully enforce though, so not a full mitigation.
Will discuss more and decide on mitigation path with team.
Thanks @tmattimore - I am going to downgrade to M due to the external requirements needed for it to become a reality. If I may ask, What is the hesitancy to simply introduce standard reentrancy modifiers? Its not critical to the audit in any way, just more of my own curiosity.
#5 - c4-judge
2023-01-30T22:26:41Z
0xean changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-01-31T15:12:14Z
0xean marked the issue as primary issue
#7 - tbrent
2023-02-10T22:51:24Z
We think its real. other potential mitigation:
- governance level norm of excluding erc777 as collateral. Can't fully enforce though, so not a full mitigation.
Will discuss more and decide on mitigation path with team.
What is the hesitancy to simply introduce standard reentrancy modifiers? Its not critical to the audit in any way, just more of my own curiosity.
@0xean we would need a global mutex in order to prevent the attack noted here, which means lots of gas-inefficient external calls. The classic OZ modifier wouldn't be enough.
🌟 Selected for report: immeas
Also found by: HollaDieWaldfee, JTJabba, hihen, rvierdiiev, unforgiven, wait
258.0215 USDC - $258.02
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L406-L418 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L655-L714 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L342-L370
When users issue new RTokens, code creates vesting deposits and calculates finish time and increase the amount of the allVestAt
but if users cancel their issuance in vesting time code don't decrease the value of the allVestAt
. this would cause natural DOS by design as users interact with protocol and issue and cancel. also attacker can get a small loan and repeat 1000 times of issue() and cancel() for those tokens and increase allVestAt
as much high that no one be able to create RToken. the default is issuance rate is 6% in each hour and each month has 720 hour so if attacker get a flash loan for basket tokens worth of 6% RToken backed tokens and 720 times repeats issue()
and cancel()
then allVestAt
would increase 1 month and no one be able to create RToken in that Month and attacker needs to do this each month one time to completely block protocol. this is a critical issue because it is happing normally by design as users interact with system and also attacker can intentionally block the new RToken supply and break the protocol and cause losses for RSR stakers.
There is a griefing attack too as other users call issue()
and see that they have to wait long time for minting new RTokens they need to call cancel()
and pay two transaction gas. there is no parameter that user can specify maximum time they allow for vesting time when they call issue()
and code can create vesting issue with any delay for user and user has no control over the maximum allowed time and user can't calculate the time before transaction too because other transactions can change the state of the contract before user transaction mining.
This is cancel()
and refundSpan()
code:
function cancel(uint256 endId, bool earliest) external notFrozen { address account = _msgSender(); IssueQueue storage queue = issueQueues[account]; require(queue.left <= endId && endId <= queue.right, "out of range"); // == Interactions == if (earliest) { refundSpan(account, queue.left, endId); } else { refundSpan(account, endId, queue.right); } } function refundSpan( address account, uint256 left, uint256 right ) private { if (left >= right) return; // refund an empty span IssueQueue storage queue = issueQueues[account]; // compute total deposits to refund uint256 tokensLen = queue.tokens.length; uint256[] memory amt = new uint256[](tokensLen); uint256 amtRToken; // {qRTok} IssueItem storage rightItem = queue.items[right - 1]; // compute item(right-1) - item(left-1) // we could dedup this logic for the zero item, but it would take more SLOADS if (left == 0) { amtRToken = rightItem.amtRToken; for (uint256 i = 0; i < tokensLen; ++i) { amt[i] = rightItem.deposits[i]; // Decrement liabilities liabilities[IERC20(queue.tokens[i])] -= amt[i]; } } else { IssueItem storage leftItem = queue.items[left - 1]; amtRToken = rightItem.amtRToken - leftItem.amtRToken; for (uint256 i = 0; i < tokensLen; ++i) { amt[i] = rightItem.deposits[i] - leftItem.deposits[i]; // Decrement liabilities liabilities[IERC20(queue.tokens[i])] -= amt[i]; } } if (queue.left == left && right == queue.right) { // empty entire queue queue.left = 0; queue.right = 0; } else if (queue.left == left && right < queue.right) { queue.left = right; // remove span from beginning } else if (queue.left < left && right == queue.right) { queue.right = left; // refund span from end } else { // untestable: // All calls to refundSpan() use valid values for left and right. // queue.left <= left && right <= queue.right. // Any call to refundSpan() passes queue.left for left, // OR passes queue.right for right, OR both. revert("Bad refundSpan"); } // error: can't remove [left,right) from the queue, and leave just one interval emit IssuancesCanceled(account, left, right, amtRToken); // == Interactions == for (uint256 i = 0; i < queue.tokens.length; ++i) { IERC20Upgradeable(queue.tokens[i]).safeTransfer(account, amt[i]); } }
As you can see there is no logic to decrease the value of the allVestAt
and as users calls issue()
the function whenFinished()
would get called and increase the allVestAt
but as they call cancel()
the value of the allVestAt
won't get decreased so as users interact with system contract would delay minting new tokens and users would cancel their deposits and it would cause more delay too. attacker can perform this attack intentionally to perform a DOS attack and totally block minting new RTokens. attacker can perform this steps:
issue(10% of the RToken supply)
and tokens would get transferred to RToken and code would increase allVestAt
by about 2 hour time and crease a queue for attacker.
2.2 call the cancel()
and cancel vesting deposits and code would transfer attacker deposits to contract address but it won't decrease allVestAt
allVestAt
would be increase as 1 month. and any user wants to crease RToken would have wait 1 month. attacker can repeat the attack and increase the allVestAt
as much as he wants and deny others from minting new RTokens.issue()
has no argument that user can set for maxVestingTimeAllowed
time allowed so if users calls issue()
and sees they have to wait more than one month to get RToken they would cancel their issuance and it would increase allVestAt
more. and the depositing user most call cancel()
again and would pay two transaction gas. so there is a possible greifing attack too which increase the impact of the DOS.VIM
decrease allVestAt
when users cancels their deposits.
#0 - c4-judge
2023-01-22T01:41:32Z
0xean marked the issue as satisfactory
#1 - 0xean
2023-01-22T01:42:04Z
I don't see how this leads to a direct loss of user funds, so currently believe M to be the correct severity and will leave open for sponsor comment.
#2 - c4-judge
2023-01-22T01:42:09Z
0xean changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-01-22T15:08:18Z
0xean marked the issue as primary issue
#4 - c4-sponsor
2023-01-26T19:15:41Z
tbrent marked the issue as sponsor confirmed
#5 - c4-judge
2023-02-09T15:12:56Z
0xean marked issue #267 as primary and marked this issue as a duplicate of 267
🌟 Selected for report: HollaDieWaldfee
Also found by: unforgiven
1529.3658 USDC - $1,529.37
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/BackingManager.sol#L67-L77 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L439-L443
Function redeem()
in RToken contract, redeems RToken for basket collateral and it's callable when protocol is paused. but function redeem()
transfers basket tokens from BackingManager contract to redeemer and it requires spending allowance of the BackingManger tokens which is granted by calling grantRTokenAllowance()
for each basket tokens. function grantRTokenAllowance()
is not callable when protocol is paused. so if for any one of the basket tokens function grantRTokenAllowance()
didn't get called (newly added tokens to basket) then when protocol is paused grantRTokenAllowance()
can't be called and redeem()
won't be possible.
This is grantRTokenAllowance()
code:
function grantRTokenAllowance(IERC20 erc20) external notPausedOrFrozen { require(assetRegistry.isRegistered(erc20), "erc20 unregistered"); // == Interaction == IERC20Upgradeable(address(erc20)).safeApprove(address(main.rToken()), 0); IERC20Upgradeable(address(erc20)).safeApprove(address(main.rToken()), type(uint256).max); }
As you can see it gives RToken spending allowance and it can't be called when protocol is Paused.
This is redeem()
code:
function redeem(uint256 amount) external notFrozen { require(amount > 0, "Cannot redeem zero"); ......... ......... ......... // Accept and burn RToken, reverts if not enough balance to burn _burn(redeemer, amount); bool allZero = true; for (uint256 i = 0; i < erc20length; ++i) { if (amounts[i] == 0) continue; if (allZero) allZero = false; // Send withdrawal IERC20Upgradeable(erc20s[i]).safeTransferFrom( address(backingManager), redeemer, amounts[i] ); } if (allZero) revert("Empty redemption"); }
As you can see it can be called when protocol is paused and it transfers tokens from BackingManager to redeemer and require that RToken has spending allowance of BackingManager tokens so function grantRTokenAllowance()
most be called for basket tokens before.
the problem is that if there were newly added token in the basket and grantRTokenAllowance()
isn't called for that token and protocol was paused then user won't be able to redeem()
because grantRTokenAllowance()
is not callable during Paused time and redeem()
won't be possible in Paused time. so as docs says that redeem()
should be possible when protocol is paused but it won't happen in all cases and redeem() would be blocked.
VIM
allow grantRTokenAllowance()
to be called when contract is paused or call it when basket changes from contract.
#0 - c4-judge
2023-01-24T16:19:21Z
0xean marked the issue as duplicate of #16
#1 - c4-judge
2023-01-31T16:22:08Z
0xean marked the issue as satisfactory