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: 10/246
Findings: 8
Award: $749.96
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: monrel
Also found by: 0xRajkumar, 0xfusion, AkshaySrivastav, Bahurum, Brenzee, Cryptor, Dug, Haipls, Koolex, Krace, MiloTruck, RaymondFam, RedTiger, ToonVH, Tricko, Vagner, aga7hokakological, anodaram, bart1e, bin2chen, bytes032, carrotsmuggler, ck, d3e4, giovannidisiena, igingu, juancito, mahdirostami, mert_eren, n33k, nemveer, parsely, pavankv, sashik_eth, shaka, sinarette, ulqiorra, yac
3.4908 USDC - $3.49
When a user deposits after an inflation attack, the user could receive zero SafEth tokens because of how solidity rounds down non-integer values. This means that the user can lose all of the ETH that they deposit because zero SafEth tokens are minted for their deposit.
The core problem here is that it is possible to pass a mintAmount
of zero to the OpenZeppelin _mint()
function. In comparison, the solmate ERC4626 minting logic will purposefully revert if zero shares will be minted.
Assume the following scenario:
minAmount
value, into the protocol. The user's transaction is submitted to the mempool.stake()
with 0.5 ETH into asymmetry and receives slightly less than 0.5 ETH in SafETH in return (actually 5 * 10^17 considering it is a ERC20 with 18 decimals)unstake()
to withdraw all of their SafETH tokens except for 1 weipreDepositPrice
to 10 ^ 36. preDepositPrice
is now 3 * 10^18.stake()
, the value of totalStakeValueEth
is X and preDepositPrice
is X. If the attacker airdrop 10 ETH per derivative then everything deposited under 10 ETH won't mint anything.Manual analysis
Two fixes should be made:
Some related information and discussion on inflation attacks, though specific to ERC4626, is found in the OZ repo.
#0 - c4-pre-sort
2023-04-04T12:46:08Z
0xSorryNotSorry marked the issue as duplicate of #715
#1 - c4-judge
2023-04-21T14:56:36Z
Picodes marked the issue as satisfactory
🌟 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
poolPrice()
in Reth.sol uses the Uniswap pool spot price as an oracle. This makes it vulnerable to manipulation from flash loans.
The Reth.poolprice()
function should return the expected exchange rate between rETH and ETH. But the return value can be manipulated because it is using the spot price of the Uniswap pool. The liquidity of this pool is only $5 million, so flashloans that frontrun a SafEth.stake()
call can easily alter the spot price of the pool. The minAmount
value calculated by Reth relies on the spot price too, so it does not help prevent the protocol from swapping to rETH at a very bad rate that is manipulated by frontrunning.
Manual analysis
Do not use Uniswap pool spot price for a price oracle. Instead, use Chainlink or another source of price data that cannot easily be manipulated.
#0 - c4-pre-sort
2023-03-31T19:46:52Z
0xSorryNotSorry marked the issue as low quality report
#1 - c4-pre-sort
2023-04-04T11:37:03Z
0xSorryNotSorry marked the issue as duplicate of #601
#2 - c4-judge
2023-04-21T16:11:53Z
Picodes marked the issue as duplicate of #1125
#3 - c4-judge
2023-04-21T16:14:27Z
Picodes marked the issue as satisfactory
133.8143 USDC - $133.81
rebalanceToWeights()
in SafEth.sol works by withdrawing all derivative positions and depositing all positions with the new weights. This causes greater loss of value than is necessary.
In the case of rETH, if rETH cannot be deposited into the RocketPool contract directly, WETH will be swapped for rETH using Uniswap. The fee of the pool is 500 or 0.05% (specified here). If the entire position is withdrawn and redeposited in the exact same amount, this means a 0.1% fee is paid even when there is no change in the Uniswap position.
In addition to Uniswap fees, the default slippage tolerance of 1% may give MEV bots an arbitrage opportunity, resulting in additional loss of value for every rebalance. Each derivative has a maxSlippage
state variable, so during a rebalance it is possible that a 1% loss of value for each derivative could take place. For n derivatives, that's n% of the total ETH staked that could be lost (i.e. up to 3% loss with 3 derivatives). A brief test of Uniswap v3 showed an autoslippage value of 0.1-0.21% for a swap between 1 ETH and 100 ETH of value, which is significantly less than the hardcoded 1% value, increasing the risk of value loss from arbitrage.
The core problem is that all derivative balances are withdrawn and redeposited in rebalanceToWeights()
, instead of only the balances that need to be moved to a new derivative.
There are two ways value can be lost during this process.
Manual analysis
rebalanceToWeights()
should not withdraw and redeposit all positions. Instead, the rebalancing should only withdraw and deposit the exact value than needs changing. For example, if there is 1 ether of rETH than needs to be converted to wstETH, then only 1 ETH should be withdrawn from Reth.sol and deposited with WestEth.sol. Moving less value will result in less fees paid to Uniswap if Reth.swapExactInputSingleHop()
is called and also reduce the risk of frontrunning that arbitrages the maxSlippage
amount.
In order to reduce the value moved during rebalancing, the SafEth.sol contract should do the following steps:
ethAmount
for each derivative with the new weightsethDelta
between the ethAmount
and the current ethAmount
for every derivativeethDelta
is greater than zero or less than zero. If ethDelta
is zero, no action is needed for this derivative.#0 - c4-pre-sort
2023-04-04T20:30:33Z
0xSorryNotSorry marked the issue as primary issue
#1 - c4-sponsor
2023-04-07T17:07:46Z
elmutt marked the issue as disagree with severity
#2 - elmutt
2023-04-07T17:08:12Z
We plan on adding more robust rebalance options to let us rebalance any deraivatives any amount we want. disagreeing with severity because it is an onlyOwner function and we are aware of the issue.
#3 - c4-sponsor
2023-04-07T17:10:12Z
elmutt marked the issue as sponsor acknowledged
#4 - c4-judge
2023-04-21T16:08:56Z
Picodes changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-04-23T11:46:14Z
Picodes marked the issue as duplicate of #765
#6 - c4-judge
2023-04-24T19:25:50Z
Picodes changed the severity to QA (Quality Assurance)
#7 - c4-judge
2023-04-24T19:26:01Z
This previously downgraded issue has been upgraded by Picodes
#8 - c4-judge
2023-04-24T19:26:43Z
Picodes marked the issue as satisfactory
530.2809 USDC - $530.28
rETH is acquired using the Uniswap rETH/WETH pool. This solution has higher fees and lower liquidity than alternatives, which results in more lost user value than other solutions.
The Uniswap rETH/WETH pool that is used in Reth.sol to make swaps has a liquidity of $5 million. In comparison, the Balancer rETH/WETH pool has a liquidity of $80 million. Even the Curve rETH/WETH pool has a liquidity of $8 million. The greater liquidity should normally offer lower slippage to users. In addition, the fees to swap with the Balancer pool are only 0.04% compared to Uniswap's 0.05%. Even the Curve pool offers a lower fee than Uniswap with just a 0.037% fee. This Dune Analytics dashboard shows that Balancer is where the majority of rETH swaps happen by volume.
One solution to finding the best swap path for rETH is to use RocketPool's RocketSwapRouter.sol contract swapTo()
function. When users visit the RocketPool frontend to swap ETH for rETH, this is the function that RocketPool calls for the user. RocketSwapRouter.sol automatically determines the best way to split the swap between Balancer and Uniswap pools.
Pools that can be used for rETH/WETH swapping:
Line where Reth.sol swaps WETH for rETH with the Uniswap rETH/WETH pool.
Etherscan, Dune Analytics
The best solution is to use the same flow as RocketPool's frontend UI and to call swapTo()
in RocketSwapRouter.sol. An alternative is to modify Reth.sol to use the Balancer rETH/ETH pool for swapping instead of Uniswap's rETH/WETH pool to better conserve user value by reducing swap fees and reducing slippage costs.
#0 - c4-pre-sort
2023-04-02T17:37:54Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-04T20:37:39Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-sponsor
2023-04-07T17:13:56Z
elmutt marked the issue as sponsor confirmed
#3 - c4-judge
2023-04-23T11:44:43Z
Picodes marked the issue as selected for report
#4 - d3e4
2023-04-26T00:28:45Z
What is the issue here? This is nothing but an improvement proposal (QA).
#5 - Picodes
2023-04-27T09:18:44Z
My reasoning was that it's not an improvement proposal but a bug ( sub-optimal of the AMM pool), hence it does qualify for Medium for "leak of value".
I have to admit that I hesitated but I leaned towards Med because of the label "sponsor confirmed" suggesting that this finding provided value for the sponsor.
🌟 Selected for report: RaymondFam
Also found by: 7siech, LeoGold, Phantasmagoria, SunSec, adeolu, anodaram, aviggiano, chaduke, d3e4, eyexploit, jasonxiale, juancito, koxuan, mojito_auditor, n33k, neumo, rotcivegaf, yac
17.681 USDC - $17.68
When a user calls stake()
, the calculation to determine the amount of ETH to convert to each derivative rounds down and can leave some ETH dust in the SafEth contract. This ETH is trapped and cannot be retrieved, resulting in users slowly losing value as stake()
keeps getting called.
The division operation in this line rounds down. If there are 3 derivatives and the division rounds down for all of them, 3 wei will get trapped in the SafEth contract every time the function is called. https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L88
Manual analysis
Keep address(this).balance == 0
at the end of stake()
and unstake()
by sending address(this).balance
to msg.sender
.
address(msg.sender).call{value: address(this).balance}
In unstake()
, this line can be replaced by sending address(this).balance
instead.
#0 - c4-pre-sort
2023-04-04T16:28:52Z
0xSorryNotSorry marked the issue as duplicate of #455
#1 - c4-judge
2023-04-24T21:22:13Z
Picodes marked the issue as satisfactory
🌟 Selected for report: RaymondFam
Also found by: 0xepley, BPZ, Franfran, Parad0x, RedTiger, d3e4, fyvgsk, handsomegiraffe, ladboy233, rbserver, silviaxyz, whoismatthewmc1, yac
40.6368 USDC - $40.64
https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L173-L174 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/SfrxEth.sol#L74-L75 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/WstEth.sol#L60
There is a hardcoded slippage value for each derivative which can be changed by the owner using setMaxSlippage()
. Because the slippage is not user-specified, if the slippage tolerance is not met, the protocol is unusable. The hardcoded slippage will result in the stake()
operation reverting if even one of the many derivatives cannot be exchanged within the slippage tolerance. Third parties can alter the exchange rate and cause the rate to be outside the slippage tolerance, preventing users from calling stake()
.
If the slippage tolerance is not met during the derivative.deposit()
call, the entire stake()
operation will revert.
Every derivative has a slippage tolerance:
The simplest case of this denial of service is in Reth.sol because the Uniswap pool can be manipulated so that the price of rETH in the Uniswap pool drops below the rate calculated by minOut
.
Manual analysis
Allow users to optionally specify the slippage when calling stake()
, but use the owner-controlled maxSlippage
if the user chooses not to specify a slippage tolerance.
#0 - c4-pre-sort
2023-04-04T21:23:04Z
0xSorryNotSorry marked the issue as duplicate of #949
#1 - c4-pre-sort
2023-04-04T21:55:57Z
0xSorryNotSorry marked the issue as duplicate of #365
#2 - c4-judge
2023-04-24T18:14:47Z
Picodes marked the issue as not a duplicate
#3 - c4-judge
2023-04-24T18:15:57Z
Picodes marked the issue as duplicate of #588
#4 - c4-judge
2023-04-24T21:13:16Z
Picodes marked the issue as not a duplicate
#5 - c4-judge
2023-04-24T21:13:19Z
Picodes marked the issue as not a duplicate
#6 - c4-judge
2023-04-24T21:13:42Z
Picodes marked the issue as duplicate of #150
#7 - c4-judge
2023-04-24T21:13:47Z
Picodes marked the issue as duplicate of #150
#8 - c4-judge
2023-04-24T21:14:37Z
Picodes marked the issue as satisfactory
🌟 Selected for report: brgltd
Also found by: 0x3b, 0xAgro, 0xGusMcCrae, 0xNorman, 0xRajkumar, 0xSmartContract, 0xTraub, 0xWagmi, 0xWaitress, 0xffchain, 0xhacksmithh, 0xkazim, 0xnev, 3dgeville, ArbitraryExecution, Aymen0909, BRONZEDISC, Bason, Bloqarl, BlueAlder, Brenzee, CodeFoxInc, CodingNameKiki, Cryptor, DadeKuma, DevABDee, Diana, Dug, Englave, Gde, Haipls, HollaDieWaldfee, Ignite, Infect3d, Jerry0x, Josiah, Kaysoft, Koko1912, KrisApostolov, Lavishq, LeoGold, Madalad, PNS, Rappie, RaymondFam, RedTiger, Rickard, Rolezn, Sathish9098, SunSec, T1MOH, UdarTeam, Udsen, Viktor_Cortess, Wander, adriro, ak1, alejandrocovrr, alexzoid, arialblack14, ayden, bin2chen, brevis, btk, c3phas, carlitox477, catellatech, ch0bu, chaduke, ck, climber2002, codeslide, descharre, dingo2077, ernestognw, fatherOfBlocks, favelanky, georgits, helios, hl_, inmarelibero, juancito, ks__xxxxx, lopotras, lukris02, m_Rassska, mahdirostami, maxper, nadin, navinavu, nemveer, p_crypt0, peanuts, pipoca, pixpi, qpzm, rbserver, reassor, roelio, rotcivegaf, scokaf, siddhpurakaran, slvDev, smaul, tnevler, tsvetanovv, turvy_fuzz, vagrant, wen, yac, zzzitron
13.1298 USDC - $13.13
Use storage gaps like all OZ upgradeable contracts avoid the possibility of future storage collisions with mappings and dynamic arrays that are allocated to low numbered storage slots. Although the odds of such a collision are low, it is better to protect against it by reserving storage slots than to expect there are never any problems, due to the severity of the consequences if there is a collision. Some OZ best practices are already applied in other places, so use the other best practices to avoid future storage collisions.
stake()
or unstake()
stake()
and unstake()
in SafEth.sol have no reentrancy protection. At a minimum, it is recommended to move the line with _burn(msg.sender, _safEthAmount);
earlier in unstake()
, because the function calls external contracts in the for loop (albeit contracts written by the Assymetry team). Detected with this slither detector.
safeApprove()
Uniswap has a contract intended to help with Uniswap interactions. Use safeApprove()
from this contract instead of approve()
before the Uniswap interaction in Reth.sol.
Several critical operations do not trigger events in Reth.sol
, SfrxEth.sol
and WstEth.sol
. As a result, it is difficult to check the behavior of the contracts. Without events, users and blockchain-monitoring systems cannot easily detect suspicious behavior.
This comment's description is inaccurate and was accidentally copied from a different function.
addDerivative()
lets owner control user valueaddDerivative()
allows the SafEth contract owner to add ETH derivatives immediately. This can be followed by a call to rebalanceToWeights()
which will rebalance the position of all existing SafEth holders to hold some of the added derivative. The lack of a timelock causes two problems:
addDerivative()
with a large weight that would cause most of the ETH to be sent to this new derivative, and therefore steal user value.addDerivative()
, some existing SafEth holders may not want exposure to this new ETH derivative. Using a timelock allows users time to withdraw their position if they do not want this exposure to the new derivative. There is an additional risk that the new derivative contract, the equivalent of Reth.sol, WstEth.sol, or SfrxEth.sol, will have a bug that could put user funds at risk. Using a timelock gives users time to review the new code that will soon control their funds to make sure they trust the new code.#0 - c4-sponsor
2023-04-10T18:48:14Z
toshiSat marked the issue as sponsor acknowledged
#1 - c4-judge
2023-04-24T17:45:35Z
Picodes marked the issue as grade-b
🌟 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
derivatives[i].balance()
in rebalanceToWeights()
The derivatives[i].balance()
external function is called twice in rebalanceToWeights()
when the if
statement is entered. Caching the value locally saves gas. Hardhat gas estimates show this caching saves 8154 gas for rebalanceToWeights()
.
+ uint256 derivativeBalance = derivatives[i].balance(); - if (derivatives[i].balance() > 0) + if (derivativeBalance > 0) - derivatives[i].withdraw(derivatives[i].balance()); + derivatives[i].withdraw(derivativeBalance);
derivatives[i]
in stake()
Caching derivatives[i]
to a local variable in stake()
in this for loop can save 537 gas.
- for (uint i = 0; i < derivativeCount; i++) + for (uint i = 0; i < derivativeCount; i++) { + IDerivative derivative = derivatives[i]; underlyingValue += - (derivatives[i].ethPerDerivative(derivatives[i].balance()) * + (derivative.ethPerDerivative(derivative.balance()) * - derivatives[i].balance()) / + derivative.balance()) / 10 ** 18; + }
derivatives[i].balance()
in stake()
Caching derivatives[i].balance()
to a local variable in stake()
in this for loop can save 8145 gas. When this for loop is modified so derivatives[i]
and derivatives[i].balance()
are cached, 8592 gas can be saved.
- for (uint i = 0; i < derivativeCount; i++) + for (uint i = 0; i < derivativeCount; i++) { + uint256 derivativeBalance = derivatives[i].balance(); underlyingValue += - (derivatives[i].ethPerDerivative(derivatives[i].balance()) * + (derivatives[i].ethPerDerivative(derivativeBalance) * - derivatives[i].balance()) / + derivativeBalance) / 10 ** 18; + }
ethAmountToRebalance
in rebalanceToWeights()
is not modified in the for
statement so checking if this value is zero should be moved outside of the for loop to avoid duplicate checks of the same value. This reduces the contract size by 2 bytes and hardhat gas estimates show this saving 55 gas in the maximum gas scenario.
+ if (ethAmountToRebalance != 0) { for (uint i = 0; i < derivativeCount; i++) { - if (weights[i] == 0 || ethAmountToRebalance == 0) continue; + if (weights[i] == 0) continue;
Reth.deposit()
logic inside for loopThe variables rocketDepositPoolAddress
and rocketDepositPool
are only used in the else
block of Reth.deposit()
. Calculating these values is unnecessary in the if
block, and therefore wastes unnecessary gas. Move these lines from outside the if
/else
block into the else
block.
// Per RocketPool Docs query addresses each time it is used address rocketDepositPoolAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketDepositPool") ) ); RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface( rocketDepositPoolAddress );
#0 - c4-sponsor
2023-04-10T18:48:21Z
toshiSat marked the issue as sponsor acknowledged
#1 - c4-judge
2023-04-23T15:38:13Z
Picodes marked the issue as grade-b