When staking ETH for SafETH, rETH deposit can get routed through uniswap v3 pool if the rETH contract doesn't have enough space for the staked ETH.
When routing through uniswap, the minOut is only determined by the spot price that can be changed prior to calling staking.
On top of that, the stake function mint SafETH by calculating the backing of the protocol. rETH backing being determined by uniswap spot price, an attacker can increase or decrease the assumed backing per rETH on staking and get more safETH than deserved and unstake more ETH than staked.
At the date of this report it has ~5m$ liquidity in ETH/rETH but most of it is provided in a very tiny range between 1.0833 ETH per rETH and 1.0408 ETH per rETH.
The deposit function of rETH is initiallized at a 1% maxSlippage so if attacker went outside the range given above, the maxSlippage of the trade would probably be reached BUT attacker could on top of changing the ratio of the pool, add liquidity in a very tiny range where the deposit swap is supposed to happen to make maxSlippage useless.
The given example is approximative and values are not always accurate, more calculation would be needed to execute it correctly.
Here is the scenario of the attack:
Current state of SafETH would be 500 SafETH for a backing of 550 ETH with a 1/3 of it in each derivative: rETH, SfrxETH, WstETH. All with 1 - 1 ratio.
Attacker takes a 1200~ rETH (should be enough to drain the pool + add liquidity) and 200~ ETH (Needed for staking) flashloan from AAVEv3 (will have 0.09% of fee to reimburse).
Attacker add liquidity with a very tiny range at 0.0001 ETH per rETH.
Attacker swap rETH for ETH on the 0,05% univ3 pool with almost no slipage (see liquidity range) and only 0.05% fee ending the pool at the ratio he added liquidity at.
Pool is now at a price of 0.0001 ETH per rETH on the position we added liquidity at.
Attacker stake 200 ETH.
The estimated backing per SafETH is now estimated by the protocol to be :
183.33 ETH for WstETH + 183.33 ETH for SfrxETH + 183.33 * 0.0001 = 0.01833 ETH for rETH
So total backing is 366.6783 ETH for 500 SafETH, result in 0.7333 ETH per SafETH.
66.6666 (200 / 3 derivative) ETH are routed to each derivative
rETH is routed on uniswap in the range we added liquidity at so protocol get 0.006666~ rETH back.
Attacker receives 200 / 0.7333 = 272.7 SafETH.
Protocol now has 250 WstETH, 250 SfrxETH and 183.33 + 0,006666 rETH.
Attacker remove liquidity getting back the ETH and then swap back all ETH to get back as much rETH as possible with a very tiny slipage and only 0.05% fee.
Attacker unstake his 272.7 SafETH, the protocol understand that he is due 35.29% of all derivative, resulting in the withdraw of :
Lines of code
https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L170-L183 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L63-L129
Vulnerability details
Impact
When staking ETH for SafETH, rETH deposit can get routed through uniswap v3 pool if the rETH contract doesn't have enough space for the staked ETH.
When routing through uniswap, the minOut is only determined by the spot price that can be changed prior to calling staking.
On top of that, the stake function mint SafETH by calculating the backing of the protocol. rETH backing being determined by uniswap spot price, an attacker can increase or decrease the assumed backing per rETH on staking and get more safETH than deserved and unstake more ETH than staked.
Proof of Concept
The current uniswap pool used by the code is this pool (0.05% fee): https://info.uniswap.org/#/pools/0xa4e0faa58465a2d369aa21b3e42d43374c6f9613
At the date of this report it has ~5m$ liquidity in ETH/rETH but most of it is provided in a very tiny range between 1.0833 ETH per rETH and 1.0408 ETH per rETH.
The deposit function of rETH is initiallized at a 1% maxSlippage so if attacker went outside the range given above, the maxSlippage of the trade would probably be reached BUT attacker could on top of changing the ratio of the pool, add liquidity in a very tiny range where the deposit swap is supposed to happen to make maxSlippage useless.
The given example is approximative and values are not always accurate, more calculation would be needed to execute it correctly.
Here is the scenario of the attack:
Current state of SafETH would be 500 SafETH for a backing of 550 ETH with a 1/3 of it in each derivative: rETH, SfrxETH, WstETH. All with 1 - 1 ratio.
Pool is now at a price of 0.0001 ETH per rETH on the position we added liquidity at.
The estimated backing per SafETH is now estimated by the protocol to be : 183.33 ETH for WstETH + 183.33 ETH for SfrxETH + 183.33 * 0.0001 = 0.01833 ETH for rETH So total backing is 366.6783 ETH for 500 SafETH, result in 0.7333 ETH per SafETH.
66.6666 (200 / 3 derivative) ETH are routed to each derivative
rETH is routed on uniswap in the range we added liquidity at so protocol get 0.006666~ rETH back.
Attacker receives 200 / 0.7333 = 272.7 SafETH.
Protocol now has 250 WstETH, 250 SfrxETH and 183.33 + 0,006666 rETH.
250 0.3529 = 88.225 WstETH 250 0.3529 = 88.225 SfrxETH 183.3 * 0.3529 = 64.68657 rETH
Given backing at the beggining of 1 - 1 this results in 241.13 ETH for the Attacker.
Attacker paid 0.05 / 100 * 1200 = 0.6 ETH in swap fees and 0.6 rETH (because swapped both ways.
Attacker reimburse 1200 rETH 0,09 / 100 = 1.08 rETH extra Attacker reimburse 200 ETH 0.09 / 100 = 0.18 ETH extra
Attacker is left with 38.67 ETH.
Tools Used
Manual review
Recommended Mitigation Steps
rETH deposit should be using a twap price or an oracle to make this attack more difficult.