Lybra Finance - m_Rassska's results

A protocol building the first interest-bearing omnichain stablecoin backed by LSD.

General Information

Platform: Code4rena

Start Date: 23/06/2023

Pot Size: $60,500 USDC

Total HM: 31

Participants: 132

Period: 10 days

Judge: 0xean

Total Solo HM: 10

Id: 254

League: ETH

Lybra Finance

Findings Distribution

Researcher Performance

Rank: 122/132

Findings: 1

Award: $9.93

QA:
grade-b

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

9.931 USDC - $9.93

Labels

bug
downgraded by judge
grade-b
QA (Quality Assurance)
satisfactory
sponsor disputed
Q-09

External Links

Lines of code

https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/LybraWstETHVault.sol#L29-L46

Vulnerability details

Vulnerability Details

  • LybraWstETHVault provides an opportunity for users to deposit eth into wstETHVault as a collateral in order to issue some PeUSD. By invoking depositEtherToMint(uint256) user powers the vault with eth and the vault submits that eth into a Lido, which mints some shares according to the following formula: see here..

  • Let's now review the following logic presented by Lybra Finance:

    •   function depositEtherToMint(uint256 mintAmount) external payable override {
          require(msg.value >= 1 ether, "DNL");
      
          uint256 sharesAmount = lido.submit{value: msg.value}(address(configurator));
          require(sharesAmount > 0, "ZERO_DEPOSIT");
      
          lido.approve(address(collateralAsset), msg.value);
      
          uint256 wstETHAmount = IWstETH(address(collateralAsset)).wrap(msg.value);
      
          depositedAsset[msg.sender] += wstETHAmount;
          ... // some code 
        }
  • As we can see, the vault submits some deposited eth and then wraps received stETH into wstETH. However, it falsely assumes that

    • getPooledEthByShares(getSharesByPooledEth(ethAmount=1e18))==1e18ethgetPooledEthByShares(getSharesByPooledEth(_ethAmount = 1e18)) == 1e18eth which is not true due to some rounding issues on Lido's side.
  • Speaking in general, 1e18 eth does not mint exactly 1e18 stETH, some tiny epsilon which is 1e-18, makes the whole changes.

  • Because of that, the wrapping process will revert due to an insufficient amount of shares.

</br>

Proof of Concept

  • Try to deploy the following test contract and it will highlight the revert:

    • interface Ilido {
          function submit(address _referral) external payable returns (uint256 StETH);
          function approve(address spender, uint256 amount) external returns (bool);
      }
      
      interface IWstETH {
          function stEthPerToken() external view returns (uint256);
          function wrap(uint256 _stETHAmount) external returns (uint256);
      }
      
      // wstETH on Goerli = 0x6320cD32aA674d2898A68ec82e869385Fc5f7E2f
      // Lido & stETH on Goerli = 0x1643E812aE58766192Cf7D2Cf9567dF2C37e9B7F
      
      contract Test {
      
          Ilido immutable lido;
          IWstETH immutable wstETH;
      
          constructor(address _lido, address _asset) {
              lido = Ilido(_lido);
              wstETH = IWstETH(_asset);
          }
      
          function tryToDeposit() external payable {
              lido.submit{value: msg.value};
              lido.approve(address(wstETH), msg.value);
              uint256 wstETHAmount = IWstETH(address(wstETH)).wrap(msg.value); // should revert with "BALANCE_EXCEEDED".
          }
      } 
</br> </br>
  • Short term (not recommended):
    •   function depositEtherToMint(uint256 mintAmount) external payable override {
          require(msg.value >= 1 ether, "DNL");
      
          uint256 sharesAmount = lido.submit{value: msg.value}(address(configurator));
          require(sharesAmount > 0, "ZERO_DEPOSIT");
      
          lido.approve(address(collateralAsset), msg.value);
      
        +-  uint256 wstETHAmount = IWstETH(address(collateralAsset)).wrap(lido.getPooledEthByShares(sharesAmount));
      
          depositedAsset[msg.sender] += wstETHAmount;
          ... // some code 
        }
      
  • Long term, derived from Asymmetry Finance. However, with auto-wrap you can't specify the referral address. If it's highly necessary, combine these 2 approaches.
    •   function depositEtherToMint(uint256 mintAmount) external payable override {
          require(msg.value >= 1 ether, "DNL");
      
          ++ uint256 wstEthBalancePre = IWStETH(address(collateralAsset)).balanceOf(address(this));
          ++ // solhint-disable-next-line
          ++ (bool sent, ) = IWStETH(address(collateralAsset)).call{value: msg.value}("");
          ++ require(sent, "Failed to send Ether");
          ++ uint256 wstEthBalancePost = IWStETH(IWStETH(address(collateralAsset))).balanceOf(address(this));
          ++ uint256 wstEthAmount = wstEthBalancePost - wstEthBalancePre; 
      
          -- uint256 sharesAmount = lido.submit{value: msg.value}(address(configurator));
          -- require(sharesAmount > 0, "ZERO_DEPOSIT");
          -- lido.approve(address(collateralAsset), msg.value);
          --  uint256 wstETHAmount = IWstETH(address(collateralAsset)).wrap(lido.getPooledEthByShares(sharesAmount));
      
          depositedAsset[msg.sender] += wstEthAmount;
          ... // some code 
        }
      

Assessed type

DoS

#0 - c4-pre-sort

2023-07-08T19:10:10Z

JeffCX marked the issue as duplicate of #917

#1 - c4-judge

2023-07-28T19:33:20Z

0xean marked the issue as unsatisfactory: Insufficient quality

#2 - 0xean

2023-07-29T22:19:58Z

I am not clear on where you think this will revert, for reference here is the code from lido

/** * @notice Exchanges stETH to wstETH * @param _stETHAmount amount of stETH to wrap in exchange for wstETH * @dev Requirements: * - `_stETHAmount` must be non-zero * - msg.sender must approve at least `_stETHAmount` stETH to this * contract. * - msg.sender must have at least `_stETHAmount` of stETH. * User should first approve _stETHAmount to the WstETH contract * @return Amount of wstETH user receives after wrap */ function wrap(uint256 _stETHAmount) external returns (uint256) { require(_stETHAmount > 0, "wstETH: can't wrap zero stETH"); uint256 wstETHAmount = stETH.getSharesByPooledEth(_stETHAmount); _mint(msg.sender, wstETHAmount); stETH.transferFrom(msg.sender, address(this), _stETHAmount); return wstETHAmount; }

#3 - Rassska

2023-07-30T05:41:35Z

Hey @0xean!

The issue appears on a higher level. Let's look at these lines:

  • uint256 sharesAmount = lido.submit{value: msg.value}(address(configurator));
  • uint256 wstETHAmount = IWstETH(address(collateralAsset)).wrap(msg.value);

Firstly, we simply deposit all eth and receive corresponding amount of shares in the form of stETH. Secondly, the wrapping over all stETH that we received should be executed.

However, the protocol assumes that msg.value is exactly 1:1 backed with stETH(eg. 1e18 eth <=> 1e18 stETH). Which is not true due to the rounding issues(not issues, rather security measures) on a Lido side.

The Test provided above should demonstrate it. But the live example, can be seen here: https://goerli.etherscan.io/tx/0x83f5fe9ceee2ad80d7c391b0d9930e6ae2039d47f04cef7a5b9f52eec1aff1c9

I know, it was not easy to judge this contest, much respect! Anyways, thanks a lot for looking into it!

#4 - 0xean

2023-07-30T13:39:49Z

Understood, I think this qualifies as M. Thanks for clarifying and apologies this was marked as a dupe during pre-sort (cc @JeffCX )

#5 - c4-judge

2023-07-30T13:39:58Z

0xean marked the issue as not a duplicate

#6 - c4-judge

2023-07-30T13:40:02Z

0xean changed the severity to 2 (Med Risk)

#7 - c4-judge

2023-07-30T13:40:26Z

0xean marked the issue as satisfactory

#8 - 0xean

2023-07-30T13:40:33Z

cc @LybraFinance

#9 - LybraFinance

2023-08-01T07:04:33Z

The issue you pointed out is indeed worth studying. However, the depositEtherToMint() we tested on the testnet can be executed, and the test transaction is https://goerli.arbiscan.io/tx/0x7010cdd84284e0bf42d0ce8f1a93d7414377f174c91097145af2a4e346acb940. In stETH.transferFrom(), the logic actually executed internally is stETH's _transferShares(). function _transfer(address _sender, address _recipient, uint256 _amount) internal { uint256 _sharesToTransfer = getSharesByPooledEth(_amount); _transferShares(_sender, _recipient, _sharesToTransfer); _emitTransferEvents(_sender, _recipient, _amount, _sharesToTransfer); } So it appears in submit() that the user gets 1e-18 less stETH, but it can still pass when wrap(1e18).

#10 - c4-sponsor

2023-08-01T07:04:54Z

LybraFinance marked the issue as sponsor disputed

#11 - Rassska

2023-08-01T08:00:38Z

Thanks for your reply!

As we know, the Lido is deployed across 5 chains: Ethereum, Polygon, Solana, Polkadot(stopped), and Kusama(stopped), therefore, it's hard to verify the source that you're referring to. I mean, Lido itself hasn't deployed its contracts on other chains.

Knowing it, let's mention that ST(sponsor) in the discord said the following:

Currently, in V2, only peUSD and LBR token will exist on other Layer 2 networks, while all other contracts are on the mainnet.

Thus, **/LybraWstETHVault.sol should sit on mainnet, where the stETH is not exactly backed 1:1 by ETH.

My question is: why would you send msg.value to wrap where it clearly defined in NatSpec:

@param _stETHAmount amount of stETH to wrap in exchange for wstETH.

Btw, you could simply change the msg.value with sharesAmount and send the shares amount that you received to wrap(doesn't affect the intrinsic gas).

Again, thanks for your internal checking, would love to hear from @0xean as well!

#12 - 0xean

2023-08-01T13:10:51Z

Interesting...

For reference here is the wrap call on the mainnet contract

/** * @notice Exchanges stETH to wstETH * @param _stETHAmount amount of stETH to wrap in exchange for wstETH * @dev Requirements: * - `_stETHAmount` must be non-zero * - msg.sender must approve at least `_stETHAmount` stETH to this * contract. * - msg.sender must have at least `_stETHAmount` of stETH. * User should first approve _stETHAmount to the WstETH contract * @return Amount of wstETH user receives after wrap */ function wrap(uint256 _stETHAmount) external returns (uint256) { require(_stETHAmount > 0, "wstETH: can't wrap zero stETH"); uint256 wstETHAmount = stETH.getSharesByPooledEth(_stETHAmount); _mint(msg.sender, wstETHAmount); stETH.transferFrom(msg.sender, address(this), _stETHAmount); return wstETHAmount; }

Specifically this call here -

uint256 wstETHAmount = stETH.getSharesByPooledEth(_stETHAmount);

call this function in stETH

/** * @return the amount of shares that corresponds to `_ethAmount` protocol-controlled Ether. */ function getSharesByPooledEth(uint256 _ethAmount) public view returns (uint256) { return _ethAmount .mul(_getTotalShares()) .div(_getTotalPooledEther()); }

which returns an ETH amount. The important bit is when we mint with Lido it calls the below

/** * @dev Process user deposit, mints liquid tokens and increase the pool buffer * @param _referral address of referral. * @return amount of StETH shares generated */ function _submit(address _referral) internal returns (uint256) { require(msg.value != 0, "ZERO_DEPOSIT"); StakeLimitState.Data memory stakeLimitData = STAKING_STATE_POSITION.getStorageStakeLimitStruct(); // There is an invariant that protocol pause also implies staking pause. // Thus, no need to check protocol pause explicitly. require(!stakeLimitData.isStakingPaused(), "STAKING_PAUSED"); if (stakeLimitData.isStakingLimitSet()) { uint256 currentStakeLimit = stakeLimitData.calculateCurrentStakeLimit(); require(msg.value <= currentStakeLimit, "STAKE_LIMIT"); STAKING_STATE_POSITION.setStorageStakeLimitStruct(stakeLimitData.updatePrevStakeLimit(currentStakeLimit - msg.value)); } uint256 sharesAmount = getSharesByPooledEth(msg.value); _mintShares(msg.sender, sharesAmount); _setBufferedEther(_getBufferedEther().add(msg.value)); emit Submitted(msg.sender, msg.value, _referral); _emitTransferAfterMintingShares(msg.sender, sharesAmount); return sharesAmount; }

which uses the same function

uint256 sharesAmount = getSharesByPooledEth(msg.value);

So I think the net of this is that we are going to experience the same rounding error in both calls, and this issue doesn't manifest itself due to that. It may be worth fixing the code, but I think QA is the correct severity.

#13 - c4-judge

2023-08-01T13:11:16Z

0xean changed the severity to QA (Quality Assurance)

#14 - c4-judge

2023-08-01T13:13:03Z

0xean marked the issue as grade-b

#15 - Rassska

2023-08-01T16:12:15Z

Thanks a lot!

Truth be told, there is no clear reason to mark this issue as QA. I've clearly represented, why the function depositEtherToMint(uint256) fails to deliver the promised value and the way, how to fix it.

Again, **/LybraWstETHVault.sol should be deployed on mainnet where the line above fails due to an insufficient stETH balance. If it doesn't fail on your side, double check the stETH balance, it should be 0 before any deposits.

There are multiple ways to fix it: auto-wrap(not suitable), provide sharesAmount of stETH to wrap.

I don't have anything left to say, provided fully-working PoC that clearly proves the claims. It's a little bit disappointing anyways :(

#16 - 0xean

2023-08-01T16:36:49Z

@Rassska have you deployed your contract to goerli? if so can you provide a transaction link to the reverted transaction?

The current transaction link presented certainly isn't evidence of anything and after digging into the code it appears you are incorrect in your assumptions based on the mainnet deployed contracts and the two below calls in lido's codebase which show that msg.value == _stETHAmount yield equivalent values.

uint256 sharesAmount = getSharesByPooledEth(msg.value); uint256 wstETHAmount = stETH.getSharesByPooledEth(_stETHAmount);

Either provide a goerli transaction that shows an actual revert or judging is finalized on this issue.

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