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: 33/246
Findings: 3
Award: $208.37
🌟 Selected for report: 0
🚀 Solo Findings: 0
195.1013 USDC - $195.10
The unstake()
and rebalanceToWeights()
functions iterate over the derivatives
mapping and call the withdraw()
function on them.
The withdraw()
function on the Reth.sol
contract is the following:
function withdraw(uint256 amount) external onlyOwner { RocketTokenRETHInterface(rethAddress()).burn(amount); // solhint-disable-next-line (bool sent, ) = address(msg.sender).call{value: address(this).balance}(""); require(sent, "Failed to send Ether"); }
It tries to convert rETH back to ETH directly with RocketPool. In case is not possible to do the convertion the execution will revert, causing a DoS on the functions unstake()
and rebalanceToWeights()
.
The Rocket Pool Docs docs have the following note:
"Trading rETH back for ETH directly with Rocket Pool is only possible when the staking pool has enough ETH in it to handle your trade.
It's possible that if node operators have put all of the staking pool to work on the Beacon chain, then the liquidity pool won't have enough balance to cover your unstaking. In this scenario, you may find other ways to trade your rETH back to ETH (such as a decentralized exchange like Uniswap (opens new window)) - though they will likely come with a small premium."
Also this can be read directly from the Rocket Pool contracts starting at the burn function
Manual Review
Don't rely only on trading rETH back for ETH directly with Rocket Pool, when this is no possible, use an AMM like Uniswap or Balancer
#0 - c4-pre-sort
2023-04-04T19:56:01Z
0xSorryNotSorry marked the issue as duplicate of #210
#1 - c4-judge
2023-04-21T16:36:35Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-04-21T16:36:42Z
Picodes changed the severity to 3 (High Risk)
🌟 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#L215
The amount of SafEth tokens minted depends on the amount of Eth contributed to the system relative to the eth underlying value already in the system. This is calculated as follows:
uint256 mintAmount = (totalStakeValueEth * 10 ** 18) / preDepositPrice;
totalStakeValueEth
is the amount of Eth contributed by the depositor. This contribution is calculated by adding the ETH value of the derivative tokens that were minted with the deposit of the user:
uint256 totalStakeValueEth = 0; // total amount of derivatives worth of ETH in system for (uint i = 0; i < derivativeCount; i++) { uint256 weight = weights[i]; IDerivative derivative = derivatives[i]; if (weight == 0) continue; uint256 ethAmount = (msg.value * weight) / totalWeight; // This is slightly less than ethAmount because slippage uint256 depositAmount = derivative.deposit{value: ethAmount}(); uint derivativeReceivedEthValue = (derivative.ethPerDerivative( depositAmount ) * depositAmount) / 10 ** 18; totalStakeValueEth += derivativeReceivedEthValue; }
preDepositPrice
is calculated as follows:
preDepositPrice = (10 ** 18 * underlyingValue) / totalSupply;
underlyingValue
is calculated by adding up the ETH value of all the balances in each derivative:
uint256 underlyingValue = 0; // Getting underlying value in terms of ETH for each derivative for (uint i = 0; i < derivativeCount; i++) underlyingValue += (derivatives[i].ethPerDerivative(derivatives[i].balance()) * derivatives[i].balance()) / 10 ** 18;
The Reth.sol
contract reads the spot price of the rETH/WETH Uniswap pool, which can be manipulated (This price is read when poolCanDeposit()
returns false).
A manipulation of this price, for example, dumping rETH, will cause underlyingValue
to decrease making each SafEth token cheaper and allowing the attacker to get more tokens.
The conditions needed for a succesful attack are:
1.- Reth.poolCanDeposit(_amount)
returns false during the computation of underlyingValue
. Note: Reth.poolCanDeposit(_amount)
returns false if _amount
will cause the deposit pool to reach its limit of 5000 ETH.
2.- The weight of the Reth
derivative have been set to zero but a rebalance have not happened.
Giving the conditions from above, the proof of concept is as follows:
The state before the attacker deposits is:
rEth = 0
, wsEth = 40
, sfrxEth = 40
, hence totalWeight = 80
For wsEth and sfrxEth, ethPerDerivative
returns 1 ETH / derivative. But the attacker have manipulated the rEth
uniswaps spot price, so for rEth
, ethPerDerivative
returns 0.1 ETH / rETH.
The attacker stakes 10 ETH. First underlyingValue
is computed. The contributions are:
1.- The wsEth derivative contract contributes (1 ETH / wsEth) * 10 wsEth
= 10 Eth
2.- The sfrxEth derivative contract contributes (1 ETH / sfrxEth) * 10 sfrxEth
= 10 Eth
3.- The rEth derivative contract contributes (0.1 ETH / rEth) * 10 rEth
= 1 Eth
So underlyingValue
equals 20.1 ETH
.
preDepositPrice
is computed as underlyingValue / totalSupply
:
20.1 ETH / 30 SafEth
. So, preDepositPrice == 0.67 ETH / SafEth
.
Then the attacker's ETH gets deposited to each derivative, because rETH
weight have been set to zero, then it is skipped. The contribution of the attacker's ETH will be:
5 wsEth and 5 sfrxEth which its prices have not been manipulated, so totalStakeValueEth
will be 10 ETH
.
The tokens to mint are calculated as totalStakeValueEth / preDepositPrice
. Substituting values we have mintAmount = 10 ETH / 0.67 ETH / SafETH
. Which is 14.92 SafETH
.
Even though the attacker contributed with the 25% of ETH (10/40), he has 33% (14.92 / 44.92) of the totalSupply of the SafETH token.
The attack can be more profitable by a bigger manipulation of the rETH price or if the Reth.sol
derivative contract have a bigger percentage of all the derivatives on the protocol (In the example it was 33%).
Manual Review
Don't use AMM's spot price since those can be manipulated easily via flashloans, instead use TWAPs.
#0 - c4-pre-sort
2023-03-31T18:07:36Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-04T11:35:27Z
0xSorryNotSorry marked the issue as duplicate of #601
#2 - c4-judge
2023-04-21T16:14:35Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-04-21T16:15:13Z
Picodes marked the issue as duplicate of #1125