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: 118/246
Findings: 4
Award: $27.89
🌟 Selected for report: 0
🚀 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
https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L68-L75 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L98 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/WstEth.sol#L93-L95
Users may receive 0 SafETH in exchange for their deposit if the attacker has inflated the derivatives balances, which leads to the loss of their principal due to rounding off in solidity.
Steps to reproduce:
-- When TotalSupply() of SafEth = 0
1. Attacker stakes minAmount(0.5 ether) in SafETH.
2. Attacker then unstakes all but 1 wei of SafETH.
3. Attacker then inflates all of the derivatives by directly transferring Z amounts of the respective underlyings (reth, sFRXEth, wStETH in our case) to the respective derivatives address. Thus increasing the balances of all the respective derivatives.
4. Now 1 wei of SafETH is equivalent to Z amount of underlying staked derivatives, thus increasing the preDepositPrice
in the stake function.
5. Now if users stake less than Z amount, they will be minted 0 SafETH due to rounding off, while their deposited ETH will be lost to the attacker.
6. Attacker won't have any issues making Z amount as big as possible since 1 wei of SafEth is owned by him.
Manual Review
stake()
, ensure the number of shares to be minted is non-zero:
require(mintAmount != 0, "No shares minted");#0 - c4-pre-sort
2023-04-04T12:44:02Z
0xSorryNotSorry marked the issue as duplicate of #715
#1 - c4-judge
2023-04-21T14:56:13Z
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/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L72-L75 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L211-L216
When: reth.balance() increases up to a certain limit such that poolCanDeposit(reth.balance())
is always false.
Where: Stake function
Under certain condition mentioned above, reth.ethPerDerivative(reth.balance())
will always return poolPrice()
rather than getEthValue
, while deposit will still happen in reth pool rather than through uniswap swapping. Attacker can use this schema to deflate underlying
using flashloan in stake function and increase totalStakedValueEth more than deserved.
Here, derivatives[i].balance()
is used, instead of derivatives[i].ethPerDerivative((msg.value*weights[i])/totalWeights)
underlyingValue += (derivatives[i].ethPerDerivative(derivatives[i].balance()) * derivatives[i].balance()) / 10 ** 18;
Assuming reth.balance()
(balance of reth in the derivative) increases to a certain threshold, such poolCanDeposit()
will always return false.
In the below code, _amount is reth.balance()
sent through here.
return rocketDepositPool.getBalance() + _amount <= rocketDAOProtocolSettingsDeposit.getMaximumDepositPoolSize()
-1. A malicious user will take a huge flashloan of RETH from some other pool and swap it for ETH in the RETH-ETH Uniswap V3 Pool that is used by rethDerivative.
-2. This will result into RETH price being drastically drop in the pool.
-3. Now, this malicious user will call stake
function in SafETH
with high eth. In the stake function, underlying
is calculated using totalDerivative[i].balance()
rather than _amount or msg.value. Thus ethPerDerivative
will return a deflated price of RETH.
-4. This will result in a reduced underlying
-> which will in turn reduce preDepositPrice
.
-5. While on the other hand reth.deposit()
will deposit in the rethPool directly rather than using the RETH-ETH Uniswap Pool as PoolCanDeposit()
will return true here.
if (!poolCanDeposit(msg.value)) {
-6. Similarily, totalStakedValueEth will remain normal as ethPerDerivative()
will return correct values.
uint derivativeReceivedEthValue = (derivative.ethPerDerivative( depositAmount ) * depositAmount) / 10 ** 18; totalStakeValueEth += derivativeReceivedEthValue;
-7. Hence, mintAmount will be inflated as preDepositPrice is deflated and totalStakedValueEth is correct. -8. The attacker can easily swap and return the remaining flash loan, and the profit from this attack will be much higher than fees.
Malicious users are prone to get more shares than deserved.
Manuel Review
Underlying is using ethPerDerivative(derivative[i].balance())
but only reth uses this arguement and that too in a vulnerable fashion. I would suggest to use derivatives[i].ethPerDerivative((msg.value*weights[i])/totalWeights)
- underlyingValue += (derivatives[i].ethPerDerivative(derivatives[i].balance()) * derivatives[i].balance()) / 10 ** 18;
#0 - c4-pre-sort
2023-04-04T11:24:19Z
0xSorryNotSorry marked the issue as duplicate of #601
#1 - c4-judge
2023-04-21T16:14:44Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-04-21T16:15:22Z
Picodes marked the issue as duplicate of #1125
🌟 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
Where : withdraw() - SfrxEth derivative
Slippage is calculated using the spot price of the curve pool from which swap is being done, which can be easily manipulated for sandwich attacks.
minOut
is calculated from the same inflated pool, the slippage holds no value under this circumstance.uint256 minOut = (((ethPerDerivative(_amount) * _amount) / 10 ** 18) * (10 ** 18 - maxSlippage)) / 10 ** 18; IFrxEthEthPool(FRX_ETH_CRV_POOL_ADDRESS).exchange( 1, 0, frxEthBalance, minOut );
rebalanceToWeights()
Manual Review
In such scenarios, it is recommended that slippage should not be calculated from the spot price of the pool.
#0 - c4-pre-sort
2023-04-04T11:20:21Z
0xSorryNotSorry marked the issue as duplicate of #601
#1 - c4-pre-sort
2023-04-04T11:31:21Z
0xSorryNotSorry marked the issue as not a duplicate
#2 - c4-pre-sort
2023-04-04T18:44:43Z
0xSorryNotSorry marked the issue as duplicate of #698
#3 - c4-judge
2023-04-21T15:29:49Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2023-04-22T09:24:42Z
Picodes marked the issue as duplicate of #1125
11.1318 USDC - $11.13
https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L63-L101 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L48-L56
When the safEth pool is initialised, no derivatives are added in the initialization. Derivatives are added in a separate function, addDerivative()
.
Any users trying to stake()
during that gap will lose their ETH as they will get minted 0 safEth in return.
Create a new file in test folder and replace the following content:
/* eslint-disable new-cap */ import { network, upgrades, ethers } from "hardhat"; import { expect } from "chai"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; import { SafEth } from "../typechain-types"; import { SnapshotRestorer, takeSnapshot, } from "@nomicfoundation/hardhat-network-helpers"; import { rEthDepositPoolAbi } from "./abi/rEthDepositPoolAbi"; import { RETH_MAX, WSTETH_ADRESS, WSTETH_WHALE } from "./helpers/constants"; import { derivativeAbi } from "./abi/derivativeAbi"; import { getDifferenceRatio } from "./SafEth-Integration.test"; import ERC20 from "@openzeppelin/contracts/build/contracts/ERC20.json"; const initialUpgradeableDeploy = async function () { const SafEth = await ethers.getContractFactory("SafEth"); const safEth = await upgrades.deployProxy(SafEth, [ "Asymmetry Finance ETH", "safETH", ]); await safEth.deployed(); return safEth; }; describe("Af Strategy", function () { let adminAccount: SignerWithAddress; let safEthProxy: SafEth; let initialHardhatBlock: number; // incase we need to reset to where we started const resetToBlock = async (blockNumber: number) => { await network.provider.request({ method: "hardhat_reset", params: [ { forking: { jsonRpcUrl: process.env.MAINNET_URL, blockNumber, }, }, ], }); safEthProxy = (await initialUpgradeableDeploy()) as SafEth; const accounts = await ethers.getSigners(); adminAccount = accounts[0]; }; before(async () => { const latestBlock = await ethers.provider.getBlock("latest"); initialHardhatBlock = latestBlock.number; await resetToBlock(initialHardhatBlock); }); describe("Loss of user's funds when there's no derivative", function () { it.only("Should be minted zero safEth", async function () { const depositAmount = ethers.utils.parseEther("200"); const tx1 = await safEthProxy.connect(adminAccount).stake({ value: depositAmount }); const mined1 = await tx1.wait(); expect(await safEthProxy.balanceOf(adminAccount.address)).to.equal(0); }); }); });
Manual Review
stake()
function, i.e. require(derivativeCount>0,"No derivatives added yet")
#0 - c4-pre-sort
2023-04-04T19:17:50Z
0xSorryNotSorry marked the issue as duplicate of #363
#1 - c4-judge
2023-04-21T16:29:55Z
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
stake()
function, msg.value is deposited in each derivatives respective to it's weight.
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L88uint256 ethAmount = (msg.value * weight) / totalWeight; // This is slightly less than ethAmount because slippage uint256 depositAmount = derivative.deposit{value: ethAmount}();
totalStakeValueEth
will always be wrong if poolCanDeposit
== falsepoolCanDeposit == true
. Otherwise it is swapped with rETH via uniswap pool.
https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L91-L95uint256 depositAmount = derivative.deposit{value: ethAmount}(); uint derivativeReceivedEthValue = (derivative.ethPerDerivative( depositAmount ) * depositAmount) / 10 ** 18; totalStakeValueEth += derivativeReceivedEthValue;
totalStakeValueEth
being lesser.stake
, unstake
, rebalanceToWeights
, adjustWeight
or addDerivative
is called, it uses unnecessary gas which can be prevented by removing the derivative from arrayhttps://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L92-L94
when: RocketPool's getMaximumDepositPoolSize()
is reached right after user's deposit.
Proof of Concept:
stake
amount is distributed in such a way that 20 eth is about to deposit() in reth derivative.
https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L147-L149
As you can seen the derivative will process the deposit as poolCanDeposit
function returns true.
https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L211-L215
Now to fetch the totalStakedValueEth
stake
function calls the ethPerDerivative
with 20 eth as arguement. But now poolCanDeposit
will return false and Uniswap v3 pool prices will be fetched, which is incorrect.isStakingPaused
of lido is not checked during deposit in WstEth.solhttps://github.com/lidofinance/lido-dao/blob/master/contracts/0.4.24/Lido.sol#L679
_submit
function of Lido, there's a check whether the staking is pausedmaxSlippage
should have some upper limit#0 - c4-sponsor
2023-04-07T21:55:25Z
toshiSat marked the issue as sponsor acknowledged
#1 - c4-judge
2023-04-24T17:17:33Z
Picodes marked the issue as grade-b