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: 62/132
Findings: 4
Award: $101.94
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/configuration/LybraConfigurator.sol#L85-L93 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/governance/GovernanceTimelock.sol#L25-L31
Any user can call configurator functions that only should be called by authorized accounts. This would allow any user to have complete control over the protocol. So, the user can mint tokens, transfer tokens from other users without having an allowance, change the flash loan fee, change the collateral ratio and pause the minting or burning of tokens from vaults, among other actions.
The two modifiers from LybraConfigurator contract calls two functions from GovernanceTimelock contract: https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/configuration/LybraConfigurator.sol#L85-L93
modifier onlyRole(bytes32 role) { GovernanceTimelock.checkOnlyRole(role, msg.sender); _; } modifier checkRole(bytes32 role) { GovernanceTimelock.checkRole(role, msg.sender); _; }
But this two functions only return a boolean, they don't revert in case of an unauthorized access: https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/governance/GovernanceTimelock.sol#L25-L31
function checkRole(bytes32 role, address _sender) public view returns(bool){ return hasRole(role, _sender) || hasRole(DAO, _sender); } function checkOnlyRole(bytes32 role, address _sender) public view returns(bool){ return hasRole(role, _sender); }
This means that the modifiers do nothing. They need to have a require in order to revert if the user is not authorized.
contract ConfiguratorTest is Test { address[] proposer = [makeAddr("proposer"), makeAddr("proposer2")]; address[] executer = [makeAddr("executer"), makeAddr("executer2")]; address admin = makeAddr("admin"); address _oracle = makeAddr("_oracle"); uint256 minDelay = 1 days; GovernanceTimelock governanceTimelock; Configurator configurator; address vault; PeUSD peusd; function setUp() public { vm.startPrank(admin); governanceTimelock = new GovernanceTimelock(minDelay, proposer, executer, admin); address curve = address(new mockCurve()); configurator = new Configurator(address(governanceTimelock), curve); vm.stopPrank(); } function testAccessControl() public { address hacker = makeAddr("hacker"); vm.startPrank(hacker); // Should revert but it doesn't configurator.initToken(vault, vault); configurator.setMintVault(hacker, true); configurator.setFlashloanFee(0); vm.stopPrank(); } }
None of this functions reverts, so the user can perform any action that requires special authorization.
Manual Review
Check the boolean and add a revert in case it didn't fit the condition
error UnauthorizedAccess(); modifier onlyRole(bytes32 role) { if(!GovernanceTimelock.checkOnlyRole(role, msg.sender)) revert UnauthorizedAccess(); _; } modifier checkRole(bytes32 role) { if(!GovernanceTimelock.checkRole(role, msg.sender)) revert UnauthorizedAccess(); _; }
Access Control
#0 - c4-pre-sort
2023-07-08T23:57:21Z
JeffCX marked the issue as duplicate of #704
#1 - c4-judge
2023-07-28T17:18:50Z
0xean marked the issue as satisfactory
🌟 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/pools/base/LybraEUSDVaultBase.sol#L30 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/LybraEUSDVaultBase.sol#L323-L325 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L265-L267 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/configuration/LybraConfigurator.sol#L331-L341
No user can be liquidated in a vault that inherits the LybraPeUSDVaultBase because inside the LybraConfigurator contract the badCollateralRatio cannot be set and the getBadCollateralRatio function performs an underflow.
In both kinds of base contract for vault, the vaultType is defined as immutable without the public keyword. This means that the variable can't be access from ouside the contract: https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraEUSDVaultBase.sol#L30 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L18
uint8 immutable vaultType = 0;
uint8 immutable vaultType = 1;
But at the end of both contracts, there is a getter call getVaultType: https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraEUSDVaultBase.sol#L323-L325 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L265-L267
function getVaultType() external pure returns (uint8) { return vaultType; }
The problem is that in the function setSafeCollateralRatio makes a call to a getter that doesn't exist in the vaults: https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/configuration/LybraConfigurator.sol#L199
function setSafeCollateralRatio(address pool, uint256 newRatio) external checkRole(TIMELOCK) { if(IVault(pool).vaultType() == 0) { require(newRatio >= 160 * 1e18, "eUSD vault safe collateralRatio should more than 160%"); } else { require(newRatio >= vaultBadCollateralRatio[pool] + 1e19, "PeUSD vault safe collateralRatio should more than bad collateralRatio"); } vaultSafeCollateralRatio[pool] = newRatio; emit SafeCollateralRatioChanged(pool, newRatio); }
This makes imposible to set the safeCollateralRatio because it will always revert. Even though when there is no safeCollateralRatio set the contract has a default value, the default badCollateralRatio is calculates in a way that underflows when the safeCollateralRatio is not defined.
function getSafeCollateralRatio( address pool ) external view returns (uint256) { if (vaultSafeCollateralRatio[pool] == 0) return 160 * 1e18; return vaultSafeCollateralRatio[pool]; } function getBadCollateralRatio(address pool) external view returns(uint256) { if(vaultBadCollateralRatio[pool] == 0) return vaultSafeCollateralRatio[pool] - 1e19; return vaultBadCollateralRatio[pool]; }
This causes a Denial of Service (DoS) in the liquidation function from the vaults that uses LybraPeUSDVaultBase as the base contract. This means that every time the liquidation function is called it's going to revert because of underflow and no user is gonna be liquidated ever. https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L128
POC:
contract ConfiguratorTest is Test { address[] proposer = [makeAddr("proposer"), makeAddr("proposer2")]; address[] executer = [makeAddr("executer"), makeAddr("executer2")]; address admin = makeAddr("admin"); uint256 minDelay = 1 days; GovernanceTimelock governanceTimelock; Configurator configurator; address rkPool = 0xDD3f50F8A6CafbE9b31a427582963f465E745AF8; address rETH = 0xae78736Cd615f374D3085123A210448E74Fc6393; address oracle = 0x4c517D4e2C851CA76d7eC94B805269Df0f2201De; address etherOracle = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; address vault; PeUSDMainnet peusd; esLBRBoost boost; EUSDMiningIncentives mintingIncentives; address firstUser = makeAddr("firstUser"); address secondUser = makeAddr("secondUser"); function setUp() public { vm.startPrank(admin); governanceTimelock = new GovernanceTimelock(minDelay, proposer, executer, admin); address curve = address(new mockCurve()); configurator = new Configurator(address(governanceTimelock), curve); peusd = new PeUSDMainnet(address(configurator), 18, address(0)); vault = address(new LybraRETHVault(address(peusd), address(configurator), rETH, oracle, rkPool)); boost = new esLBRBoost(); mintingIncentives = new EUSDMiningIncentives(address(configurator), address(boost), etherOracle, address(0)); configurator.setEUSDMiningIncentives(address(mintingIncentives)); configurator.setBorrowApy(vault, 100); configurator.setMintVaultMaxSupply(vault, 1000 ether); configurator.setMintVault(vault, true); vm.stopPrank(); } function testVaultType() public { vm.startPrank(firstUser); vm.expectRevert(); configurator.setSafeCollateralRatio(vault, 160 * 1e18); vm.expectRevert(); configurator.setBadCollateralRatio(vault, 140 * 1e18); vm.expectRevert(); configurator.getBadCollateralRatio(vault); LybraRETHVault(vault).depositEtherToMint{value: 100 ether}(100 ether); vm.stopPrank(); vm.startPrank(secondUser); LybraRETHVault(vault).depositEtherToMint{value: 1 ether}(1 ether); peusd.approve(vault, 1 ether); vm.expectRevert(); LybraRETHVault(vault).liquidation(secondUser, firstUser, 1e17); vm.stopPrank(); } }
Manual Review
Use the correct getter to obtain the vaultType: getVaultType()
function setSafeCollateralRatio(address pool, uint256 newRatio) external checkRole(TIMELOCK) { if(IVault(pool).getVaultType() == 0) { require(newRatio >= 160 * 1e18, "eUSD vault safe collateralRatio should more than 160%"); } else { require(newRatio >= vaultBadCollateralRatio[pool] + 1e19, "PeUSD vault safe collateralRatio should more than bad collateralRatio"); } vaultSafeCollateralRatio[pool] = newRatio; emit SafeCollateralRatioChanged(pool, newRatio); }
Additionally, you may want to use getSafeCollateralRatio function to avoid a possible underflow.
function getBadCollateralRatio(address pool) external view returns(uint256) { + if(vaultBadCollateralRatio[pool] == 0) return getSafeCollateralRatio(pool) - 1e19; - if(vaultBadCollateralRatio[pool] == 0) return vaultSafeCollateralRatio[pool] - 1e19; return vaultBadCollateralRatio[pool]; }
Under/Overflow
#0 - c4-pre-sort
2023-07-08T18:36:14Z
JeffCX marked the issue as duplicate of #882
#1 - c4-judge
2023-07-28T15:36:30Z
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
29.0567 USDC - $29.06
https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/governance/LybraGovernance.sol#L143-L149 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/governance/Governor.sol#L299-L305 https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/governance/Governor.sol#L218-L220
The voter has a window of only 3 blocks or 36 seconds to vote. An attacker can block this window of time via block stuffing to prevent users from voting and take down a proposal.
votingPeriod is defined as 3 blocks and votingDelay as 1 block: https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/governance/LybraGovernance.sol#L143-L149
function votingPeriod() public pure override returns (uint256){ return 3; } function votingDelay() public pure override returns (uint256){ return 1; }
When a proposal is made, the voteStart is calculated as the currentTimepoint (proposal block number) + votingDelay (1 block). The voteDuration is the votingPeriod (3 blocks): https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/governance/Governor.sol#L299-L305
uint256 snapshot = currentTimepoint + votingDelay(); uint256 duration = votingPeriod(); _proposals[proposalId] = ProposalCore({ proposer: proposer, voteStart: SafeCast.toUint48(snapshot), voteDuration: SafeCast.toUint32(duration), })
The proposal deadline is calculated as the voteStart plus the voteDuration. This means that the voters have only 3 blocks to vote. https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/governance/Governor.sol#L218-L220
function proposalDeadline(uint256 proposalId) public view virtual override returns (uint256) { return _proposals[proposalId].voteStart + _proposals[proposalId].voteDuration; }
One block equals 12 seconds, so three blocks are 36 seconds. This means that voters need to take knowleadge of a proposal and vote within the first minute it is proposed. If a user tries to vote after this 3 blocks have passed, the transaction is gonna be reverted.
This can allow an attacker to block this window of time via block stuffing and prevent users from voting.
contract ConfiguratorTest is Test { address[] proposer = [makeAddr("proposer"), makeAddr("proposer2")]; address[] executer = [makeAddr("executer"), makeAddr("executer2")]; address admin = makeAddr("admin"); uint256 minDelay = 1 days; GovernanceTimelock governanceTimelock; LybraGovernance lybraGovernance; Configurator configurator; address _oracle = makeAddr("_oracle"); address vault; ProtocolRewardsPool rewardsPool; PeUSD peusd; esLBR eslbr; LBR lbr; address player = makeAddr("player"); function setUp() public { vm.startPrank(admin); vm.roll(1); governanceTimelock = new GovernanceTimelock(minDelay, proposer, executer, admin); address curve = address(new mockCurve()); configurator = new Configurator(address(governanceTimelock), curve); eslbr = new esLBR(address(configurator)); lybraGovernance = new LybraGovernance("Gov", governanceTimelock, address(eslbr)); peusd = new PeUSD(18, address(0)); vault = address(new LybraStETHDepositVault(address(configurator), address(peusd), _oracle)); rewardsPool = new ProtocolRewardsPool(address(configurator)); configurator.setProtocolRewardsPool(address(rewardsPool)); address[] memory c = new address[](1); bool[] memory b = new bool[](1); c[0] = admin; b[0] = true; configurator.setTokenMiner(c, b); eslbr.mint(player, 1e23); vm.stopPrank(); } function testSmallVotingWindow() public { vm.startPrank(player); vm.roll(2); eslbr.delegate(player); vm.roll(4); address[] memory proposers = new address[](2); uint256[] memory values = new uint256[](2); bytes[] memory call = new bytes[](2); proposers[0] = makeAddr("propose1"); proposers[1] = makeAddr("propose2"); values[0] = 20; values[1] = 205; call[0] = abi.encode("function anything(uint256)", 45); call[1] = call[0]; string memory description = "testing"; uint256 projectId = lybraGovernance.propose(proposers, values, call, description); vm.roll(9); vm.expectRevert(); lybraGovernance.castVote(projectId, 1); } }
Manual Review
Make a wider voting window, at least 1 hour to vote (300 blocks).
Governance
#0 - c4-pre-sort
2023-07-04T14:14:11Z
JeffCX marked the issue as duplicate of #268
#1 - c4-judge
2023-07-28T15:43:57Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-07-28T19:46:30Z
0xean changed the severity to 2 (Med Risk)
🌟 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#L46-L48 https://github.com/rocket-pool/rocketpool/blob/master/contracts/contract/token/RocketTokenRETH.sol#L66-L68
The bad typo can cause a complete DoS in all the functions that calls getAssetPrice(). This includes some key functions like withdraw, deposit, mint, liquidation, among others.
The RETH vault gets the asset price calling the exchange rate from the collateral contract (RETH). The problem is that there is no function getExchangeRatio() inside the RETH contract: https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/pools/LybraRETHVault.sol#L46-L48
function getAssetPrice() public override returns (uint256) { return (_etherPrice() * IRETH(address(collateralAsset)).getExchangeRatio()) / 1e18; }
The function is actually called getExchangeRate(): https://github.com/rocket-pool/rocketpool/blob/master/contracts/contract/token/RocketTokenRETH.sol#L66-L68
function getExchangeRate() override external view returns (uint256) { return getEthValue(1 ether); }
This will cause a revert in every function that makes this bad call to the RETH contract.
POC:
contract ConfiguratorTest is Test { address[] proposer = [makeAddr("proposer"), makeAddr("proposer2")]; address[] executer = [makeAddr("executer"), makeAddr("executer2")]; address admin = makeAddr("admin"); uint256 minDelay = 1 days; GovernanceTimelock governanceTimelock; Configurator configurator; address rkPool = 0xDD3f50F8A6CafbE9b31a427582963f465E745AF8; address rETH = 0xae78736Cd615f374D3085123A210448E74Fc6393; address oracle = 0x4c517D4e2C851CA76d7eC94B805269Df0f2201De; address etherOracle = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; address vault; PeUSDMainnet peusd; esLBRBoost boost; EUSDMiningIncentives mintingIncentives; address user = makeAddr("user"); function setUp() public { vm.startPrank(admin); governanceTimelock = new GovernanceTimelock(minDelay, proposer, executer, admin); address curve = address(new mockCurve()); configurator = new Configurator(address(governanceTimelock), curve); peusd = new PeUSDMainnet(address(configurator), 18, address(0)); vault = address(new LybraRETHVault(address(peusd), address(configurator), rETH, oracle, rkPool)); boost = new esLBRBoost(); mintingIncentives = new EUSDMiningIncentives(address(configurator), address(boost), etherOracle, address(0)); configurator.setEUSDMiningIncentives(address(mintingIncentives)); configurator.setBorrowApy(vault, 100); configurator.setMintVaultMaxSupply(vault, 1000 ether); configurator.setMintVault(vault, true); vm.stopPrank(); } function testExchangeRate() public { vm.startPrank(user); vm.expectRevert(); LybraRETHVault(vault).depositEtherToMint{value: 100 ether}(100 ether); vm.stopPrank(); } }
Manual Review
Use the correct function name: getExchangeRate().
function getAssetPrice() public override returns (uint256) { - return (_etherPrice() * IRETH(address(collateralAsset)).getExchangeRatio()) / 1e18; + return (_etherPrice() * IRETH(address(collateralAsset)).getExchangeRate()) / 1e18; }
DoS
#0 - c4-pre-sort
2023-07-04T13:24:01Z
JeffCX marked the issue as duplicate of #27
#1 - c4-judge
2023-07-28T17:15:39Z
0xean marked the issue as satisfactory