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: 93/246
Findings: 3
Award: $45.32
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
The price of safETH is determined by the prices of ETH derivatives (e.g. rETH) in ETH terms. For one of the derivatives, WstETH, price is returned in stETH terms instead of ETH. This could lead to serious consequences in the event that stETH price de-pegs from ETH.
// WstEth.sol function ethPerDerivative(uint256 _amount) public view returns (uint256) { return IWStETH(WST_ETH).getStETHByWstETH(10 ** 18); }
In WstETH.sol
, the price of WstETH is obtained from the Lido’s WstETH contract function getStETHByWstETH
which returns the amount of stETH for one WstETH.
This is unexpected behaviour because in SafEth.sol
, stake()
expects to calculate underlyingValue
in terms of ETH — not stETH.
// SafEth.sol function stake() external payable { 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;
In the event that stETH de-pegs from ETH (stETH worth less than ETH), the protocol would still assume a 1:1 ratio between stETH:ETH and calculate a higher-than-accurate underlyingValue
, resulting in a user receiving less safETH than he deserves.
In the event that stETH de-pegs from ETH (stETH worth more than ETH), the protocol would still assume a 1:1 ratio between stETH:ETH and calculate a lower-than-accurate underlyingValue
, resulting in a user receiving more safETH than he deserves. An attacker could take advantage of such a situation, to steal protocol’s funds by repeatedly minting safETH for cheap, and unstaking to receive ETH.
Example: 1 ETH = 0.5 steth 1 ETH = 1 rETH
1 ETH = 1 frxETH
Assume protocol has 1 rETH, 1 WstETH, 1 frxETH, and already minted 3 safETH
-1) Attacker stakes(deposits) 1 ETH
-2)underlyingValue
= 2.5 ETH, totalSupply = 3, preDepositprice
= 2.5 / 3 = 0.833 eth
-3) totalStakeValueEth
: 0.66 + 0.33 + 0.33 = 1.32 eth (assuming 33% weight each)
-4) mintAmount
= 1.32 / 0.833 = 1.5846 safETH
By depositing 1 ETH, attacker manage to mint 1.5846 safETH
Protocol now has 1.33 rETH, 1.66 WstETH, 1.33 frxETH, and 4.5846 safETH Attacker has roughly 1/3 of supply 5) Attacker unstakes 1.5846 safETH 6) Protocol withdraws 1/3 of each derivative and receives ~0.44 rETH, 0.44 frxETH and 0.55 WstETH 7) After converting to ETH, attacker receives ~1.9 ETH: stealing 0.9 ETH for a deposit of 1 ETH
Manual review
In WstEth.sol
, ethPerDerivative()
after obtaining stETH for each WstETH, it should convert stETH to ETH. This can be done by obtaining stETH:ETH price from Chainlink for example.
// WstEth.sol function ethPerDerivative(uint256 _amount) public view returns (uint256) { uint256 stEthAmt = IWStETH(WST_ETH).getStETHByWstETH(10 ** 18); return // convert stETHAmt to ETH using Chainlink }
#0 - c4-pre-sort
2023-04-04T18:49:17Z
0xSorryNotSorry marked the issue as duplicate of #588
#1 - c4-judge
2023-04-21T17:09:03Z
Picodes marked the issue as satisfactory
#2 - Picodes
2023-04-22T08:59:07Z
I don't think the example is correct. Even if on the secondary market stETH has depeg, when staking the user would be depositing on Lido and getting 1 stETH / ETH.
#3 - c4-judge
2023-04-22T08:59:11Z
Picodes marked the issue as partial-50
#4 - c4-judge
2023-04-24T20:45:02Z
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
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/SfrxEth.sol#L116 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L215
The price of safETH
is determined by the prices of ETH derivatives (e.g. rETH) in ETH terms. The protocol obtains the price of some ETH derivatives through decentralized oracles. These oracles are susceptible to price manipulation attacks, which subsequently would affect the price of safETH
, leading to loss of protocol funds.
// SfrxEth.sol function ethPerDerivative(uint256 _amount) public view returns (uint256) { uint256 frxAmount = IsFrxEth(SFRX_ETH_ADDRESS).convertToAssets( 10 ** 18 ); return ((10 ** 18 * frxAmount) / IFrxEthEthPool(FRX_ETH_CRV_POOL_ADDRESS).price_oracle()); }
Attack scenario:
-1. Attacker flash loans large sum of DAI
-2. Swap DAI for frxETH on Uniswap
-3. Sell frxETH on Curve (frxETH - ETH pool) to depress price of frxETH
-4. Go to Asymmetry to stake 1 ETH
-5. In SafEth.sol
, stake()
function is called and underlyingValue
of all derivatives are calculated to determine preDepositPrice
-6. SfrxEth.sol
returns ethPerDerivative
as a low amount due to price manipulation in step 3 — therefore preDepositPrice
is calculated as an artificially low value
-7. Attacker mints large amount of safETH
-8. Attacker unstake all his safETH
and receives large amount of ETH
-9. Attacker repays flash loan and keeps balance
Possible total loss of protocol’s funds, depending on how low the frxETH price can be manipulated, attacker may be able to mint a very large amount of safETH and steal all ETH in the protocol. Alternatively, attacker may carry out attack described above multiple times till all funds are drained.
This is similar to many previous other oracle manipulation hacks, the most recent one at Bonq DAO https://hacken.io/insights/bonqdao-hack/
Manual review Hardhat
Do not solely rely one one decentralized oracle/AMM for price.
frxETH price is assessed to be the most susceptible as price is read from Curve pool oracle. rETH price, which may be read from UniswapV3, is less susceptible due to TWAP model. wstETH price is not read from an AMM oracle, but also has other flaws described in another finding, such as returning price in stETH terms not ETH.
It is recommended that all derivative prices are checked against other oracle sources (e.g. UniswapV3 or Chainlink) before they are consumed. Another guard could also be added to check if prices have drastic movements from a lastUpdatedPrice similar to how Liquity protocol handles oracle prices. https://www.liquity.org/blog/price-oracles-in-liquity
#0 - c4-pre-sort
2023-04-04T16:01:11Z
0xSorryNotSorry marked the issue as duplicate of #1035
#1 - c4-judge
2023-04-21T13:54:12Z
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/main/contracts/SafEth/derivatives/SfrxEth.sol#L94 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/WstEth.sol#L73
Stake/Deposit actions do not specify any minimum accepted values. This renders users and the protocol prone to arbitrage attacks, causing them to receive less than expected value.
Using deposit()
in the SfrxEth.sol
contract:
// SfrxEth.sol function deposit() external payable onlyOwner returns (uint256) { IFrxETHMinter frxETHMinterContract = IFrxETHMinter( FRX_ETH_MINTER_ADDRESS ); uint256 sfrxBalancePre = IERC20(SFRX_ETH_ADDRESS).balanceOf( address(this) ); frxETHMinterContract.submitAndDeposit{value: msg.value}(address(this)); uint256 sfrxBalancePost = IERC20(SFRX_ETH_ADDRESS).balanceOf( address(this) ); return sfrxBalancePost - sfrxBalancePre; }
If the frxETHMinterContract.submitAndDeposit
returns a very low amount (due to arbitrage or error on Frax’s contract), the protocol will assume very little was deposited and mint less safETH to the user who staked.
Manual review
It is advised to add a param to stake()
to relay expected minimum receive values and revert if the minimum is not received. For example:
// SafEth.sol function stake(uint256 minReceiveAmt) external payable { ... uint256 mintAmount = (totalStakeValueEth * 10 ** 18) / preDepositPrice; require(mintAmount >= minReceiveAmt, "less than minimum received") _mint(msg.sender, mintAmount);
#0 - c4-pre-sort
2023-04-04T21:07:00Z
0xSorryNotSorry marked the issue as primary issue
#1 - c4-sponsor
2023-04-07T14:26:36Z
toshiSat marked the issue as sponsor disputed
#2 - c4-judge
2023-04-22T10:54:04Z
Picodes marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2023-04-24T20:55:31Z
Picodes marked the issue as duplicate of #150
#4 - c4-judge
2023-04-24T20:55:37Z
Picodes marked the issue as satisfactory