Platform: Code4rena
Start Date: 24/03/2023
Pot Size: $49,200 USDC
Total HM: 20
Participants: 246
Period: 6 days
Judge: Picodes
Total Solo HM: 1
Id: 226
League: ETH
Rank: 34/246
Findings: 5
Award: $208.22
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: rbserver
Also found by: 0xAgro, DadeKuma, DeStinE21, HollaDieWaldfee, IgorZuk, J4de, P7N8ZK, Parad0x, Stiglitz, bytes032, carrotsmuggler, csanuragjain, dec3ntraliz3d, kaden, koxuan, lukris02, rvierdiiev, tnevler
58.9366 USDC - $58.94
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L15
There's no way to remove a derivative completely.
If a derivative breaks, there's no way to remove it completely. The derivative will forever be called when unstaking, at the very least the balance
function, which is enough to block unstaking if it throws. It may also block rebalanceToWeights
, which is an important emergency mechanism.
Manual review.
Add a way to remove a derivative or at least completely stop querying it.
#0 - c4-pre-sort
2023-04-02T19:42:03Z
0xSorryNotSorry marked the issue as low quality report
#1 - c4-pre-sort
2023-04-04T17:32:21Z
0xSorryNotSorry marked the issue as duplicate of #703
#2 - c4-judge
2023-04-21T15:06:34Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-04-24T19:36:09Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: adriro
Also found by: 0xMirce, 0xRajkumar, 0xepley, BPZ, Bahurum, Bauer, Co0nan, Emmanuel, Franfran, HollaDieWaldfee, IgorZuk, MiloTruck, NoamYakov, RedTiger, Ruhum, T1MOH, Tricko, ad3sh_, auditor0517, bin2chen, carrotsmuggler, eyexploit, handsomegiraffe, igingu, jasonxiale, koxuan, lukris02, monrel, nadin, peanuts, rbserver, rvierdiiev, shaka, sinarette, tnevler, y1cunhui
4.5426 USDC - $4.54
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L73 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L92 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L211 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/WstEth.sol#L86
When staking the amount of minted SafETH may not reflect the share of the unstakeable Ether. Depending on the blockchain state staking and immediate unstaking may cause a loss or gain of ETH at the expense of the SafETH holders.
In https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L73 the currently unstakeable Ether is calculated as derivative.ethPerDerivative(derivative.balance()) * derivative.balance()
. In https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L92 the newly added unstakeable Ether is calculated as derivative.ethPerDerivative(depositAmount) * depositAmount
. The goal of this calculation is to make the staking user's share of the total SafETH supply match the share of unstakeable ETH that became available. This is good because it means that one user staking doesn't affect the other user's unstakeable ETH. In order for this to work each derivative's ethPerDerivative
should return the derivative price while withdrawing ETH. For example the SfrxEth derivative does this correctly, it simulates withdrawal of ETH.
The RETH derivative's implementation of ethPerDerivative
in https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L211 simulates not withdrawal but deposit of ETH. Its logic has a path querying RETH price on Uniswap, which doesn't match the withdraw
logic, it never uses Uniswap, instead it always burns RETH to get ETH. RETH's ethPerDerivative
in the current form can't be used to accurately calculate the withdrawable amount of ETH.
The WstETH derivative has a similar problem, its implementation of ethPerDerivative
in https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/WstEth.sol#L86 simulates deposit of ETH. It only checks the relation between WStETH and StETH, which matches the depositing logic, but doesn't take into account that withdrawal makes an extra step of selling StETH for ETH, and they may not have a 1:1 rate.
Manual review.
Make RETH and WstETH ethPerDerivative
logic simulate the withdrawal of ETH.
#0 - c4-pre-sort
2023-04-04T20:46:30Z
0xSorryNotSorry marked the issue as duplicate of #588
#1 - c4-judge
2023-04-21T17:08:57Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-04-22T09:04:34Z
Picodes marked the issue as not a duplicate
#3 - c4-judge
2023-04-22T09:04:44Z
Picodes marked the issue as duplicate of #588
🌟 Selected for report: HHK
Also found by: 019EC6E2, 0Kage, 0x52, 0xRobocop, 0xTraub, 0xbepresent, 0xepley, 0xfusion, 0xl51, 4lulz, Bahurum, BanPaleo, Bauer, CodeFoxInc, Dug, HollaDieWaldfee, IgorZuk, Lirios, MadWookie, MiloTruck, RedTiger, Ruhum, SaeedAlipoor01988, Shogoki, SunSec, ToonVH, Toshii, UdarTeam, Viktor_Cortess, a3yip6, auditor0517, aviggiano, bearonbike, bytes032, carlitox477, carrotsmuggler, chalex, deliriusz, ernestognw, fs0c, handsomegiraffe, igingu, jasonxiale, kaden, koxuan, latt1ce, m_Rassska, n1punp, nemveer, nowonder92, peanuts, pontifex, roelio, rvierdiiev, shalaamum, shuklaayush, skidog, tank, teddav, top1st, ulqiorra, wait, wen, yac
0.1353 USDC - $0.14
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L240
The attacker can perform a sandwich attack and enforce an arbitrary RETH purchase rate when the user deposit
s when stake
or rebalanceToWeights
are called.
For the attack to work SafETH derivative for RETH must be in the mode of buying on Uniswap (poolCanDeposit(amt) == false
). With sufficient capital the attacker can impose an arbitrary price of RETH on Uniswap by performing a sandwich attack.
deposit
on RETH derivative which buys very little RETH for a lot of ETH inflating RETH price even more.This is possible because when the RETH derivative buys RETH, it calculates the maximum slippage based on the current price of RETH with no consideration for the historical rates.
Manual review.
In function poolPrice
use the Uniswap's observe
mechanism to get the rate more difficult. Uniswap provides a library with the helpers to make it easier with consult
and getQuoteAtTick
: https://github.com/Uniswap/v3-periphery/blob/main/contracts/libraries/OracleLibrary.sol
#0 - c4-pre-sort
2023-04-04T11:39:01Z
0xSorryNotSorry marked the issue as duplicate of #601
#1 - c4-judge
2023-04-21T16:11:46Z
Picodes marked the issue as duplicate of #1125
#2 - c4-judge
2023-04-21T16:14:18Z
Picodes marked the issue as satisfactory
133.8143 USDC - $133.81
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L138
Moving ETH between derivatives is infeasible for anything other than serious emergencies due to the cost of the operation.
SafETH distributes ETH among derivatives using weights when the users are staking. The owner of the protocol can change the weights, but it doesn't affect the already staked funds. In order to update the staked funds the owner must call rebalanceToWeights
which unstakes all assets and then stakes them using the current weight configuration. This is very costly because both unstaking and staking may involve exchanging tokens on DEXes, which costs a percentage of the tokens in fees and may involve trading volumes big enough in relation to liquidity to cause large slippage further increasing the cost. Because staking ETH usually has no more than a few % of APY, a single rebalanceToWeights
may spend gains from months of staking or even cause losses. This means that the owners will be calling this function as rarely as possible which will make the protocol miss on higher yield derivatives while keeping funds staked in poorly performing protocols. This also means that any emergency withdrawal from one of the derivatives will be a very difficult decision, on one hand there will be risk of losing the funds by not withdrawing, but on the other will be losing funds on fees. This may lead to a passive approach to risks, so even if one of the staking protocol behaves suspiciously, funds may not be withdrawn unless it's too late.
Manual review.
Add tools for manual stake rebalancing allowing moving only funds that actually need to be moved and potentially over multiple transactions to avoid large slippage. Consider implementing direct exchange between derivatives' tokens without converting them to ETH first. Consider an option to automatically rebalance when changing weights, but only the assets affected by the change.
#0 - c4-pre-sort
2023-04-04T20:32:38Z
0xSorryNotSorry marked the issue as duplicate of #676
#1 - c4-judge
2023-04-23T11:46:08Z
Picodes marked the issue as duplicate of #765
#2 - c4-judge
2023-04-24T19:25:50Z
Picodes changed the severity to QA (Quality Assurance)
#3 - c4-judge
2023-04-24T19:26:01Z
This previously downgraded issue has been upgraded by Picodes
#4 - c4-judge
2023-04-24T19:26:46Z
Picodes marked the issue as satisfactory
🌟 Selected for report: Rolezn
Also found by: 0x3b, 0xGordita, 0xSmartContract, 0xhacksmithh, 0xnev, 0xpanicError, 4lulz, Angry_Mustache_Man, ArbitraryExecution, Aymen0909, Bason, BlueAlder, EvanW, Franfran, HHK, Haipls, IgorZuk, JCN, KrisApostolov, Madalad, MiksuJak, MiniGlome, RaymondFam, ReyAdmirado, Rickard, Sathish9098, Udsen, adriro, alexzoid, anodaram, arialblack14, c3phas, carlitox477, ch0bu, chaduke, codeslide, d3e4, dicethedev, ernestognw, fatherOfBlocks, georgits, hunter_w3b, inmarelibero, lukris02, mahdirostami, maxper, pavankv, pixpi, rotcivegaf, smaul, tank, tnevler, wen, yac
10.7864 USDC - $10.79
The current storage takes 5 slots:
bool public pauseStaking; bool public pauseUnstaking; uint256 public derivativeCount; uint256 public totalWeight; uint256 public minAmount; uint256 public maxAmount;
This is excessive, the types can be much smaller:
derivativeCount
can be uint16
there won't be more than 65 thousand derivativestotalWeight
can be uint32
, the weights scale of 4 billion should be accurate enoughminAmount
and maxAmount
can be uint96
, the total supply of ETH in the ecosystem is a number that can fit in 87 bits, using 96 is more than safe.The optimized storage fits exactly in 256 bits and takes only 1 slot:
bool public pauseStaking; bool public pauseUnstaking; uint16 public derivativeCount; uint32 public totalWeight; uint96 public minAmount; uint96 public maxAmount;
If it's necessary, the storage can be reduced by 1 more byte if pause
variables are merged into a bitmap.
Each derivative takes at least 3 slots:
mapping(uint256 => IDerivative) public derivatives; // derivatives in the system mapping(uint256 => uint256) public weights; // weights for each derivative
The third slot is the maxSlippage
variable stored by each derivative.
weights
can be reduced to uint32
as mentioned in the global storage packing findingmaxSlippage
can be reduced to uint64
, because it only deals with numbers on a scale from 0
(no slippage allowed) to 10^18
(any slippage allowed), which needs just 60 bits. Going further this value doesn't need to be stored in each derivative separately, it can be passed in deposit
and withdraw
functions. It's more flexible this way, keeps the configuration in one place, simplifies the derivatives implementation and makes events cleaner, currently derivatives don't emit anything even though their internal state is changed.The optimized per-derivative storage fits exactly in 256 bits and takes 1 slot:
struct DerivativeStorage { IDerivative derivative; uint32 weight; uint64 maxSlippage; } mapping(uint256 => DerivativeStorage) public derivatives;
stake
In the current implementation of stake
in https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L73 derivatives[i].balance()
is called twice in a row, but its result could be stored in a local variable and reused.
uint256 balance = derivatives[i].balance(); underlyingValue += (derivatives[i].ethPerDerivative(balance) * balance) / 10 ** 18;
#0 - c4-sponsor
2023-04-10T18:20:28Z
toshiSat marked the issue as sponsor acknowledged
#1 - c4-judge
2023-04-23T15:36:16Z
Picodes marked the issue as grade-b