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
Rank: 6/132
Findings: 7
Award: $1,819.65
π Selected for report: 3
π Solo Findings: 1
π Selected for report: alexweb3
Also found by: D_Auditor, DedOhWale, DelerRH, LuchoLeonel1, Musaka, Neon2835, Silvermist, Timenov, TorpedoPistolIXC41, adeolu, cartlex_, hals, josephdara, koo, lanrebayode77, mahyar, mladenov, mrudenko, pep7siup, zaevlad, zaggle
18.4208 USDC - $18.42
All the functions in LybraConfigurator.sol can be called by malicious accounts. This breaks the whole protocol, as anyone can change the tokens, the fees and the ratios, the borrow apy or mint unlimited amount of tokens, etc..
A malicious user calls a LybraConfigurator function that is supposed to be executable only by the DAO or the TIMELOCK . The following modifiers should check if the caller has the needed rights. Both GovernanceTimelock.checkOnlyRole() and GovernanceTimelock.checkRole() return booleans indicating if a user has the needed role. However, the returned value is not checked, allowing anyone to execute the certain function.
modifier onlyRole(bytes32 role) { GovernanceTimelock.checkOnlyRole(role, msg.sender); _; }
modifier checkRole(bytes32 role) { GovernanceTimelock.checkRole(role, msg.sender); _; }
Manual review
In both the modifiers, add a require statement that checks the return value of the GovernanceTimelock function.
modifier onlyRole(bytes32 role) { require(GovernanceTimelock.checkOnlyRole(role, msg.sender), "NO_ROLE"); _; }
modifier checkRole(bytes32 role) { require(GovernanceTimelock.checkRole(role, msg.sender), "NO_ROLE"); _; }
Access Control
#0 - c4-pre-sort
2023-07-09T13:07:10Z
JeffCX marked the issue as duplicate of #704
#1 - c4-judge
2023-07-28T17:18:48Z
0xean marked the issue as satisfactory
π Selected for report: hl_
Also found by: 0xRobocop, Co0nan, CrypticShepherd, DedOhWale, Iurii3, Kenshin, Musaka, OMEN, RedOneN, SpicyMeatball, Toshii, Vagner, bytes032, cccz, gs8nrv, hl_, kenta, lanrebayode77, mahdikarimi, max10afternoon, peanuts, pep7siup
5.5262 USDC - $5.53
Wrong logic in LybraPeUSDVaultBase._repay()
causes users to be charge lower/no fees.
The logic in _repay
in implemented wrongly, more precisely, the totalFee
and borrowed[_onBehalfOf]
calculation are bolt lowered from the amount, even tho some parts of the amount provided should go as fees and the rest to deduct borrowed[_onBehalfOf]
.
function _repay(address _provider, address _onBehalfOf, uint256 _amount) internal virtual { try configurator.refreshMintReward(_onBehalfOf) {} catch {} _updateFee(_onBehalfOf); uint256 totalFee = feeStored[_onBehalfOf]; uint256 amount = borrowed[_onBehalfOf] + totalFee >= _amount ? _amount : borrowed[_onBehalfOf] + totalFee; if(amount >= totalFee) { feeStored[_onBehalfOf] = 0; PeUSD.transferFrom(_provider, address(configurator), totalFee); PeUSD.burn(_provider, amount - totalFee); } else { //@audit here we deduct amount from the fee feeStored[_onBehalfOf] = totalFee - amount; PeUSD.transferFrom(_provider, address(configurator), amount); } try configurator.distributeRewards() {} catch {} //@audit and here we deduct amount from the borrow balance borrowed[_onBehalfOf] -= amount; poolTotalPeUSDCirculation -= amount; emit Burn(_provider, _onBehalfOf, amount, block.timestamp); }
A more clearer explanation + an example:
borrowed[_onBehalfOf] = 100e18
and a total fee generated of 20 EUSD feeStored[_onBehalfOf] = 20e18
burn()
which in turns calls _repay()
function _repay(address _provider, address _onBehalfOf, uint256 _amount) internal virtual { try configurator.refreshMintReward(_onBehalfOf) {} catch {} _updateFee(_onBehalfOf); // we get the fee == 20e18 uint256 totalFee = feeStored[_onBehalfOf]; // amount = _amount = 100e18 uint256 amount = borrowed[_onBehalfOf] + totalFee >= _amount ? _amount : borrowed[_onBehalfOf] + totalFee; if(amount >= totalFee) {//true feeStored[_onBehalfOf] = 0;// we reset the fee PeUSD.transferFrom(_provider, address(configurator), totalFee);// we send it away PeUSD.burn(_provider, amount - totalFee);//and burn the rest } try configurator.distributeRewards() {} catch {} //but here the borrowed[_onBehalfOf] is lowered by the `amount`, not by `amount - totalFee` borrowed[_onBehalfOf] -= amount; poolTotalPeUSDCirculation -= amount;
borrowed[_onBehalfOf] -= amount
the user borrowed = 100 and paid fees on it, repaying 100 will get his borrow balance to 0, without accounting the fees that he should have payed!Manual Review
Example how _repay
can be fixed:
if(amount >= totalFee) { feeStored[_onBehalfOf] = 0; PeUSD.transferFrom(_provider, address(configurator), totalFee); PeUSD.burn(_provider, amount - totalFee); + try configurator.distributeRewards() {} catch {} + borrowed[_onBehalfOf] -= (amount - totalFee); } else { feeStored[_onBehalfOf] = totalFee - amount; PeUSD.transferFrom(_provider, address(configurator), amount); } - try configurator.distributeRewards() {} catch {} - borrowed[_onBehalfOf] -= amount; poolTotalPeUSDCirculation -= amount;
Math
#0 - c4-pre-sort
2023-07-11T19:48:17Z
JeffCX marked the issue as duplicate of #532
#1 - c4-judge
2023-07-28T15:39:43Z
0xean marked the issue as satisfactory
π Selected for report: SpicyMeatball
202.5014 USDC - $202.50
Rewards could be stolen on users who have withdrawn most of their LP from the UNIv2 ETH/LBR pool, but not replayed their borrow.
Because EUSDMiningIncentives
has a function to purchase rewards of users with small amount of LP it is possible to do the same for users with large amounts too, if they are not cautious with their funds. The exploit triggers when users with many rewards first unstake the LP and then repay the borrow amount. Between the 2 transaction MEV could be squished to extract his rewards. This exploit is possible dues to how isOtherEarningsClaimable
works
//if all of the LP is withdrawn the calculation comes to "0 / borrow_amount < 500" => true function isOtherEarningsClaimable(address user) public view returns (bool) { // (user_LP_in_pools * 10 000) / borrow_amount_from_vaults < 500 return (stakedLBRLpValue(user) * 10000) / stakedOf(user) < 500; }
stakedLBRLpValue
is the value staked in the UNIv2 ETH/LBR, represented in ethlbrLpToken
stakedOf
is the borrow amount from any of the vaults (RETH / stETH ...)
Example:
ethlbrLpToken
, combined dept of 200e18 and some unclaimed rewards of 100e18 EUSDpurchaseOtherEarnings
, where isOtherEarningsClaimable
is triggeredfunction isOtherEarningsClaimable(address user) public view returns (bool) { // (0 * 10000) / 200e18 = 0 ===> 0 < 500 -true return (stakedLBRLpValue(user) * 10000) / stakedOf(user) < 500; }
Manual Review
You can remove or refactor this function, but dues to it's complexity I am not sure how it should be refactored.
Math
#0 - c4-pre-sort
2023-07-11T18:37:27Z
JeffCX marked the issue as duplicate of #442
#1 - c4-judge
2023-07-28T15:42:02Z
0xean marked the issue as satisfactory
π Selected for report: Musaka
Also found by: 0xhacksmithh, 0xnev, CrypticShepherd, LuchoLeonel1, T1MOH, bytes032, cccz, devival, josephdara, ktg, squeaky_cactus
37.7738 USDC - $37.77
Due to inappropriate short votingPeriod
and votingDelay
, it is near impossible for the governance to function correctly.
When making proposals with the Governor
contract OZ uses votingPeriod
uint256 snapshot = currentTimepoint + votingDelay(); uint256 duration = votingPeriod(); _proposals[proposalId] = ProposalCore({ proposer: proposer, voteStart: SafeCast.toUint48(snapshot),//@audit votingDelay() for when the voting starts voteDuration: SafeCast.toUint32(duration),//@audit votingPeriod() for the duration executed: false, canceled: false });
But currently Lybra has implemented wrong amounts for bolt votingPeriod
and votingDelay
, which means proposals from the governance will be near impossible to be voted on.
function votingPeriod() public pure override returns (uint256){ return 3;//@audit this should be time in blocks } function votingDelay() public pure override returns (uint256){ return 1;//@audit this should be time in blocks }
https://gist.github.com/0x3b33/dfd5a29d5fa50a00a149080280569d12
Manual Review
You can implement it as OZ suggests in their examples
function votingDelay() public pure override returns (uint256) { return 7200; // 1 day } function votingPeriod() public pure override returns (uint256) { return 50400; // 1 week }
Governance
#0 - c4-pre-sort
2023-07-04T14:09:25Z
JeffCX marked the issue as primary issue
#1 - c4-sponsor
2023-07-14T09:51:34Z
LybraFinance marked the issue as sponsor acknowledged
#2 - c4-judge
2023-07-26T01:13:44Z
0xean changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-07-26T01:13:54Z
0xean marked the issue as satisfactory
#4 - c4-judge
2023-07-28T19:53:51Z
0xean marked the issue as selected for report
109.6632 USDC - $109.66
If ProtocolRewardsPool
is insufficient in EUSD, but has enough PeUSD to give rewards it still reverts, due to wrong if()
statement, thus it is unable to send the rewards to users.
Users have just emptied ProtocolRewardsPool
out of EUSD, by claiming rewards with getReward
. Now the protocol has a new distribution of PeUSD tokens, with LybraConfigurator.distributeRewards
, but when users try to claim their rewards getReward
reverts because of this:
function getReward() external updateReward(msg.sender) { uint reward = rewards[msg.sender]; if (reward > 0) { rewards[msg.sender] = 0; IEUSD EUSD = IEUSD(configurator.getEUSDAddress());//get the address uint256 balance = EUSD.sharesOf(address(this));//get the balance == //@aduit here eUSDShare = balance >= reward-false => reward - balance => rewards - 0 | eUSDShare = reward uint256 eUSDShare = balance >= reward ? reward : reward - balance; //here it tries to send the rewards amount, but it reverts since it has not tokens EUSD.transferShares(msg.sender, eUSDShare);
Because of the constant revert users are not able to claim their rewards and need to wait for EUSD distribution. The other bad thing is that the PeUSD is uncalimable to most extent.Again because of the line bellow, if:
//eUSDShare = balance >= reward-false => reward - balance => 100e18 - 40e18 => eUSDShare = 60e18 uint256 eUSDShare = balance >= reward ? reward : reward - balance; //again reverts, because contract has 40, whily trying to send 60 EUSD.transferShares(msg.sender, eUSDShare);
Now PeUSD is un-claimable and remains in the contract.
function test_no_EUSD() public { //make 2 random users deal(address(lbr), user1, 1000e18); deal(address(lbr), user2, 4000e18); //stake for bolt of them vm.prank(user1); rewardsPool.stake(1000e18); vm.prank(user2); rewardsPool.stake(4000e18); //get some PeUSD in the config and call distributeRewards() to send it to the pool //@notice here we don't send any EUSD => rewardsPool has 0 EUSD deal(address(PeUSD),address(configurator),1e21); configurator.distributeRewards(); //to make sure the balance is sent PeUSD.balanceOf(address(rewardsPool)); //user rewards is actually 2e17 per 1e18 => 2e20 total for user1 vm.prank(user1); //but here reverts, because it is unable to send any EUSD rewardsPool.getReward(); console.log(rewardsPool.earned(user1)); console.log("pEUSD user1: ", PeUSD.balanceOf(user1)); console.log("pEUSD pool : ", PeUSD.balanceOf(address(rewardsPool))); console.log(); }
Manual Review
update the if
as:
- uint256 eUSDShare = balance >= reward ? reward : reward - balance; + uint256 eUSDShare = balance >= reward ? reward : balance;
Math
#0 - c4-pre-sort
2023-07-10T13:45:53Z
JeffCX marked the issue as duplicate of #161
#1 - c4-judge
2023-07-28T15:44:25Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-07-28T19:45:16Z
0xean changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-07-28T20:42:20Z
0xean marked the issue as selected for report
#4 - c4-sponsor
2023-07-29T11:14:19Z
LybraFinance marked the issue as sponsor acknowledged
π Selected for report: Musaka
1444.4545 USDC - $1,444.45
Volatile prices can cause issue when users try to do rigidRedemption
Volatile prices can cause slippage loss, when users use rigidRedemption()
. This function takes PeUSD (stable coin) amount and converts it to WstETH/stETH (variable price). Unfortunately rigidRedemption()
does not include timestamp
or minAmount
received , meaning this trade can be executed later in time and at a different price than user previously expected.
Example:
rigidRedemption
Manual Review
Because of this scenario and other like it, it is recommended to use some sort of slippage protection when users execute trades.
function rigidRedemption(address provider, uint256 eusdAmount,uint256 minAmountReceived) external virtual { depositedAsset[provider] -= collateralAmount; totalDepositedAsset -= collateralAmount; + require(minAmountReceived <= collateralAmount); collateralAsset.transfer(msg.sender, collateralAmount); emit RigidRedemption(msg.sender, provider, eusdAmount, collateralAmount, block.timestamp); }
MEV
#0 - JeffCX
2023-07-11T19:44:03Z
I think the auditor is trying to argue the slippage protection.
#1 - c4-pre-sort
2023-07-11T19:45:50Z
JeffCX marked the issue as primary issue
#2 - c4-sponsor
2023-07-18T08:17:19Z
LybraFinance marked the issue as disagree with severity
#3 - 0xean
2023-07-26T16:25:02Z
@LybraFinance - please comment on your label.
#4 - c4-sponsor
2023-07-27T07:35:29Z
LybraFinance marked the issue as sponsor confirmed
#5 - c4-judge
2023-07-28T18:08:45Z
0xean marked the issue as satisfactory
#6 - c4-judge
2023-07-28T20:49:58Z
0xean marked the issue as selected for report
π Selected for report: bytes032
Also found by: 0xMAKEOUTHILL, 0xgrbr, 0xkazim, 0xnacho, Arz, Co0nan, CrypticShepherd, Cryptor, HE1M, Iurii3, LaScaloneta, LokiThe5th, LuchoLeonel1, MrPotatoMagic, Musaka, Qeew, RedTiger, SovaSlava, Toshii, Vagner, a3yip6, azhar, bart1e, devival, hl_, jnrlouis, kutugu, peanuts, pep7siup, qpzm, smaul
1.3247 USDC - $1.32
https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/LybraRETHVault.sol#L47 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/LybraWbETHVault.sol#L10
Due to the use of the wrong function in getAssetPrice -> getExchangeRatio
(which does not exist), the getAssetPrice will revert both in LybraRETHVault and LybraWbETHVault, causing the two contract to malfunction.
In LybraRETHVault when users deposit eth and try to mint PeUSD, they call depositEtherToMint. This function calculates their deposit amount and if they want to mint PeUSD it calls _mintPeUSD with amount and price.
Now the issue occurs when we call getAssetPrice
This function gets the ether price, RETH to ETH conversion ration and calculated the RETH price in USD. This implementation is faulty tho, because RETH does not have function called getExchangeRatio, they have getExchangeRate. This means that getAssetPrice
will call the fallback on RETH, if they have it, or revert all together.
function getAssetPrice() public override returns (uint256) { //@audit RETH does not have getExchangeRatio, they have getExchangeRate return (_etherPrice() * IRETH(address(collateralAsset)).getExchangeRatio()) / 1e18; }
One of the developers confirmed in discord that this is a misspelling of the original function, when asked about it. PIC
"The correct interface is getExchangeRate(). This was a mistake."
Same interface issue occurs also in LybraWbETHVault, where again IWBETH uses exchangeRatio, but the WBETH contract has exchangeRate. Also making LybraWbETHVault
not usable, since getAssetPrice will revert.
Manual review Foundry and HH
Change both interfaces and function implementations to use the correct names.
Other
#0 - c4-pre-sort
2023-07-04T13:32:12Z
JeffCX marked the issue as duplicate of #27
#1 - c4-judge
2023-07-28T17:14:12Z
0xean changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-07-28T17:15:30Z
0xean marked the issue as satisfactory