Asymmetry contest - ulqiorra'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: 222/246

Findings: 2

Award: $3.63

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

3.4908 USDC - $3.49

Labels

bug
3 (High Risk)
satisfactory
duplicate-1098

External Links

Lines of code

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L98-L99

Vulnerability details

Impact

By simple manipulations, the first depositor can infinitely drain all next depositors' funds by causing a rounding error in SafEth.stake() share-issuing logic and forcing it to mint zero SafEth shares to all next stakers.

A similar vulnerability was highlighted in OpenZeppelin ERC4626 audit (HIGH-01): https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/audits/2022-10-ERC4626.pdf

To attack SafEth a hacker needs to become the first investor with any amount of staked ETH, then withdraw the most of the stake so there is only 1 wei of SafEth shares left, and then inflate the underlying asset value by manually donating a large amount of underlying assets to relevant derivative pools. Any staker after the hacker's attack will receive zero shares for their stake. The attacker can redeem all underlying funds at any time by burning the only 1 wei share in the contract.

Proof of Concept

Attack scenario:

Step 1. A hacker backruns SafEth.initialize() function and becomes the first investor in the contract by staking minAmount ETH via SafEth.stake().

The contract is initialized with minAmount = 0.5 ETH:

minAmount = 5 * 10 ** 17; // initializing with .5 ETH as minimum

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L54

Right after initialization the SafEth.totalSupply is zero. Thus, the contract mints 0.5 SafEth shares to the hacker:

if (totalSupply == 0) preDepositPrice = 10 ** 18; // initializes with a price of 1

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L79-L80

Step 2. The hacker unstakes most of the funds via SafEth.unstake() so there is only 1 single wei share on the balance left. This can be done by calling:

safEth.unstake(safEth.balanceOf(hacker) - 1)

Now the SafEth.totalSupply is 1 wei.

Step 3. The hacker inflates the underlying asset value.

For example, the hacker buys 300.0 WstEth from the market and directly donates it to the project's WstEth derivative pool by calling

IERC20(WST_ETH).transfer(address(derivativeContract), 300e18)

The WstEth derivative contract's balance is calculated as follows:

function balance() public view returns (uint256) { return IERC20(WST_ETH).balanceOf(address(this)); }

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/WstEth.sol#L93-L96

After the hacker's direct transfer it returns a value greater or equal to 300e18.

Note that the hacker can redeem the full sum (300.0 ETH) at any time since they own 100% of SafEth shares (even though it's just a 1 wei).

Step 4. . Any new depositor that stakes funds after the hacker will receive zero SafEth shares because of a rounding error in the SafEth.stake() function.

To make a point we'll consider a scenario in which a victim stakes 200.0 ETH.

Note that after the contract's initialization the maxAmount that any user can deposit via stake() function is constrained by 200 ETH, so no one can stake more than this amount:

maxAmount = 200 * 10 ** 18; // initializing with 200 ETH as maximum

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L55

When the victim calls stake(), the function first checks the requirements which are all passed:

require(pauseStaking == false, "staking is paused"); require(msg.value >= minAmount, "amount too low"); require(msg.value <= maxAmount, "amount too high");

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L64-L66

Then the contract calculates the underlying asset value:

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;

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L68-L77

Note that the hacker directly donated 300.0 ETH to the WstEth contract, so the underlyingValue is equal to 300e18.

Next, the preDepositPrice is calculated:

uint256 totalSupply = totalSupply(); uint256 preDepositPrice; // Price of safETH in regards to ETH if (totalSupply == 0) preDepositPrice = 10 ** 18; // initializes with a price of 1 else preDepositPrice = (10 ** 18 * underlyingValue) / totalSupply;

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L77-L82

Note that the totalSupply is still equal to 1 wei, and underlyingValue is equal to 300e18, so preDepositPrice is calculated as 10 ** 18 * 300e18 / 1 == 1e18 * 300e18.

The following code distributes the victim's funds to different derivatives and calculates totalStakeValueEth:

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

Note that the totalStakeValueEth is expected to return a value close to the user's deposit, so, in our case, it can be considered equal to the 200e18.

Now the mintAmount is calculated:

// mintAmount represents a percentage of the total assets in the system uint256 mintAmount = (totalStakeValueEth * 10 ** 18) / preDepositPrice;

The mintAmount is calculated as 200e18 * 1e18 / (1e18 * 300e18) which is equal to zero.

Note that there is no requirement that the minAmount cannot be zero. Thus, the victim receives zero shares:

_mint(msg.sender, mintAmount);

Step 5. The hacker redeems all the funds.

Note that any new depositor funds are drained by the hacker since no one can stake more than 200.0 ETH. And even if admins increase the threshold, the hacker can simply increase the donation above the threshold, fronrunning any large stake.

When the hacker calls SafEth.unstake(1), the derivativeAmount to be withdrawn is calculated as follows:

// withdraw a percentage of each asset based on the amount of safETH uint256 derivativeAmount = (derivatives[i].balance() * _safEthAmount) / safEthTotalSupply; if (derivativeAmount == 0) continue; // if derivative empty ignore derivatives[i].withdraw(derivativeAmount);

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L114-L118

Note that safEthTotalSupply and _safEthAmount are equal to 1. Thus, the derivativeAmount is equal to the derivatives[i].balance(). So the hacker can withdraw all the funds from all the derivatives.

Tools Used

x

One way to resolve the problem is to mint 1000 dead wei shares to zero address in the initialize() function.

Other ways are descibed in the following thread:

Also take a note of a great article which analyzes flaws of different approaches of how to protect against different kinds of Inflation Attacks:

#0 - c4-pre-sort

2023-04-04T12:42:48Z

0xSorryNotSorry marked the issue as duplicate of #715

#1 - c4-judge

2023-04-21T14:56:03Z

Picodes marked the issue as satisfactory

Lines of code

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/SfrxEth.sol#L74-L82

Vulnerability details

Impact

The SfrxEth derivative contract calculates the maximum slippage for buying SfrxEth from curve pool by using the current price in the pool at runtime, without considering the price at which the user submitted the transaction to the mempool:

uint256 minOut = (((ethPerDerivative(_amount) * _amount) / 10 ** 18) * (10 ** 18 - maxSlippage)) / 10 ** 18; IFrxEthEthPool(FRX_ETH_CRV_POOL_ADDRESS).exchange( 1, 0, frxEthBalance, minOut );

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/SfrxEth.sol#L73-L82

Thus, a MEV bot can sandwich the SafEth.unstake() transaction, first causing slippage in its favor when the SfrxEth.withdraw() function is called and secondly earning a fees by buying LP-tokens from the curve pool where the transaction will occur. All of this will eat up around 1% of the user's SfrxEth withdraw:

function initialize(address _owner) external initializer { _transferOwnership(_owner); maxSlippage = (1 * 10 ** 16); // 1% }

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/SfrxEth.sol#L36-L39

Please note that the slippage occurs relative not to the price at which the user sent the transaction, but to the one chosen by the MEV bot when it decides that it is a favorable time for the bundle.

Tools Used

x

Before submitting a SafEth.unstake() transaction to the mempool, the user should calculate and manage the slippage. One way to do this is by passing minOut as an argument to the unstake() function.

#0 - c4-pre-sort

2023-03-31T11:13:45Z

0xSorryNotSorry marked the issue as low quality report

#1 - c4-pre-sort

2023-04-04T11:18:46Z

0xSorryNotSorry marked the issue as duplicate of #601

#2 - c4-pre-sort

2023-04-04T11:31:17Z

0xSorryNotSorry marked the issue as not a duplicate

#3 - c4-pre-sort

2023-04-04T18:42:54Z

0xSorryNotSorry marked the issue as duplicate of #698

#4 - c4-judge

2023-04-21T15:26:36Z

Picodes marked the issue as satisfactory

#5 - c4-judge

2023-04-21T15:26:48Z

Picodes marked the issue as selected for report

#6 - c4-judge

2023-04-22T09:24:44Z

Picodes marked the issue as duplicate of #1125

#7 - c4-judge

2023-04-22T09:26:00Z

Picodes marked the issue as not selected for report

Lines of code

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L171-L175

Vulnerability details

Impact

The Reth derivative contract calculates the maximum slippage for buying rETH from the Uniswap V3 pool by using the current price in the pool at runtime, without considering the price at which the user submitted the transaction to the mempool:

uint rethPerEth = (10 ** 36) / poolPrice(); uint256 minOut = ((((rethPerEth * msg.value) / 10 ** 18) * ((10 ** 18 - maxSlippage))) / 10 ** 18);

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L171-L175

Thus, a MEV bot can sandwich the stake() transaction, first causing slippage in its favor when the Reth.deposit() function is called and secondly earning fees on a price range in the Uniswap V3 pool where the transaction will occur, which the bot already knows in advance. All of this will eat up around 1% of the user's initial deposit:

function initialize(address _owner) external initializer { _transferOwnership(_owner); maxSlippage = (1 * 10 ** 16); // 1% }

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L42-L46

Please note that the slippage occurs relative not to the price at which the user sent the transaction, but to the one chosen by the MEV bot when it decides that it is a favorable time for the bundle.

Tools Used

x

Before submitting a SafEth.stake() transaction to the mempool, the user should calculate and manage the slippage. One way to do this is by passing minOut as an argument to the stake() function.

#0 - c4-pre-sort

2023-03-31T11:12:48Z

0xSorryNotSorry marked the issue as low quality report

#1 - c4-pre-sort

2023-04-04T11:18:33Z

0xSorryNotSorry marked the issue as duplicate of #601

#2 - c4-pre-sort

2023-04-04T12:18:31Z

0xSorryNotSorry marked the issue as not a duplicate

#3 - c4-sponsor

2023-04-07T21:39:59Z

toshiSat marked the issue as disagree with severity

#4 - c4-sponsor

2023-04-07T21:40:04Z

toshiSat marked the issue as sponsor acknowledged

#5 - c4-judge

2023-04-19T11:03:50Z

Picodes marked the issue as duplicate of #601

#6 - c4-judge

2023-04-21T16:15:02Z

Picodes marked the issue as satisfactory

#7 - c4-judge

2023-04-21T16:15:34Z

Picodes marked the issue as duplicate of #1125

#8 - c4-judge

2023-04-21T16:15:34Z

Picodes marked the issue as duplicate of #1125

Lines of code

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L240

Vulnerability details

Impact

Using a flashloan to manipulate rETH/ETH price a hacker can receive more SafEth shares for the same amount of ether, thus draining all three derivative contracts (rETH, SfrxEth and WstEth).

Proof of Concept

Reth.poolPrice() depends on UniswapV3 pool.slot0() which can be manipulated via flash loan:

(uint160 sqrtPriceX96, , , , , , ) = pool.slot0();

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L240

Reth.poolPrice() is used in Reth.ethPerDerivative() in case if the new Rocket Pool balance exceeds a certain limit which is determined via poolCanDeposit() function:

function ethPerDerivative(uint256 _amount) public view returns (uint256) { if (poolCanDeposit(_amount)) return RocketTokenRETHInterface(rethAddress()).getEthValue(10 ** 18); else return (poolPrice() * 10 ** 18) / (10 ** 18); }

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L211-L216

The poolCanDeposit() function checks if the current balance plus the user deposit is inside the Rocket Pool constraints:

return rocketDepositPool.getBalance() + _amount <= rocketDAOProtocolSettingsDeposit.getMaximumDepositPoolSize() && _amount >= rocketDAOProtocolSettingsDeposit.getMinimumDeposit();

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L147-L148

On the block number 16934718 on the Ethereum Mainnet the getMaximumDepositPoolSize() function returns a raw value 5000000000000000000000 which is equal to 5000 ETH. The function getMinimumDeposit returns a raw value 10000000000000000 which is equal to 0.01 ETH.

Note that on block number 16934718 on the Ethereum Mainnet the Rocket Pool is already at its limit and poolCanDeposit(amount) returns false for any positive amount.

Also note that even if it wasn't at its limit a hacker would still be able to push it to the limit by taking flashloan and depositing a large value of rETH to Rocket Pool directly.

The REth derivative contract uses Uniswap V3 to determine the price and do swaps:

IUniswapV3Pool pool = IUniswapV3Pool( factory.getPool(rocketTokenRETHAddress, W_ETH_ADDRESS, 500) );

https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L237-L240

This is the rETH/ETH pool at https://info.uniswap.org/#/pools/0xa4e0faa58465a2d369aa21b3e42d43374c6f9613

As of block 16934718 that pool TVL is ~1630.0 rETH and ~1300 ETH. The price of rETH can be push down by 90% by taking a flash loan of 1500 rETH from balancer.fi.

Let's consider a scenario in which a hacker takes a flash loan from balancer.fi and drops price by 90% in UniswapV3 pool that Reth derivative contract uses to determine its price.

Let's walk step by step on what happens when they call stake() function with 100 ETH: https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L63

First requirements are passed (msg.value == 100 ether):

function stake() external payable { require(pauseStaking == false, "staking is paused"); require(msg.value >= minAmount, "amount too low"); require(msg.value <= maxAmount, "amount too high");

For simplicity's sake let's imagine that SafEth derivative weights are WstEth 25%, SfrxEth 25% and rETH 50%, and that the contract derivative pools have 500 WstEth + 500 SftxEth + 1000 rETH, and the SafEth.totalSupply is 2000 ether.

Note that since the Reth derivative pool price was dropped by 90% by the hacker, the underlyingValue is caulculated as 500 + 500 + 100 == 1100 ether:

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 totalSupply at this point is the total ETH that was previously staked, that is 2000 ether. Thus, the preDepositPrice would be equal to 1e18 * 1100e18 / 2000e18 == 0.55e18:

uint256 totalSupply = totalSupply(); uint256 preDepositPrice; // Price of safETH in regards to ETH if (totalSupply == 0) preDepositPrice = 10 ** 18; // initializes with a price of 1 else preDepositPrice = (10 ** 18 * underlyingValue) / totalSupply;

Let's take a closer look on how the hacker's totalStakeValueEth is calculated:

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

First, it adds 25% SfrxEth and 25% WstEth. Let's consider their price is 1:1 and that totalStakeValueEth after adding them is equal to 50 ether.

In case of rETH, the derivative.deposit{value: ethAmount}() swaps 50 ETH for rETH in UniswapV3 pool. Since the price was dropped by 90%, let's consider a scenario in which the pool returns about 50/0.1=500 rETH.

The derivativeReceivedEthValue is still calculates as 50 ether though, because the resulting depositAmount (500 ether) is multipled by the price (0.1).

Thus, the totalStakeValueEth would be calculated as 100 ether.

But how much shares would hacker get?

uint256 mintAmount = (totalStakeValueEth * 10 ** 18) / preDepositPrice; _mint(msg.sender, mintAmount);

The mintAmount is calculated roughly as 100e18 * 1e18 / 0.55e18 which is roughly equal to 181 ether.

Thus, we can consider a scenario where the hacker gets 181.0 SafEth shares.

Now the hacker can return the Uniswap V3 pool to its normal price. Note that the price was already partially pushed up by the hacker's call to stake() which in turn swapped ETH for rETH in the pool.

When the price returned to the norm, the hacker calls unstake(181 ether): https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L108

The SafEth.totalSupply is equal to 2181 ether at this point, and there are 525 WstEth + 525 SfrxEth + 1500 rETH on the derivative contracts.

The function withdraws 25% of the hacker's shares both from SfrxEth and WstEth, and 50% from Reth:

for (uint256 i = 0; i < derivativeCount; i++) { // withdraw a percentage of each asset based on the amount of safETH uint256 derivativeAmount = (derivatives[i].balance() * _safEthAmount) / safEthTotalSupply; if (derivativeAmount == 0) continue; // if derivative empty ignore derivatives[i].withdraw(derivativeAmount); } _burn(msg.sender, _safEthAmount);

The derivativeAmount for WstEth and SftxEth would be equal to 525 * 181 / 2181 which is about 43 ether.

The derivativeAmount for rETH would be equal to 1500 * 181 / 2181 which is about 124 ether.

Thus, the hacker receives total of 43 + 43 + 124 == 210 ether back. This is much more than the hacker originally distributed to the pools via stake() function (25 + 25 + 50).

The full attack can be done in a single transaction.

Tools Used

x

It is recommended to use TWAP price with long period.

#0 - c4-pre-sort

2023-04-04T11:17:46Z

0xSorryNotSorry marked the issue as duplicate of #601

#1 - c4-pre-sort

2023-04-06T11:32:28Z

0xSorryNotSorry marked the issue as not a duplicate

#2 - c4-pre-sort

2023-04-06T11:32:45Z

0xSorryNotSorry marked the issue as duplicate of #1035

#3 - toshiSat

2023-04-07T14:29:22Z

@0xSorryNotSorry all these reth flashloan attack tickets are not copies of #1035, they are copies of #848

#4 - c4-judge

2023-04-21T13:51:44Z

Picodes marked the issue as satisfactory

#5 - c4-judge

2023-04-21T13:51:53Z

Picodes marked the issue as selected for report

#6 - c4-judge

2023-04-22T09:32:36Z

Picodes marked issue #142 as primary and marked this issue as a duplicate of 142

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