From 85cb09d70660f19edb7d02967f486eedb5bf22e0 Mon Sep 17 00:00:00 2001 From: sherlock-admin2 <138802946+sherlock-admin2@users.noreply.github.com> Date: Sat, 21 Sep 2024 17:12:49 +0200 Subject: [PATCH] Uploaded files for judging --- .gitignore | 10 -- 001/021.md | 32 ++++ 001/034.md | 68 +++++++ 001/035.md | 118 ++++++++++++ 001/042.md | 34 ++++ 001/053.md | 38 ++++ 001/055.md | 148 ++++++++++++++++ 001/056.md | 38 ++++ 001/069.md | 65 +++++++ 001/073.md | 95 ++++++++++ 001/074.md | 263 +++++++++++++++++++++++++++ 001/078.md | 98 ++++++++++ 001/079.md | 61 +++++++ 001/085.md | 104 +++++++++++ 001/087.md | 92 ++++++++++ 001/090.md | 108 +++++++++++ 001/092.md | 51 ++++++ 001/096.md | 53 ++++++ 001/106.md | 80 +++++++++ 001/109.md | 205 +++++++++++++++++++++ 001/113.md | 58 ++++++ 001/114.md | 37 ++++ 001/122.md | 38 ++++ 001/128.md | 61 +++++++ 001/129.md | 453 +++++++++++++++++++++++++++++++++++++++++++++++ 001/130.md | 96 ++++++++++ 001/133.md | 65 +++++++ 001/134.md | 210 ++++++++++++++++++++++ 001/135.md | 110 ++++++++++++ 001/136.md | 80 +++++++++ 001/138.md | 54 ++++++ 001/144.md | 230 ++++++++++++++++++++++++ 001/146.md | 106 +++++++++++ 001/157.md | 87 +++++++++ 001/159.md | 31 ++++ 001/167.md | 35 ++++ 001/168.md | 38 ++++ 001/169.md | 52 ++++++ 002/004.md | 162 +++++++++++++++++ 002/011.md | 72 ++++++++ 002/012.md | 80 +++++++++ 002/015.md | 64 +++++++ 002/024.md | 82 +++++++++ 002/038.md | 60 +++++++ 002/077.md | 89 ++++++++++ 002/081.md | 61 +++++++ 002/093.md | 51 ++++++ 002/107.md | 98 ++++++++++ 002/110.md | 45 +++++ 002/117.md | 40 +++++ 002/119.md | 103 +++++++++++ 002/132.md | 46 +++++ 002/139.md | 39 ++++ 002/140.md | 40 +++++ 002/143.md | 73 ++++++++ 002/152.md | 31 ++++ 002/153.md | 80 +++++++++ 002/161.md | 128 +++++++++++++ 003/018.md | 67 +++++++ 003/030.md | 107 +++++++++++ 003/033.md | 36 ++++ 003/046.md | 117 ++++++++++++ 003/061.md | 58 ++++++ 003/068.md | 137 ++++++++++++++ 003/070.md | 24 +++ 003/076.md | 36 ++++ 003/095.md | 38 ++++ 003/098.md | 52 ++++++ 003/101.md | 38 ++++ 003/120.md | 178 +++++++++++++++++++ 003/125.md | 50 ++++++ 003/131.md | 78 ++++++++ 003/142.md | 38 ++++ 003/160.md | 26 +++ 003/162.md | 30 ++++ 004/002.md | 57 ++++++ 004/043.md | 164 +++++++++++++++++ 004/047.md | 106 +++++++++++ 004/049.md | 29 +++ 004/051.md | 111 ++++++++++++ 004/060.md | 207 ++++++++++++++++++++++ 004/064.md | 163 +++++++++++++++++ 004/067.md | 153 ++++++++++++++++ 004/137.md | 108 +++++++++++ 004/148.md | 53 ++++++ 005/006.md | 131 ++++++++++++++ 005/007.md | 100 +++++++++++ 005/023.md | 51 ++++++ 005/063.md | 55 ++++++ 005/071.md | 75 ++++++++ 005/080.md | 62 +++++++ 005/082.md | 29 +++ 005/118.md | 75 ++++++++ 005/150.md | 70 ++++++++ 006/037.md | 55 ++++++ 006/040.md | 82 +++++++++ 006/044.md | 83 +++++++++ 006/062.md | 64 +++++++ 006/072.md | 66 +++++++ 006/088.md | 42 +++++ 006/126.md | 64 +++++++ 006/170.md | 67 +++++++ 007/108.md | 153 ++++++++++++++++ 007/115.md | 147 +++++++++++++++ 007/154.md | 130 ++++++++++++++ 007/163.md | 139 +++++++++++++++ 008.md | 74 ++++++++ 008/005.md | 91 ++++++++++ 008/039.md | 43 +++++ 008/086.md | 40 +++++ 009.md | 47 +++++ 009/001.md | 50 ++++++ 009/032.md | 30 ++++ 010.md | 33 ++++ 010/003.md | 49 +++++ 010/020.md | 50 ++++++ 011/013.md | 54 ++++++ 011/014.md | 59 ++++++ 012/022.md | 52 ++++++ 012/141.md | 93 ++++++++++ 013/029.md | 45 +++++ 013/057.md | 70 ++++++++ 014/045.md | 125 +++++++++++++ 014/075.md | 30 ++++ 015/102.md | 42 +++++ 015/104.md | 42 +++++ 016.md | 67 +++++++ 017.md | 35 ++++ 019.md | 90 ++++++++++ 025.md | 70 ++++++++ 026.md | 31 ++++ 027.md | 43 +++++ 028.md | 41 +++++ 031.md | 71 ++++++++ 036.md | 31 ++++ 041.md | 53 ++++++ 048.md | 53 ++++++ 050.md | 29 +++ 052.md | 43 +++++ 054.md | 37 ++++ 058.md | 37 ++++ 059.md | 281 +++++++++++++++++++++++++++++ 065.md | 143 +++++++++++++++ 066.md | 21 +++ 083.md | 59 ++++++ 084.md | 36 ++++ 089.md | 40 +++++ 091.md | 42 +++++ 094.md | 20 +++ 097.md | 154 ++++++++++++++++ 099.md | 40 +++++ 100.md | 76 ++++++++ 103.md | 43 +++++ 105.md | 48 +++++ 111.md | 47 +++++ 112.md | 47 +++++ 116.md | 77 ++++++++ 121.md | 87 +++++++++ 123.md | 29 +++ 124.md | 25 +++ 127.md | 53 ++++++ 145.md | 6 + 147.md | 56 ++++++ 149.md | 46 +++++ 151.md | 39 ++++ 155.md | 41 +++++ 156.md | 138 +++++++++++++++ 158.md | 43 +++++ 164.md | 64 +++++++ 165.md | 35 ++++ 166.md | 41 +++++ invalid/.gitkeep | 0 172 files changed, 12764 insertions(+), 10 deletions(-) delete mode 100644 .gitignore create mode 100644 001/021.md create mode 100644 001/034.md create mode 100644 001/035.md create mode 100644 001/042.md create mode 100644 001/053.md create mode 100644 001/055.md create mode 100644 001/056.md create mode 100644 001/069.md create mode 100644 001/073.md create mode 100644 001/074.md create mode 100644 001/078.md create mode 100644 001/079.md create mode 100644 001/085.md create mode 100644 001/087.md create mode 100644 001/090.md create mode 100644 001/092.md create mode 100644 001/096.md create mode 100644 001/106.md create mode 100644 001/109.md create mode 100644 001/113.md create mode 100644 001/114.md create mode 100644 001/122.md create mode 100644 001/128.md create mode 100644 001/129.md create mode 100644 001/130.md create mode 100644 001/133.md create mode 100644 001/134.md create mode 100644 001/135.md create mode 100644 001/136.md create mode 100644 001/138.md create mode 100644 001/144.md create mode 100644 001/146.md create mode 100644 001/157.md create mode 100644 001/159.md create mode 100644 001/167.md create mode 100644 001/168.md create mode 100644 001/169.md create mode 100644 002/004.md create mode 100644 002/011.md create mode 100644 002/012.md create mode 100644 002/015.md create mode 100644 002/024.md create mode 100644 002/038.md create mode 100644 002/077.md create mode 100644 002/081.md create mode 100644 002/093.md create mode 100644 002/107.md create mode 100644 002/110.md create mode 100644 002/117.md create mode 100644 002/119.md create mode 100644 002/132.md create mode 100644 002/139.md create mode 100644 002/140.md create mode 100644 002/143.md create mode 100644 002/152.md create mode 100644 002/153.md create mode 100644 002/161.md create mode 100644 003/018.md create mode 100644 003/030.md create mode 100644 003/033.md create mode 100644 003/046.md create mode 100644 003/061.md create mode 100644 003/068.md create mode 100644 003/070.md create mode 100644 003/076.md create mode 100644 003/095.md create mode 100644 003/098.md create mode 100644 003/101.md create mode 100644 003/120.md create mode 100644 003/125.md create mode 100644 003/131.md create mode 100644 003/142.md create mode 100644 003/160.md create mode 100644 003/162.md create mode 100644 004/002.md create mode 100644 004/043.md create mode 100644 004/047.md create mode 100644 004/049.md create mode 100644 004/051.md create mode 100644 004/060.md create mode 100644 004/064.md create mode 100644 004/067.md create mode 100644 004/137.md create mode 100644 004/148.md create mode 100644 005/006.md create mode 100644 005/007.md create mode 100644 005/023.md create mode 100644 005/063.md create mode 100644 005/071.md create mode 100644 005/080.md create mode 100644 005/082.md create mode 100644 005/118.md create mode 100644 005/150.md create mode 100644 006/037.md create mode 100644 006/040.md create mode 100644 006/044.md create mode 100644 006/062.md create mode 100644 006/072.md create mode 100644 006/088.md create mode 100644 006/126.md create mode 100644 006/170.md create mode 100644 007/108.md create mode 100644 007/115.md create mode 100644 007/154.md create mode 100644 007/163.md create mode 100644 008.md create mode 100644 008/005.md create mode 100644 008/039.md create mode 100644 008/086.md create mode 100644 009.md create mode 100644 009/001.md create mode 100644 009/032.md create mode 100644 010.md create mode 100644 010/003.md create mode 100644 010/020.md create mode 100644 011/013.md create mode 100644 011/014.md create mode 100644 012/022.md create mode 100644 012/141.md create mode 100644 013/029.md create mode 100644 013/057.md create mode 100644 014/045.md create mode 100644 014/075.md create mode 100644 015/102.md create mode 100644 015/104.md create mode 100644 016.md create mode 100644 017.md create mode 100644 019.md create mode 100644 025.md create mode 100644 026.md create mode 100644 027.md create mode 100644 028.md create mode 100644 031.md create mode 100644 036.md create mode 100644 041.md create mode 100644 048.md create mode 100644 050.md create mode 100644 052.md create mode 100644 054.md create mode 100644 058.md create mode 100644 059.md create mode 100644 065.md create mode 100644 066.md create mode 100644 083.md create mode 100644 084.md create mode 100644 089.md create mode 100644 091.md create mode 100644 094.md create mode 100644 097.md create mode 100644 099.md create mode 100644 100.md create mode 100644 103.md create mode 100644 105.md create mode 100644 111.md create mode 100644 112.md create mode 100644 116.md create mode 100644 121.md create mode 100644 123.md create mode 100644 124.md create mode 100644 127.md create mode 100644 145.md create mode 100644 147.md create mode 100644 149.md create mode 100644 151.md create mode 100644 155.md create mode 100644 156.md create mode 100644 158.md create mode 100644 164.md create mode 100644 165.md create mode 100644 166.md create mode 100644 invalid/.gitkeep diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3fbffbb..0000000 --- a/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -* -!*/ -!/.data -!/.github -!/.gitignore -!/README.md -!/comments.csv -!*.md -!**/*.md -!/Audit_Report.pdf diff --git a/001/021.md b/001/021.md new file mode 100644 index 0000000..8bfa774 --- /dev/null +++ b/001/021.md @@ -0,0 +1,32 @@ +Scrawny Iron Tiger + +High + +# ETH Locked in Vault Due to Rounding Errors in Fee Distribution + +## Summary +The LidoVault contract manages deposits, staking through Lido, and distributes rewards and early exit fees between variable and fixed users. When fixed users withdraw before the vault ends, they are charged early exit fees, which are then distributed to variable users and the protocol fee receiver. However, due to the lack of floating-point arithmetic in Solidity, rounding errors in fee calculations cause small amounts of ETH to be left behind in the contract, permanently locking those funds. + +## Vulnerability Detail +The issue arises in the calculateVariableWithdrawState and calculateVariableWithdrawStateWithUser functions, where the following calculation is used to distribute early exit fees: +uint256 totalOwed = bearerBalance.mulDiv(totalEarnings, variableSideCapacity); + +Here, Solidity’s integer division causes the remainder from the division (bearerBalance * totalEarnings % variableSideCapacity) to be discarded. This means small fractions of ETH are not distributed to any user but remain in the contract. + +Over time, as more transactions occur, these undistributed remainders accumulate, leading to permanently locked ETH in the vault. Because the fees are already moved from Lido to the vault, these remainders remain in the vault with no mechanism to recover them. + +## Impact +This vulnerability leads to small but gradually increasing amounts of ETH being locked in the contract, potentially reducing the overall ETH distributed to users. The longer the contract operates, the larger this locked ETH becomes, representing a loss for users and potentially affecting the correct distribution of rewards and fees. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L890 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L867 + +## Tool used + +Manual Review + +## Recommendation +To prevent ETH from being locked in the vault due to rounding errors, consider leaving early exit fees in Lido instead of transferring them to the vault. Since the Lido contract manages most of the vault's assets, keeping the early exit fees in Lido would allow the fees to accrue and be distributed without suffering from rounding errors in the vault's internal calculations. + +Alternatively, consider implementing a mechanism to recover and redistribute the small remainder amounts that are currently left behind due to Solidity's integer division. This could involve a periodic sweep or redistribution of remaining ETH to active users. \ No newline at end of file diff --git a/001/034.md b/001/034.md new file mode 100644 index 0000000..2d26178 --- /dev/null +++ b/001/034.md @@ -0,0 +1,68 @@ +Rural Fuchsia Starfish + +Medium + +# Calls To `vaultEndedWithdraw()` Can Revert Through Underflow Preventing Finalization + +### Summary + +Some outcomes of the `LidoVault` can lead to a `totalEarnings` calculation that results in `revert` during variable side withdrawal. + +This prevents a full term variable side from ever realizing their earnings in accrued fees. + +### Root Cause + +The following [`totalEarnings` calculation](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L775), executed in calls to [`vaultEndedWithdraw(uint256)`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L709C12-L709C44) is liable to integer underflow: + +```solidity +uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes, vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; + +if (totalEarnings > 0) { + // ... +``` + +Although we safely handle the case where `totalEarnings` may not evaluate to zero, we do not handle the edge case where `totalProtocolFee`s are in excess of the `vaultEndingETHBalance` (i.e. through slashing). + +Notice then, that for the case where `vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes, vaultEndingStakesAmount)` is less than the `totalProtocalFee`, this calculation will revert through underflow, i.e.: + +```solidity +uint256 a = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes, vaultEndingStakesAmount); +uint256 b = totalProtocolFee; +uint256 totalEarnings = b - a + vaultEndedStakingEarnings; /// @audit a > b will revert +``` + +For the case where `a` is greater than `b`, this calculation will revert, preventing successful completion of the `vaultEndedWithdraw(uint256)`. + +Although the vault may be finalized by a fixed side participant, no variable side accounts will be able to [progress far enough to successfully redeem their share of the fee shares](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L787C7-L797C1). + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. A `LidoVault` `isEnded()` with a value of `vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes, vaultEndingStakesAmount)` which is less than the `totalProtocalFee`. + +### Impact + +Inability for all variable side withdrawals to finalize the vault and [claim their share of fees](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L787C7-L797C1) due to DoS. + +### PoC + +_No response_ + +### Mitigation + +Sanity check the calculation before attempting the subtraction: + +```diff +- uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes, vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; ++ uint256 shareOfEndingBalance = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes, vaultEndingStakesAmount) + vaultEndedStakingEarnings; ++ uint256 totalEarnings = shareOfEndingBalance > totalProtocolFee ++ ? shareOfEndingBalance - totalProtocolFee ++ : 0; +``` \ No newline at end of file diff --git a/001/035.md b/001/035.md new file mode 100644 index 0000000..0f89bd6 --- /dev/null +++ b/001/035.md @@ -0,0 +1,118 @@ +Rural Fuchsia Starfish + +Medium + +# Variable Fee Earnings Share Calculation Overvalues Variable Side Withdrawal's Claim To Fees + +### Summary + +When performing a variable side withdraw, the withdrawer's claim to the [`feeShareAmount`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L787C7-L797C1) is amplified through mistaking the effect of charging the `protocolFee` as debt owed to the caller. + +### Root Cause + +When a variable side withdrawal is executed, [their staking earnings are computed as follows](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L536C13-L540C88): + +```solidity +withdrawnStakingEarnings += ethAmountOwed - protocolFee; /// @audit earnings inclusive of fees +withdrawnStakingEarningsInStakes += stakesAmountOwed; /// @audit earnings in shares not inclusive of fees + +variableToWithdrawnStakingEarnings[msg.sender] += ethAmountOwed - protocolFee; /// @audit earnings inclusive of fees +variableToWithdrawnStakingEarningsInShares[msg.sender] += stakesAmountOwed; /// @audit earnings in shares not inclusive of fees +``` + +This in itself is not a problem, because the `variableToWithdrawnStakingEarnings` is designed to track the specific accounting for a user's withdrawal (subject to protocol fees), whereas the `variableToWithdrawnStakingEarningsInShares` is designed to represent the user's total claim to shares at the point the withdrawal was made. + +Their total claim to shares (non-inclusive of fees) is used to determine the `msg.sender`'s total share of the accumulated fees at vault finalization. + +To describe this concept, notice that in `getCalculateVariableWithdrawStateWithStakingBalance(uint256)`, [a staker's `previousWithdrawnAmount` is amplified](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L885C5-L885C118) to undo the effects of the protocol fee and ensure the calculation is performed in fixed terms of their total shares of the vault at withdrawal: + +```solidity +/// @audit Amplify to undo the effects of the protocol fee so we +/// @audit can reliably relate the `variableToWithdrawnStakingEarnings` +/// @audit to as a function of the user's total share of the vault. +uint256 previousWithdrawnAmount = variableToWithdrawnStakingEarnings[user].mulDiv(10000, 10000 - protocolFeeBps); +``` + +Now, notice that to calculate the `msg.sender`'s `feeShareAmount`, we make a subsequent call [`calculateVariableFeeEarningsShare`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L787C7-L790C8): + +```solidity +uint256 feeShareAmount = 0; +if (withdrawnFeeEarnings + feeEarnings > 0) { + feeShareAmount = calculateVariableFeeEarningsShare(); +} +``` + +Diving into the function, we see the following: + +```solidity +function calculateVariableFeeEarningsShare() internal returns (uint256) { + (uint256 currentState, uint256 feeEarningsShare) = calculateVariableWithdrawState( + feeEarnings + withdrawnFeeEarnings, /// @audit Calculating the total fees accrued across all shares. +@> variableToWithdrawnFees[msg.sender] /// @audit Using the value inclusive of fees. + ); + + variableToWithdrawnFees[msg.sender] = currentState; + withdrawnFeeEarnings += feeEarningsShare; + feeEarnings -= feeEarningsShare; + + return feeEarningsShare; +} +``` + +Therefore, the protocol uses a value of `variableToWithdrawnFees[msg.sender]` which decreases the `previousWithdrawnAmount` in calls to [`calculateVariableWithdrawState`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L859C12-L859C42), since **this is the value inclusive of fees**: + +```solidity +function calculateVariableWithdrawState( + uint256 totalEarnings, + uint256 previousWithdrawnAmount /// @audit Using a lower value since fees were taken from this amount. +) internal view returns (uint256, uint256) { + + uint256 bearerBalance = variableBearerToken[msg.sender]; + require(bearerBalance > 0, "NBT"); + + uint256 totalOwed = bearerBalance.mulDiv(totalEarnings, variableSideCapacity); + uint256 ethAmountOwed = 0; + if (previousWithdrawnAmount < totalOwed) { + /// @audit Increases perceived `ethAmountOut` since fees make it + /// @audit look like the `msg.sender` has withdrawn less, but these + /// @audit were actually fees: + ethAmountOwed = totalOwed - previousWithdrawnAmount; + } + + return (ethAmountOwed + previousWithdrawnAmount, ethAmountOwed); +} +``` + +In doing so, the `LidoVault` believes the claimant to have a greater claim to the vault, when in actuality these additional claims are representative of protocol fees. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Variable size withdrawals receive undue claims to fee shares. + +### PoC + +_No response_ + +### Mitigation + +Amplify the `previousWithdrawAmount` by undoing the affects of fees when we [`calculateVariableFeeEarningsShare`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L946C12-L946C45): + +```diff +(uint256 currentState, uint256 feeEarningsShare) = calculateVariableWithdrawState( + feeEarnings + withdrawnFeeEarnings, +- variableToWithdrawnFees[msg.sender] ++ variableToWithdrawnFees[msg.sender].mulDiv(10000, 10000 - protocolFeeBps) +); +``` \ No newline at end of file diff --git a/001/042.md b/001/042.md new file mode 100644 index 0000000..87d20bf --- /dev/null +++ b/001/042.md @@ -0,0 +1,34 @@ +Scrawny Iron Tiger + +High + +# Locked Ether Accumulation in Vault After Withdrawal Process + +## Summary +In the provided contract, there is an issue with locked Ether accumulating in the vault after users withdraw their funds. This happens because fractional amounts of Ether resulting from the calculation involving fixedBearerToken[msg.sender], vaultEndedFixedDepositsFunds, and fixedLidoSharesTotalSupply() are effectively being locked and cannot be withdrawn by the users. Over time, this will accumulate in the vault, and if multiple vaults are created, the amount of Ether locked in the system could grow substantially. + +## Vulnerability Detail +sendAmount = fixedBearerToken[msg.sender].mulDiv( + vaultEndedFixedDepositsFunds, + fixedLidoSharesTotalSupply() +); + +Each user can withdraw only the integer part of this calculation, and the remainder is effectively lost and accumulated in the vault. Over time, as more users withdraw and as more vaults are created, these small locked amounts can accumulate into significant values of Ether that are permanently locked. + +## Impact +Loss of Funds: Users may lose fractional amounts of Ether on each withdrawal, leading to a cumulative loss of Ether in the vault. +Locked Ether: Over time, the vault will accumulate non-negligible amounts of Ether, which will be locked and inaccessible to users. +System Inefficiency: Ether that is locked in the vault cannot be used or withdrawn, reducing the efficiency of the system. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L748-L751 + +## Tool used + +Manual Review + +## Recommendation +Consider to make function to move rest ethers to lido. +You can use selfdestruct function. +At the end of vault scenario, owner of vault can call selfdestruct(address(lido)) and system can collect rest ethers. diff --git a/001/053.md b/001/053.md new file mode 100644 index 0000000..7caa8ce --- /dev/null +++ b/001/053.md @@ -0,0 +1,38 @@ +Chilly Amethyst Griffin + +High + +# wrong equation when calculating the profit + +### Summary + +in LidoVault function withdraw line 528 the function **calculateVariableWithdrawState** takes two argument **totalEarnings** and **previousWithdrawnAmount** and based on that and the user **variableBearerToken** it calculates the amount of earning and the problem is that when calculating **totalEarnings** it uses `(lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes)` where lidoStETHBalance is the StETH amount of the contract **currentStakes** is the ldo share of the contract and **withdrawnStakingEarningsInStakes** is the previous requested amount of shares and it adds **currentStakes** and **withdrawnStakingEarningsInStakes** but instead it should subtract in order to not count the previous withdrawal as a profit but in here it is adding which will even add more profit + +### Root Cause + +in LidoVault.sol:528 it adds currentStakes + withdrawnStakingEarningsInStakes instead it should subtract +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L527 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + + loss of fund as it give a false profit and based on that it calculates the amount to give for user + +### PoC + +_No response_ + +### Mitigation + +subtract instead of adding \ No newline at end of file diff --git a/001/055.md b/001/055.md new file mode 100644 index 0000000..fb8f7e6 --- /dev/null +++ b/001/055.md @@ -0,0 +1,148 @@ +Passive Denim Yeti + +High + +# The shares equivalent of amount entitled to the protocolFeeReceiver is included in the common variableToWithdrawnStakingEarningsInShares[msg.sender] and withdrawnStakingEarningsInStakes + +## Summary +*There's a problem in the `withdraw` when acting on a variable-side depositor behalf.* + +It's not accurate to include the *shares equivalent of the `protocolFee` amount that the `protocolFeeReceiver` is **entitled to*** neither in `variableToWithdrawnStakingEarningsInShares[msg.sender]` nor in `withdrawnStakingEarningsInStakes` when in-progress (`isStarted() == true`) withdrawing on the ***VARIABLE*** side. + +## Vulnerability Detail +That is because roughly *5%* of the `ethAmountOwed` (a variable representing the Lido earnings that the variable-side depositor is entitled to) is **entitled** to the `protocolFeeReceiver`. + +## Impact +In reality for the calculations for the *variable-side depositor* **vault-is-started-and-is-not-ended** withdrawal flow to be accurate, the `protocolFee` should not only be deducted from the `ethAmountOwed` in general, but also from the shares calculation. + +It is evident because all the state-keeping changes applied **do deduct (exclude)** the `protocolFee` from the `ethAmountOwed`, such as in: +`variableToWithdrawnStakingEarnings[msg.sender] += ethAmountOwed - protocolFee;` +and +`withdrawnStakingEarnings += ethAmountOwed - protocolFee;`. + +There will be an imbalance between the assets quantity tracked and the shares quantity tracked, because the shares we apply in `variableToWithdrawnStakingEarningsInShares[msg.sender] += stakesAmountOwed;` and `withdrawnStakingEarningsInStakes += stakesAmountOwed;` are based on the ***full amount of the `ethAmountOwed`***, not the `ethAmountOwed - protocolFee`. + +Namely the problem happens because in reality the shares caculation `uint256 stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed);` should be based on the `ethAmountOwed - protocolFee`, not the **full pre-protocol-fee-distribution initial amount**. + +```solidity + if (ethAmountOwed >= minStETHWithdrawalAmount()) { + // estimate protocol fee and update total - will actually be applied on withdraw finalization + uint256 protocolFee = ethAmountOwed.mulDiv(protocolFeeBps, 10000); + totalProtocolFee += protocolFee; + uint256 stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed); // HERE the amount passed (ethAmountOwed) should be ethAmountOwed - protocolFee + + withdrawnStakingEarnings += ethAmountOwed - protocolFee; + withdrawnStakingEarningsInStakes += stakesAmountOwed; + + variableToWithdrawnStakingEarnings[msg.sender] += ethAmountOwed - protocolFee; + variableToWithdrawnStakingEarningsInShares[msg.sender] += stakesAmountOwed; + variableToWithdrawnProtocolFee[msg.sender] += protocolFee; + variableToVaultOngoingWithdrawalRequestIds[msg.sender] = requestWithdrawViaETH( + msg.sender, + ethAmountOwed + ); + + emit LidoWithdrawalRequested( + msg.sender, + variableToVaultOngoingWithdrawalRequestIds[msg.sender], + VARIABLE, + true, + false + ); + } +``` + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L530-L553 + +You can also see that the `protocolFee` is ***correctly excluded from what the withdrawer (ex.-depositor) receives as ETH after the stEth withdrawal is fulfilled by Lido's withdrawal queue system***: +```solidity + /// @notice Finalize a variable withdrawal that was requested after the vault has started + function finalizeVaultOngoingVariableWithdrawals() external { + uint256[] memory requestIds = variableToVaultOngoingWithdrawalRequestIds[msg.sender]; + if(variableToPendingWithdrawalAmount[msg.sender] != 0) { + withdrawAmountVariablePending(); // is it re-entrant? + if(requestIds.length == 0) { + return; + } + + } + + require(requestIds.length != 0, "WNR"); + + delete variableToVaultOngoingWithdrawalRequestIds[msg.sender]; + + uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); + + + uint256 protocolFee = applyProtocolFee(amountWithdrawn); + + uint256 sendAmount = amountWithdrawn + calculateVariableFeeEarningsShare() - protocolFee; + + bool _isEnded = isEnded(); + transferWithdrawnFunds(msg.sender, sendAmount); + + emit LidoWithdrawalFinalized(msg.sender, requestIds, VARIABLE, true, _isEnded); + emit VariableFundsWithdrawn(sendAmount, msg.sender, true, _isEnded); + } +``` + +--- + +#### So it's clearly not correct to include the proportion of `protocolFee`'s shares in the `` calculation. That issue consequently makes the following calculation inaccurate. + +Both: +```soidity +(lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits), +``` +and +```solidity +variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes) +``` +in +``` +(uint256 currentState, uint256 ethAmountOwed) = calculateVariableWithdrawState( + (lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits), + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes) + ); +``` + +And correspondingly the other calculations too: +```solidity + /// @notice Helper function to calculate the ongoing variable withdraw state for user + /// The vault must track a variable user's withdrawals during the duration of the vault since withdrawals can be executed at any time + /// @param totalEarnings Amount of staking or fee earnings + /// @param previousWithdrawnAmount The total amount of earnings already withdrawn + /// @param user for whom the calculation + /// @return (currentState, amountOwed) The new total amount of earnings withdrawn, the amount to withdraw + function calculateVariableWithdrawStateWithUser( + uint256 totalEarnings, + uint256 previousWithdrawnAmount, + address user + ) internal view returns (uint256, uint256) { + + uint256 bearerBalance = variableBearerToken[user]; + require(bearerBalance > 0, "NBT"); + + uint256 totalOwed = bearerBalance.mulDiv(totalEarnings, variableSideCapacity); + uint256 ethAmountOwed = 0; + if (previousWithdrawnAmount < totalOwed) { + ethAmountOwed = totalOwed - previousWithdrawnAmount; + } + + return (ethAmountOwed + previousWithdrawnAmount, ethAmountOwed); + } +``` + + +There's an inaccuracy particularly at this line: https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L534 + +## Tool used +Manual review. + +## Recommendation +HERE the amount passed (`ethAmountOwed`) should be `ethAmountOwed - protocolFee`: +```diff +- uint256 stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed); ++ uint256 stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed - protocolFee); +``` \ No newline at end of file diff --git a/001/056.md b/001/056.md new file mode 100644 index 0000000..989ca33 --- /dev/null +++ b/001/056.md @@ -0,0 +1,38 @@ +Chilly Amethyst Griffin + +High + +# in LidoVault line 527 fixedETHDeposits will not always have a correct value + +### Summary + +in LidoVault line 527 the function **calculateVariableWithdrawState** is used to calculate the earning amount and it takes two arguments one **totalEarnings** and **previousWithdrawnAmount** and based on that it calculates the amount that will be withdrawn and the problem applies when calculating **totalEarnings** it uses `lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits` and the problem is in **fixedETHDeposits** because **fixedETHDeposits** is **fixedSidestETHOnStartCapacity** and in line 499 we can see that if the deposit type is **Fixed** it subtracts the withdrawal amount from **fixedSidestETHOnStartCapacity** but we can't note that in order to clime from lido we need to call the function **finalizeVaultOngoingFixedWithdrawals** so lets say a user requested a Fixed type withdrawal but doesn't call **finalizeVaultOngoingFixedWithdrawals** so **stakingBalance** will remain the same but **fixedSidestETHOnStartCapacity** will be updated so in line 528 when calculating the **totalEarninig** it will count the subtracted or the requested withdrawal amount from the previous as a profit + +### Root Cause + +in LidoVault line 527 it uses fixedETHDeposits //fixedSidestETHOnStartCapacity but if **finalizeVaultOngoingFixedWithdrawals** is not called it will not be correct amount +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L527 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +loss of fund as it calculates a wrong profit for a user to withdraw + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/001/069.md b/001/069.md new file mode 100644 index 0000000..00a1085 --- /dev/null +++ b/001/069.md @@ -0,0 +1,65 @@ +Bald Tawny Cottonmouth + +High + +# Fixed side users ETH token balances are not deducted when vault is ongoing & ended. + +### Summary + +The fixed-side user can withdraw their deposited ETH amount via the `withdraw` function. However, when the vault is ongoing, they receive an amount deducted by the `earlyExitFees`, but they could receive the principal amount + upfront amount if they withdraw after the vault has ended. + +The problem is that, in both scenarios, the fixed user's deposited ETH and total ETH deposit balances are not deducted during withdrawal. + +### Root Cause + +During withdrawal, whether the vault is ongoing or ended, the ETH balances are not deducted. +During finalize withdrawal txn the ETH balances are not also deducted. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +In both scenarios, the ETH balance should be deducted. + +### Attack Path + +_No response_ + +### Impact + +The fixed users' ETH deposit balance and the protocol's total ETH balance will always include amounts that no longer exist in the system. +1 - Other fixed-side users would not be able to deposit as much as they should due to: +```solidity +require(amount <= fixedSideCapacity - fixedETHDepositTokenTotalSupply, "OED"); + // do not allow remaining capacity to be less than minimum fixed deposit bps + uint256 remainingCapacity = fixedSideCapacity - fixedETHDepositTokenTotalSupply - amount; + require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); +``` +2 - The vault might unfairly start , since their deposit will count towards reaching the fixed capacity +```solidity +if ( + fixedETHDepositTokenTotalSupply == fixedSideCapacity && + variableBearerTokenTotalSupply == variableSideCapacity + ) + ``` + +3 - The internal accounting logic will be break. + +### PoC + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L472C1-L513C8 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L732C5-L762C6 + +### Mitigation + +Consider deducting the ETH balances in both scenarios. + +Add following in both cases: + +```diff ++ fixedETHDepositTokenTotalSupply -= fixedETHDepositToken[msg.sender]; ++ fixedETHDepositToken[msg.sender] = 0; +``` \ No newline at end of file diff --git a/001/073.md b/001/073.md new file mode 100644 index 0000000..2f304ff --- /dev/null +++ b/001/073.md @@ -0,0 +1,95 @@ +Itchy Pebble Piranha + +High + +# `LidoVault::vaultEndedWithdraw` doesn't take into consideration income withdrawals before slashing, blocking variable users from withdrwing their income + +### Summary + +When FIXED users deposit ETH, they are being deposited in Lido, and Lido might experience slashing. This is expected on the protocol's side, as the impact would be lower income, but it is expected for the protocol to keep functioning as expected, from the contest README: +>These incidents will decrease income from deposits to the Lido Liquid Staking protocol and could decrease the stETH balance. The contract must be operational after it, but it is acceptable for users to lose part of their income/deposit... + +However, this isn't always preserved, let's take the following scenario. We have some FIXED value staked in Lido, some profit is accumulated, VARIABLE user withdraws his cut of that profit, by calling `LidoVault::withdraw`. When doing so, `withdrawnStakingEarningsInStakes` gets updated to reflect the amount of withdrawn profit shares, but, this value is calculated after some profit. No more profit comes in, and the vault ends, as soon as it ends, before any withdrawals, the vault gets slashed with some amount. +Now, when variable users come to withdraw their profit (slashing didn't remove the whole profit), `totalEarnings` will be calculated wrongly, as the following: +```solidity +uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes, vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; +``` +As the used `vaultEndingETHBalance` and `vaultEndingStakesAmount` represent the amounts after slashing, while `withdrawnStakingEarningsInStakes` represents the withdrawn shares before slashing. + +This results in wrong `totalEarnings` that also result in wrong `stakingEarningsShare` value for the VARIABLES users, `stakingEarningsShare` will be greater than the contract's balance, forcing funds to be stuck forever, as `transferWithdrawnFunds` will revert. + +### Root Cause + +When calculating the total earned ETH in `LidoVault::vaultEndedWithdraw`, the protocol doesn't take into consideration the slashing that happened after the vault ended, especially when some VARIABLE users withdrew part of their profit while the vault was still ongoing. `withdrawnStakingEarningsInStakes` will be a misleading value from the previous profit before being slashed. +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L775 + +### Impact + +DOS, variable users can't withdraw their income from the FIXED amount staked. + +### PoC + +Add the following test in `lido-fiv/test/1.LidoVault.test.ts`: + +```typescript +it("BUG - DOS, can't withdraw after Slashing", async () => { + const { lidoVault, addr1, addr2, addr3 } = await loadFixture(deployLidoVaultFixture) + const { lidoMock } = await setupMockLidoContracts(lidoVault) + + // Users deposit FIXED and VARIABLE + await lidoVault.connect(addr1).deposit(SIDE.FIXED, { value: parseEther('1000') }) + await lidoVault.connect(addr2).deposit(SIDE.VARIABLE, { value: parseEther('15') }) + await lidoVault.connect(addr3).deposit(SIDE.VARIABLE, { value: parseEther('15') }) + + // Vault has started + expect(await lidoVault.isStarted()).to.equal(true) + + // User 1 claims FIXED premium + await lidoVault.connect(addr1).claimFixedPremium() + + // Half time passes + const { duration, endTime } = await getTimeState(lidoVault) + await time.increaseTo(endTime - duration / BigInt(2)) + + // Lido rebasing, vault earns 100 ETH + await lidoMock.addStakingEarningsForTargetETH( + parseEther('1100'), + await lidoVault.getAddress() + ) + + // User 2 withdraws their income (part of the above rebasing) + await lidoVault.connect(addr2).withdraw(SIDE.VARIABLE) + + // Withdrawal was sent to Lido + expect( + (await lidoVault.getVariableToVaultOngoingWithdrawalRequestIds(addr2.address)).length + ).to.equal(1) + // `withdrawnStakingEarningsInStakes` is now > 0 + expect(await lidoVault.withdrawnStakingEarningsInStakes()).to.be.greaterThan(0) + + // End time passes + await time.increaseTo(endTime + BIG_INT_ONE) + + // Vault is ended + expect(await lidoVault.isEnded()).to.equal(true) + + // Lido slashes the vault + await lidoMock.subtractStakingEarnings(parseEther('50')) + + // User 1 withdraws their FIXED deposit + await lidoVault.connect(addr1).withdraw(SIDE.FIXED) + await lidoVault.connect(addr1).finalizeVaultEndedWithdrawals(SIDE.FIXED) + + // User 3 can't withdraw his income + await expect( + lidoVault.connect(addr3).finalizeVaultEndedWithdrawals(SIDE.VARIABLE) + ).to.be.revertedWith('ETF') +}) +``` + +### Mitigation + +In `LidoVault::vaultEndedWithdraw`, when calculating the `totalEarnings` when a variable user is withdrawing, consider the income that was withdrawn before Lido slashing happens. Maybe have something like the following? +```solidity +totalEarnings = Math.min(totalEarnings, vaultEndingETHBalance); +``` \ No newline at end of file diff --git a/001/074.md b/001/074.md new file mode 100644 index 0000000..d5d1aa4 --- /dev/null +++ b/001/074.md @@ -0,0 +1,263 @@ +Cuddly Scarlet Antelope + +High + +# Variable side users will pay more protocolFee than they have to + +## Summary + +The `protocolFee` is charged from the yield, which is being distributed to the `VARIABLE` side. The problem is that currently the protocol has implemented this in such way where the `VARIABLE` users will have to pay much more `protocolFee` than needed because the math is wrong. + +## Vulnerability Detail + +We will go through a protocol flow where some variable users just withdraw when the vault is ongoing to collect part of their earnings, while others just wait for the end. + +The users who want to withdraw on chunks will just call the withdraw() function and they will end up in the if statement for the ongoing vault here: + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L533 + +```solidity + function withdraw(uint256 side) external { + + ... MORE CODE + + else { + if (msg.sender == protocolFeeReceiver && appliedProtocolFee > 0) { + + require(variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0, "WAR"); + return protocolFeeReceiverWithdraw(); + } + + if (variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0) { + uint256 lidoStETHBalance = stakingBalance(); + uint256 fixedETHDeposits = fixedSidestETHOnStartCapacity; + + // staking earnings have accumulated on Lido + if (lidoStETHBalance > fixedETHDeposits + minStETHWithdrawalAmount()) { + + uint256 currentStakes = stakingShares(); + + (uint256 currentState, uint256 ethAmountOwed) = calculateVariableWithdrawState( + (lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - + fixedETHDeposits), + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes) + ); + if (ethAmountOwed >= minStETHWithdrawalAmount()) { + // estimate protocol fee and update total - will actually be applied on withdraw finalization +>> uint256 protocolFee = ethAmountOwed.mulDiv(protocolFeeBps, 10000); +>> totalProtocolFee += protocolFee; + uint256 stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed); + + withdrawnStakingEarnings += ethAmountOwed - protocolFee; + withdrawnStakingEarningsInStakes += stakesAmountOwed; + + variableToWithdrawnStakingEarnings[msg.sender] += ethAmountOwed - protocolFee; + variableToWithdrawnStakingEarningsInShares[msg.sender] += stakesAmountOwed; + variableToWithdrawnProtocolFee[msg.sender] += protocolFee; + variableToVaultOngoingWithdrawalRequestIds[msg.sender] = requestWithdrawViaETH(msg.sender, ethAmountOwed); + + emit LidoWithdrawalRequested( + msg.sender, + variableToVaultOngoingWithdrawalRequestIds[msg.sender], + VARIABLE, + true, + false + ); + } + } + } + + ... MORE CODE + } + +``` +What we do with the `protocolFee` in this function is that we calculate it based on the amount the user has to withdraw and we add it to the `totalProtocolFee`. The `totalProtocolFee` is a fee that should be payed by the `VARIABLE` side at the end. + +Then what the user will do is he will call `finalizeVaultOngoingVariableWithdrawals()` to collect his earnings: + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L626 +```solidity + + function finalizeVaultOngoingVariableWithdrawals() external { + uint256[] memory requestIds = variableToVaultOngoingWithdrawalRequestIds[msg.sender]; + if (variableToPendingWithdrawalAmount[msg.sender] != 0) { + withdrawAmountVariablePending(); + if (requestIds.length == 0) { + return; + } + } + require(requestIds.length != 0, "WNR"); + + delete variableToVaultOngoingWithdrawalRequestIds[msg.sender]; + +>> uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); + +>> uint256 protocolFee = applyProtocolFee(amountWithdrawn); + +>> uint256 sendAmount = amountWithdrawn + calculateVariableFeeEarningsShare() - protocolFee; + + bool _isEnded = isEnded(); +>> transferWithdrawnFunds(msg.sender, sendAmount); + + emit LidoWithdrawalFinalized(msg.sender, requestIds, VARIABLE, true, _isEnded); + emit VariableFundsWithdrawn(sendAmount, msg.sender, true, _isEnded); + } + +``` + +Here is where part of the problem occurs. As we can see we are calculating the `protocolFee` again and we take it from the amount the user has to receive. Basically he pays the `protocolFee` from his earnings that he has to but the `totalProtocolFee` is not being updated. + +The flow continues and the vault has ended, now the remaining yield can be collected by the `VARIALBE` side. What happens now is someone will call `withdraw()` which will invoke `vaultEndedWithdraw(side)`, because it is the first time it is being called it will enter the first if statement and generate the `vaultEndedWithdrawalRequestIds` and simply return here: + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L722 +```solidity + +if (vaultEndedWithdrawalRequestIds.length == 0 && !vaultEndedWithdrawalsFinalized) { + emit VaultEnded(block.timestamp, msg.sender); + if (stakingBalance() < minStETHWithdrawalAmount()) { + claimOngoingFixedWithdrawals(); + vaultEndingStakesAmount = stakingShares(); + vaultEndingETHBalance = stakingBalance(); + // not enough staking ETH to withdraw just override vault ended state and continue the withdraw + vaultEndedWithdrawalsFinalized = true; + } else { + vaultEndingStakesAmount = stakingShares(); + vaultEndingETHBalance = stakingBalance(); +>> vaultEndedWithdrawalRequestIds = requestEntireBalanceWithdraw(msg.sender); + + emit LidoWithdrawalRequested(msg.sender, vaultEndedWithdrawalRequestIds, side, true, true); +>> // need to call finalizeVaultEndedWithdrawals once request is processed + return; + } + +``` + + +Then the `finalizeVaultEndedWithdrawals()` has to be called + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L683 +```solidity + +function finalizeVaultEndedWithdrawals(uint256 side) external { + require(side == FIXED || side == VARIABLE, "IS"); + + if (vaultEndedWithdrawalsFinalized) { + return vaultEndedWithdraw(side); + } + require(vaultEndedWithdrawalRequestIds.length != 0 && !vaultEndedWithdrawalsFinalized, "WNR"); + + vaultEndedWithdrawalsFinalized = true; + + // claim any ongoing fixed withdrawals too + claimOngoingFixedWithdrawals(); + //@note this collects the whole yield earned, not only for the msg.sender + >> uint256 amountWithdrawn = claimWithdrawals(msg.sender, vaultEndedWithdrawalRequestIds); + + uint256 fixedETHDeposit = fixedSidestETHOnStartCapacity; + + if (amountWithdrawn > fixedETHDeposit) { + vaultEndedStakingEarnings = amountWithdrawn - fixedETHDeposit; + vaultEndedFixedDepositsFunds = fixedETHDeposit; + } else { + vaultEndedFixedDepositsFunds = amountWithdrawn; + } + +>> uint256 protocolFee = applyProtocolFee(vaultEndedStakingEarnings); +>> vaultEndedStakingEarnings -= protocolFee; + + emit LidoWithdrawalFinalized(msg.sender, vaultEndedWithdrawalRequestIds, side, true, true); + +>> return vaultEndedWithdraw(side); + } + +``` + +Here we remove the `protocolFee` from the remaining yield (remaining because some if it was claimed while the vault was ongoing) which is fine, but then when `vaultEndedWithdraw(SIDE.VARIABLE)` is invoked again for the user, so now he can collect the yield because the requests have been proccessed + +we go into this else statement: + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L775 +```solidity +function vaultEndedWithdraw(uint256 side) internal { + + ...MORE CODE + + else { + require(variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0, "WAR"); + + if (msg.sender == protocolFeeReceiver && appliedProtocolFee > 0) { + return protocolFeeReceiverWithdraw(); + } + + uint256 bearerBalance = variableBearerToken[msg.sender]; + require(bearerBalance > 0, "NBT"); + + // Return proportional share of both earnings to caller + uint256 stakingShareAmount = 0; + +>> uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes, vaultEndingStakesAmount) - + totalProtocolFee + + vaultEndedStakingEarnings; + + if (totalEarnings > 0) { + (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( +>> totalEarnings, + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(vaultEndingETHBalance, vaultEndingStakesAmount) + ); +>> stakingShareAmount = stakingEarningsShare; + variableToWithdrawnStakingEarningsInShares[msg.sender] = currentState.mulDiv( + vaultEndingStakesAmount, + vaultEndingETHBalance + ); + variableToWithdrawnStakingEarnings[msg.sender] = currentState; + } + + uint256 feeShareAmount = 0; + if (withdrawnFeeEarnings + feeEarnings > 0) { + feeShareAmount = calculateVariableFeeEarningsShare(); + } + + variableBearerToken[msg.sender] -= bearerBalance; + variableBearerTokenTotalSupply -= bearerBalance; + +>> uint256 sendAmount = stakingShareAmount + feeShareAmount; +>> transferWithdrawnFunds(msg.sender, sendAmount); + + emit VariableFundsWithdrawn(sendAmount, msg.sender, true, true); + return; + } +``` + +As we can see we are removing the `totalProtocolFee` from the `totalEarnings` which are then used to calculate the user's amount that he has to withdraw. This makes the formula completely wrong and makes the `VARIABLE` side pay for fees again. This `totalProtocolFee` was already paid in the `finalizeVaultOngoingVariableWithdrawals()` and is removed from the variable `vaultEndedStakingEarnings`. You already took the fee from the chunks that people were withdrawing when the vault is ongoing and in the `finalizeVaultEndedWithdrawals()` you took the fee from the `vaultEndedStakingEarnings` which means that the fee is already collected and should not be subtracted in the `vaultEndedWithdraw()` when calculating the `totalEarnings` + +## Impact + +High because users pay more `protocolFee` than what they need to, which means they incur loses and don't receive 100% of the yield that they deserve and this happens everytime so the likelihood is also high + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L533 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L626 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L722 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L683 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L775 + +## Tool used + +Manual Review + +## Recommendation + +Remove the `totalProtocolFee` when calculating the `totalEarnings` at the end in `vaultEndedWithdraw()` because the total fees are already subtracted from the yield earned. + +```diff + +- uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes, vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; + ++ uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes, vaultEndingStakesAmount) + vaultEndedStakingEarnings; + +``` \ No newline at end of file diff --git a/001/078.md b/001/078.md new file mode 100644 index 0000000..54e10d6 --- /dev/null +++ b/001/078.md @@ -0,0 +1,98 @@ +Itchy Pebble Piranha + +High + +# Variable users aren't able to withdraw the early exit fees if all the deposited amount was withdrawn before the vault end + +### Summary + +When the vault starts, FIXED users can withdraw their FIXED stake from Lido, but in return, they have to pay an early exit fee, as compensation for the VARIABLE users that withdraw instead of the profit of the FIXED stake. +>Users from the variable side receive this earlyExitFee as compensation for a reduced rate of income due to the diminished capital deployed to the underlying asset, in this case, Lido Liquid Staking protocol. + +When users withdraw their FIXED stake, both Lido balance and shares will decrease. In case all FIXED users withdraw their deposits, and the vault ends, when VARIABLE users try to withdraw the early exit fees paid by FIXED users the TX will revert. This is because `LidoVault::vaultEndedWithdraw` gets called and sets both `vaultEndingStakesAmount` and `vaultEndingETHBalance` as zeros, remember all FIXED deposit is withdrawn. Later, when calculating the `totalEarnings`, the protocol will be divided by `vaultEndingStakesAmount` (which was set to 0 beforehand), reverting with division by 0 error. + +This blocks VARIABLE users from withdrawing the `earlyExitFee` that acts as a compensation, forcing them to lose funds. + +### Root Cause + +The protocol doesn't check if the `vaultEndingStakesAmount` is zero (all FIXED staked amount was withdrawn) before going ahead with the calculation, [here](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L775). + +### Impact + +DOS, variable users won't be able to withdraw the early exit fees paid by the fixed users. + +### PoC + +Add the following test in `lido-fiv/test/1.LidoVault.test.ts`: + +```typescript +it("BUG - DOS, variable user can't withdraw fees after vault is ended", async () => { + const { lidoVault, addr1, addr2, addr4 } = await loadFixture(deployLidoVaultFixture) + await setupMockLidoContracts(lidoVault) + + // Users deposit FIXED and VARIABLE + await lidoVault.connect(addr1).deposit(SIDE.FIXED, { value: parseEther('1000') }) + await lidoVault.connect(addr2).deposit(SIDE.VARIABLE, { value: parseEther('15') }) + await lidoVault.connect(addr4).deposit(SIDE.VARIABLE, { value: parseEther('15') }) + + // Vault has started + expect(await lidoVault.isStarted()).to.equal(true) + + // User 1 claims FIXED premium + await lidoVault.connect(addr1).claimFixedPremium() + + // Half time passes + const { duration, endTime } = await getTimeState(lidoVault) + await time.increaseTo(endTime - duration / BigInt(2)) + + // User 1 withdraws all their FIXED deposit + await lidoVault.connect(addr1).withdraw(SIDE.FIXED) + // Withdrawal is finalized + await lidoVault.connect(addr1).finalizeVaultOngoingFixedWithdrawals() + + // User 1 paid early exit fee, that should be claimed by the VARIABLE users + expect(await lidoVault.feeEarnings()).to.be.greaterThan(0) + + // End time passes + await time.increaseTo(endTime + BIG_INT_ONE) + + // Vault is ended + expect(await lidoVault.isEnded()).to.equal(true) + + // User 2 tries to withdraw the fees, revert with panic + await expect(lidoVault.connect(addr2).withdraw(SIDE.VARIABLE)).to.be.revertedWithPanic( + '0x12' // reverted with panic code 0x12 (Division or modulo division by zero) + ) +}) +``` + +### Mitigation + +In `LidoVault::vaultEndedWithdraw`, when calculating both `totalEarnings` and `stakingEarningsShare`, check if `vaultEndingStakesAmount` is different than 0 before doing so, by adding something like: +```solidity +function vaultEndedWithdraw(uint256 side) internal { + ... + + if (side == FIXED) { + ... + } else { + ... + + if (vaultEndingStakesAmount != 0) { + uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; + + if (totalEarnings > 0) { + (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( + totalEarnings, + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(vaultEndingETHBalance, vaultEndingStakesAmount) + ); + stakingShareAmount = stakingEarningsShare; + variableToWithdrawnStakingEarningsInShares[msg.sender] = currentState.mulDiv(vaultEndingStakesAmount,vaultEndingETHBalance); + variableToWithdrawnStakingEarnings[msg.sender] = currentState; + } + } + + ... + } +} +``` \ No newline at end of file diff --git a/001/079.md b/001/079.md new file mode 100644 index 0000000..461093c --- /dev/null +++ b/001/079.md @@ -0,0 +1,61 @@ +Brisk Dijon Moth + +High + +# There is a calculation error when accounting the totalEarnings of the variable side + +## Summary +When accounting for the withdrawn earnings which are a part of the total earnings the wrong rate is used which will result in calculating more earnings than there actually are. +## Vulnerability Detail +When a user withdraws during the vault(isStarted==true and isEnded==false), the following code is executed: +1)Which basically gets the fixedETHOnStartCapacity and then makes a snapshot of the current balance of stEth. +```solidity + if (variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0) { + uint256 lidoStETHBalance = stakingBalance(); + uint256 fixedETHDeposits = fixedSidestETHOnStartCapacity; +``` +2) Then the current shares of the contract are taken: +```solidity +uint256 currentStakes = stakingShares(); +``` +3)In the calculateVariableWithdrawState for the total earnings slot the following is passed: +```solidity + (uint256 currentState, uint256 ethAmountOwed) = calculateVariableWithdrawState( + (lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits), + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes) + ); +``` +Here the currentStakes + withdrawnStakingEarningsInStakes are multiplied by the current rate(lidoStETHBalance/currentStakes) to find the total earnings in stEth. +4)After the `ethAmountOwed` is calculated, the following is exectuted: +```solidity +uint256 stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed); +``` +```solidity + withdrawnStakingEarningsInStakes += stakesAmountOwed; +``` +Here the amount that is withdrawn from the user is converted to shares that will be withdrawn from the protocol +5)Then the withdraw is requested: +```solidity +variableToVaultOngoingWithdrawalRequestIds[msg.sender] = + requestWithdrawViaETH(msg.sender, ethAmountOwed); +``` +Here `requestWithdrawals` will be called by lido which will send the amount of ETH to the contract and will reduce the shares. + +However there is a repeated calculation error that will result in protocol assuming there are more (or in rare cases less) earnings than there actually are. +As we know, the way lido operates is by dynamic calculation of the balance of stEth which is directly connected to the shares that the contract has from the whole lido protocol. For simplicity, in this reported the the ratio - ethBalance/shares(from lido balance of function: https://docs.lido.fi/contracts/lido#balanceof) will be referred to as "rate". + +In point 3 (above) we see that for the withdrawn amount of shares, is converted to ETH based on the following rate: lidoStETHBalance/currentStakes. +However this rate as we know from the Lido protocol is dynamic, meaning that when we made a withdraw a week ago(this is an example period), the rate was lower(or in rare cases higher) than it is now. When we requested the withdraw a week ago, lido reduces the contract's shares and sent us ETH based on the rate from this time(the reduction of the total shares will also result in less profit for users of the variable side). Calculating the total earned amount based on the current rate will result in insolvency, as the protocol will assume there is more profit than there actually is. +This calculation based on the wrong rate can be also seen here: +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L775 +## Impact +Insolvency as total earned will not be enough to satisfy all withdraws by variable side users - High +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L526C8-L529C13 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L775 +## Tool used + +Manual Review + +## Recommendation +In order to track the total earned amount the withdrawn earnings should be calculated based on the rate of the withdraw, not by the current rate \ No newline at end of file diff --git a/001/085.md b/001/085.md new file mode 100644 index 0000000..cc999a8 --- /dev/null +++ b/001/085.md @@ -0,0 +1,104 @@ +Crazy Ocean Nightingale + +High + +# `totalEarnings` is incorrect when withdrawing after ending which will withdraw too many funds leaving the `Vault` insolvent + +### Summary + +`totalEarnings` in [LidoVault::vaultEndedWithdraw()](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L709) is calculated as: +```solidity +uint256 totalEarnings = vaultEndingETHBalance.mulDiv( + withdrawnStakingEarningsInStakes, vaultEndingStakesAmount +) - totalProtocolFee + vaultEndedStakingEarnings; +``` +The withdrawn shares are scaled to get the total earnings, along with vaultEndedStakingEarnings, which was aquired by getting the liquidity from the remaining shares when `LidoVault::finalizeVaultEndedWithdrawals()` was called. + +However, `totalProtocolFee` is not scaled, which means that as the steth eth/shares ratio increases, the protocol fee increases with it, otherwise it will overestimate the `totalEarnings`, as can be confirmed in the calculations in the `POC`. + +### Root Cause + +In `LidoVault.sol:775`, `protocolFee` is not scaled to the current steth eth/shares. It should be in shares and multiplied by the current exchange rate. +In `LidoVault.sol:533`, `protocolFee` should be tracked as shares. + +### Internal pre-conditions + +None. + +### External pre-conditions + +None. + +### Attack Path + +1. Users withdraw via `LidoVault::vaultEndedWithdraw()` and withdraw more than they should due to the total earnings. Next users will not have enough funds to make their withdrawals, taking a loss for the profit of the earlier users. + +### Impact + +The protocol becomes insolvent. + +### PoC + +Consider that 2 users have 50% of the variable bearer token each and the protocol starts with 100 ETH and 100 Shares. +After some time, the protocol accrues stEth and the holdings become 200 ETH and 100 shares. +Before the vault ends, user A withdraws his share of the rewards via `LidoVault::withdraw()`, which is: +```solidity +totalEarnings = 200 * (100 + 0) / 100 - 100 = 100 +ethAmountOwed = totalEarnings / 2 = 100 / 2 = 50 // 50% of the bearer tokens, so divides by 2 +protocolFee = 50 * 0.05 = 2.5 +stakesAmountOwed = 50 / 2 = 25 // each share of stETH is worth 2 ETH, the same ratio as the protocol having 200 ETH and 100 shares. + +// RESULT + +totalProtocolFee = 2.5 +variableToWithdrawnStakingEarningsInShares[userA] = 25 +withdrawnStakingEarningsInStakes = 25 +ETH = 150 +shares = 75 +``` + +Now, assume that the stETH shares double again its value, there are 300 ETH and 75 shares now in the protocol. +Consider that the vault ended so the fixed users claimed their 100 ETH, which leaves the protocol with 200 ETH and 50 shares (25 shares were removed to pay the fixed users). + +And lastly, userB withdraws his variable rewards by calling `LidoVault::vaultEndedWithdraw()`. The vault has earnings that were not withdrawn, so in the beginning of `LidoVault::vaultEndedWithdraw()` it requests the entire balance of the contract and registers the following: +```solidity +vaultEndingStakesAmount = 50 +vaultEndingETHBalance = 200 +``` +Then, `LidoVault::finalizeVaultEndedWithdrawals()` is called, which claims the withdrawals, getting 200 ETH and setting the following variables: +```solidity +amountWithdrawn = 200 ETH +fixedETHDeposit = 0 // was withdrawn by the fixed users already +vaultEndedStakingEarnings = 200 - 200 * 0.05 = 200 - 10 = 190 +``` +And finally, it calls again `LidoVault::vaultEndedWithdraw()` at the end, which leads to the following `totalEarnings` calculations: +```solidity +totalEarnings = 200 * 25 / 50 - 2.5 + 190 = 287.5 +ethAmountOwed = totalEarnings / 2 = 287.5 / 2 = 143.75 +``` +At this point, there is 190 ETH in the contract, and userA has withdrawn 25 shares, but userB has not withdrawn any shares. In the first time the stETH shares price doubled, they both had the same shares, so they should get the same amount. However, by the end, the stETH shares price doubled again, but one user had already withdrawn. Intuitively, this means that userB, who has not withdrawn, should get 75% of the final earnings while user A should get 25%, but this is not what happens. `variableToWithdrawnStakingEarningsInShares[userB] == 0`, so userB will withdraw `143.75 / 190 ~= 0.757`. + +To fix this, if we take the `totalProtocolFee` in shares instead of flat, when userA withdrew the first time, it withdraw 2.5 in fees, which at the time was 1.25 shares (it doubled). This yields: +```solidity +totalEarnings = 200 * 25 / 50 - 1.25 * 200 / 50 + 190 = 285 +ethAmountOwed = totalEarnings / 2 = 285 / 2 = 142.5 +``` +And lastly, `142.5 / 190 == 0.75`, which is the correct amount. + +Additionally, if we calculate userA's amount, it will be wrong too and shows us how it does not add up. We just need to subtract the shares of userA worth in ETH to the `totalEarnings / 2` to get his part: +```solidity +ethAmountOwed = 143.75 - 25 * 200 / 50 = 43.75 +``` +So summing up, they get `143.5 + 43.75 == 187.25`, which is less than 190 and some ETH is stuck. The amount is lower than 190, but it should be higher, the issue is that there is another bug, which is, the component that userA has already withdrawn should be discounted by the shares paid in fees (1.25). If we do this, it becomes +```solidity +ethAmountOwed = 143.75 - (25 - 1.25) * 200 / 50 = 48.75 +``` +Now, summing both users' withdrawals, `143.75 + 48.75 == 192.5`, which is bigger than 190 and they will withdraw too much. + + +### Mitigation + +`totalProtocolFee` must be tracked as shares in `LidoVault.sol:533`. +```solidity +totalProtocolFee += lido.getSharesByPooledEth(protocolFee); +``` \ No newline at end of file diff --git a/001/087.md b/001/087.md new file mode 100644 index 0000000..7cb2e4c --- /dev/null +++ b/001/087.md @@ -0,0 +1,92 @@ +Crazy Ocean Nightingale + +High + +# The amount withdrawn by an user does not discount the fee paid which will leave funds stuck when calling `LidoVault::VaultEndedWithdraw()` + +### Summary + +In [LidoVault::vaultEndedWithdraw()](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L709), the pro-rata amount that the user has withdrawn is calculated as: +```solidity +variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv( + vaultEndingETHBalance, vaultEndingStakesAmount +) +``` +However, this does not take into account that the user paid fees, but `totalEarnings` does, which will lead to stuck ETH by overestimating the amount the user has withdrawn. + +### Root Cause + +In `LidoVault.sol:759`, `variableToWithdrawnStakingEarningsInShares[msg.sender]` does not discount the fees in shares the user has paid. + +### Internal pre-conditions + +None. + +### External pre-conditions + +None. + +### Attack Path + +1. Users withdraw via `LidoVault::vaultEndedWithdraw()`, but will receive less than they should and ETH will be stuck. + +### Impact + +ETH is stuck in the protocol and variable users take losses. + +### PoC + +Consider that 2 users have 50% of the variable bearer token each and the protocol starts with 100 ETH and 100 Shares. +After some time, the protocol accrues stEth and the holdings become 200 ETH and 100 shares. +Before the vault ends, user A withdraws his share of the rewards via `LidoVault::withdraw()`, which is: +```solidity +totalEarnings = 200 * (100 + 0) / 100 - 100 = 100 +ethAmountOwed = totalEarnings / 2 = 100 / 2 = 50 // 50% of the bearer tokens, so divides by 2 +protocolFee = 50 * 0.05 = 2.5 +stakesAmountOwed = 50 / 2 = 25 // each share of stETH is worth 2 ETH, the same ratio as the protocol having 200 ETH and 100 shares. + +// RESULT + +totalProtocolFee = 2.5 +variableToWithdrawnStakingEarningsInShares[userA] = 25 +withdrawnStakingEarningsInStakes = 25 +ETH = 150 +shares = 75 +``` + +Now, assume that the stETH shares double again its value, there are 300 ETH and 75 shares now in the protocol. +Consider that the vault ended so the fixed users claimed their 100 ETH, which leaves the protocol with 200 ETH and 50 shares (25 shares were removed to pay the fixed users). + +And lastly, userB withdraws his variable rewards by calling `LidoVault::vaultEndedWithdraw()`. The vault has earnings that were not withdrawn, so in the beginning of `LidoVault::vaultEndedWithdraw()` it requests the entire balance of the contract and registers the following: +```solidity +vaultEndingStakesAmount = 50 +vaultEndingETHBalance = 200 +``` +Then, `LidoVault::finalizeVaultEndedWithdrawals()` is called, which claims the withdrawals, getting 200 ETH and setting the following variables: +```solidity +amountWithdrawn = 200 ETH +fixedETHDeposit = 0 // was withdrawn by the fixed users already +vaultEndedStakingEarnings = 200 - 200 * 0.05 = 200 - 10 = 190 +``` +And finally, it calls again `LidoVault::vaultEndedWithdraw()` at the end, which leads to the following `totalEarnings` calculations: +```solidity +totalEarnings = 200 * 25 / 50 - 2.5 + 190 = 287.5 +ethAmountOwed = totalEarnings / 2 = 287.5 / 2 = 143.75 +``` +Now, we calculate userA's amount to withdraw. We just need to subtract the shares of userA worth in ETH to the `totalEarnings / 2` to get his part: +```solidity +ethAmountOwed = 143.75 - 25 * 200 / 50 = 43.75 +``` + +As userB has not withdraw, `variableToWithdrawnStakingEarningsInShares[userA] == 0`, so it gets `143.75`. + +So summing up, they get `143.5 + 43.75 == 187.25`, which is less than 190 and some ETH is stuck. + +### Mitigation + +The already withdrawn amount should be subtract the shares of fees they already paid, that is: +```solidity +(variableToWithdrawnStakingEarningsInShares[msg.sender] - feeInShares[msg.sender]).mulDiv( + vaultEndingETHBalance, vaultEndingStakesAmount +) +``` \ No newline at end of file diff --git a/001/090.md b/001/090.md new file mode 100644 index 0000000..0cb2ee6 --- /dev/null +++ b/001/090.md @@ -0,0 +1,108 @@ +Crazy Ocean Nightingale + +Medium + +# Lido slashing after requesting the ending withdrawal will affect the stETH shares / eth, leading to some users inability to withdraw + +### Summary + +In [LidoVault::vaultEndedWithdraw()](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L709), whenever some variable users have not withdrawn, it fetches the `vaultEndingStakesAmount = stakingShares();` and `vaultEndingETHBalance = stakingBalance();`, and then requests withrawals. However, slashing may occur in this withdrawal, which will lead to incorrect calculation of variable users earnings as the ratio stored is before slashing and users will withdraw too much, leading to other users not being able to withdraw. + +### Root Cause + +In `LidoVault.sol:720` and `LidoVault.sol:721`, the checkpoint is taken of the ending stakes and balance before a slashing event occurs after the request is claimed in `LidoVault:finalizeVaultEndedWithdrawals()`. + +### Internal pre-conditions + +None. + +### External pre-conditions + +Lido slash, which is in scope as per the readme. +> The Lido Liquid Staking protocol can experience slashing incidents (such as this https://blog.lido.fi/post-mortem-launchnodes-slashing-incident/). These incidents will decrease income from deposits to the Lido Liquid Staking protocol and could decrease the stETH balance. The contract must be operational after it + +### Attack Path + +1. Variable users have earnings that they have not withdrawn and they call `LidoVault::withdraw()`, which calls `LidoVault::vaultEndedWithdraw()`. It then checkpoints the stakes and balances and requests withdraw of all the steth balance. +2. Lido slashes the withdrawal. +3. Users call `LidoVault::finalizeVaultEndedWithdrawals()`, which receives a slashed amount, which will not match the checkpointed stakes and balances ratio and will lead to excess withdraws, not letting all users withdraw, but the first ones profit from it. + +### Impact + +First users withdrawing get more variable funds than they should but the last ones can not withdraw. + +### PoC + +Consider that 2 users have 50% of the variable bearer token each and the protocol starts with 100 ETH and 100 Shares. +After some time, the protocol accrues stEth and the holdings become 200 ETH and 100 shares. +Before the vault ends, user A withdraws his share of the rewards via `LidoVault::withdraw()`, which is: +```solidity +totalEarnings = 200 * (100 + 0) / 100 - 100 = 100 +ethAmountOwed = totalEarnings / 2 = 100 / 2 = 50 // 50% of the bearer tokens, so divides by 2 +protocolFee = 50 * 0.05 = 2.5 +stakesAmountOwed = 50 / 2 = 25 // each share of stETH is worth 2 ETH, the same ratio as the protocol having 200 ETH and 100 shares. + +// RESULT + +totalProtocolFee = 2.5 +variableToWithdrawnStakingEarningsInShares[userA] = 25 +withdrawnStakingEarningsInStakes = 25 +ETH = 150 +shares = 75 +``` + +Consider that the vault ended, but the fixed users have not claimed their 100 ETH, which leaves the protocol with 150 ETH and 75 shares. + +And lastly, userB withdraws his variable rewards by calling `LidoVault::vaultEndedWithdraw()`. The vault has earnings and 100 ETH of fixed deposits that were not withdrawn, so in the beginning of `LidoVault::vaultEndedWithdraw()` it requests the entire balance of the contract and registers the following: +```solidity +vaultEndingStakesAmount = 75 +vaultEndingETHBalance = 150 +``` +Then, `LidoVault::finalizeVaultEndedWithdrawals()` is called, which claims the withdrawals, getting 150 ETH and setting the following variables: +```solidity +amountWithdrawn = 140 ETH +fixedETHDeposit = 100 ETH // was not withdrawn by the fixed users yet +vaultEndedStakingEarnings = 140 - 100 - (140 - 100) * 0.05 = 40 - 2 = 38 +``` +It calls again `LidoVault::vaultEndedWithdraw()` at the end, which leads to the following `totalEarnings` calculations: +```solidity +totalEarnings = 150 * 25 / 75 - 2.5 + 38 = 88.5 +ethAmountOwed = totalEarnings / 2 = 88.5 / 2 = 44.25 +``` +At this point, there is 38 ETH in the contract to variable withdrawals, and userA has withdrawn 25 shares, but userB has not withdrawn any shares. + +To calculate userA's amount, we just need to subtract the shares of userA worth in ETH to the `totalEarnings / 2` to get his part: +```solidity +ethAmountOwed = 44.25 - 25 * 150 / 75 = -5.75 // In the code it does not underflow because it has checks, it just gives him 0 +``` +So userA gets 0, which is correct (the code ignores if the previous amount is bigger than the current, see [here](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L915)) because the shares value has not increased since he withdrew. + +Now, as userB has not withdrawn anything, it will get his full `totalEarnings / 2`, which is `44.25`, more than the assigned 38 and will steal from the protocol. + +### Mitigation + +The fix is recalculating the stakes and balances checkpoints after the claimining from Lido. On `LidoVault::finalizeVaultEndedWithdrawals()`, firstly we reduce the number of stakes pro-rata to the number of ETH to fixed depositors: +```solidity +vaultEndingStakesAmount -= fixedETHDeposit * vaultEndingStakesAmount / vaultEndingETHBalance -= 100 * 75 / 150 == 75 - 100 * 75 / 150 == 25 +``` +And now, we calculate the ETH balance on the discounted claim. Let's say that instead of 150 ETH, it withdrew 140 ETH. +```solidity +amountWithdrawn = 140 ETH +fixedETHDeposit = 100 ETH // was not withdrawn by the fixed users yet +vaultEndedStakingEarnings = 140 - 100 == 40 +vaultEndingETHBalance = vaultEndedStakingEarnings +vaultEndedStakingEarnings -= vaultEndedStakingEarnings * 0.05 == 40 - 40 * 0.05 == 38 +``` + +So when it calls `LidoVault::vaultEndedWithdraw()` at the end, it calculates the total earnings: +```solidity +totalEarnings = 40 * 25 / 25 - 1.25 * 40 / 25 + 38 = 76 +ethAmountOwed = totalEarnings / 2 = 76 / 2 = 38 +``` +Note that the protocolFee of 2.5 was fixed by replacing it by shares and compute the ETH amount according to the ratio steth ETH / shares. +Now, userB who has never withdrawn gets 38, the correct amount available in the contract. +UserA who has withdrawn 25 shares gets 0, which is correct as the ratio has not increased since he withdrew. +```solidity +38 - (25 - 1.25) * 40 / 25 == 0 +``` +Note that the fix from the other issue was applied and the withdrawn fee of userB was discounted from the shares. \ No newline at end of file diff --git a/001/092.md b/001/092.md new file mode 100644 index 0000000..8597d7d --- /dev/null +++ b/001/092.md @@ -0,0 +1,51 @@ +Crazy Ocean Nightingale + +Medium + +# Withdrawing after a slash event before the vault has ended will decrease `fixedSidestETHOnStartCapacity` by less than it should, so following users will withdraw more their initial deposit + +### Summary + +In [LidoVault::withdraw()](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L423), when the vault has started but not ended, it limits the value to withdraw if a slashing event occured and withdraws `lidoStETHBalance.mulDiv(fixedBearerToken[msg.sender], fixedLidoSharesTotalSupply());`. However, it also decreases `fixedSidestETHOnStartCapacity` by this same amount, which means that next users that withdraw will get more than their initial deposit in case the Lido ratio comes back up (likely during the vault's duration). + +It's clear from the code users should get exactly their initial amount of funds or less, never more, as the [comment](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L486) indicates: +> since the vault has started only withdraw their initial deposit equivalent in stETH at the start of the vault- unless we are in a loss + +### Root Cause + +In `LidoVault.sol:498`, `fixedSidestETHOnStartCapacity` is decreased by a lower amount than it should. + +### Internal pre-conditions + +None. + +### External pre-conditions + +Lido slash, which is in scope as per the readme. +> The Lido Liquid Staking protocol can experience slashing incidents (such as this https://blog.lido.fi/post-mortem-launchnodes-slashing-incident/). These incidents will decrease income from deposits to the Lido Liquid Staking protocol and could decrease the stETH balance. The contract must be operational after it + +### Attack Path + +1. Lido slashes, decreasing the steth ETH / share ratio. +2. User withdraws, taking a loss and decreasing `fixedSidestETHOnStartCapacity` with the lossy amount. +3. Next user withdrawing will withdraw more because `fixedSidestETHOnStartCapacity` will be bigger than it should. + +### Impact + +Fixed deposit users benefit from the slashing event at the expense of variable users who will take the loss. + +### PoC + +Assume that there 100 ETH and 100 shares. +A slashing event occurs and drops the ETH to 90 and shares remain 100. +There are 2 fixed depositors, with 50% of the deposits each. +User A withdraws, and should take `100 ETH * 50 / 100 == 50 ETH`, but takes `90 ETH * 50 / 100 == 45 ETH` instead due to the loss. +`fixedSidestETHOnStartCapacity` is decreased by `45 ETH`, the withdrawn amount, so it becomes `55 ETH`. +Now, when LIDO recovers from the slashing, the contract will hold more steth than `fixedSidestETHOnStartCapacity`, more specifically the remaining 45 ETH in the contract that were not withdrawn yet are worth 50 ETH now. So user B gets +`fixedSidestETHOnStartCapacity * 50 / 50 == 55`. + +As the fixed deposit user initially deposited 50, but claimed 55 now, it is getting much more than it should at the expense of the variable users who will take the loss. + +### Mitigation + +The `fixedSidestETHOnStartCapacity` should be always reduced by `fixedETHDeposits.mulDiv(fixedBearerToken[msg.sender], fixedLidoSharesTotalSupply());`, such that users get their equivalent ETH from their initial deposit back and the variable users don't take losses. \ No newline at end of file diff --git a/001/096.md b/001/096.md new file mode 100644 index 0000000..d790d07 --- /dev/null +++ b/001/096.md @@ -0,0 +1,53 @@ +Crazy Ocean Nightingale + +Medium + +# Lido slashing will cause losses to users withdrawing that were frontrunned by it due to missing slippage check + +### Summary + +Users withdraw via [LidoVault::withdraw()](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L423), which for example in the case of fixed users will mean a loss if a Lido slashing event frontruns it. Users will submit their withdraw in the frontend but receive a much smaller amount due to having been frontrunned, effectively not having control on the amount they receive. + +This happens because fixed users withdraw according to `lidoStETHBalance` whenever it is smaller than the initial deposit amount, so when a slashing happens and frontruns them, they will withdraw less eth for themselves and there is nothing they can do. + +### Root Cause + +In `LidoVault.sol:423`, a slippage check is missing to get the minimum stEth ETH / shares ratio they accept. + +### Internal pre-conditions + +None. + +### External pre-conditions + +Lido slash, which is in scope as per the readme. +> The Lido Liquid Staking protocol can experience slashing incidents (such as this https://blog.lido.fi/post-mortem-launchnodes-slashing-incident/). These incidents will decrease income from deposits to the Lido Liquid Staking protocol and could decrease the stETH balance. The contract must be operational after it + +### Attack Path + +1. User calls `LidoVault::withdraw() +2. Lido slashing frontruns the user call +3. User gets less ETH than supposed + +### Impact + +The user suffers a loss outside of his control. + +### PoC + +In `LidoVault::withdraw()`, if there is a slashing, the user will get less than the initial deposit. +```solidity +uint256 fixedETHDeposits = fixedSidestETHOnStartCapacity; +uint256 withdrawAmount = + fixedETHDeposits.mulDiv(fixedBearerToken[msg.sender], fixedLidoSharesTotalSupply()); +uint256 lidoStETHBalance = stakingBalance(); + +if (fixedETHDeposits > lidoStETHBalance) { + // our staking balance if less than our stETH deposits at the start of the vault - only return a proportional amount of the balance to the fixed user + withdrawAmount = lidoStETHBalance.mulDiv(fixedBearerToken[msg.sender], fixedLidoSharesTotalSupply()); +} +``` + +### Mitigation + +Provide a minimum stEth ETH / shares ratio that is enforced in case of a slashing, so the user is never frontrun and takes an unexpected loss. \ No newline at end of file diff --git a/001/106.md b/001/106.md new file mode 100644 index 0000000..0699e3c --- /dev/null +++ b/001/106.md @@ -0,0 +1,80 @@ +Tart Purple Spider + +Medium + +# Inaccurate Protocol Fee Calculation in Lido Vault + +## Details + +Saffron leverages Lido's liquid Ethereum staking for its fixed-income strategy. The protocol charges a protocol fee on the Variable Side's staking earnings. These earnings primarily consist of the difference between the initial ETH staked by the Fixed Side and the ETH received upon claiming Lido withdrawals after the vault ends, minus any protocol fees. + +The potential issue lies in the calculation within the finalizeVaultEndedWithdrawals function of the LidoVault contract. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L661 + +```solidity +function finalizeVaultEndedWithdrawals(uint256 side) external { +    // ... existing code ... + +    uint256 amountWithdrawn = claimWithdrawals(msg.sender, vaultEndedWithdrawalRequestIds); +    uint256 fixedETHDeposit = fixedSidestETHOnStartCapacity; + +    if (amountWithdrawn > fixedETHDeposit) { +        vaultEndedStakingEarnings = amountWithdrawn - fixedETHDeposit; +        // ... existing code ... +    } else { +        // ... existing code ... +    } + +    uint256 protocolFee = applyProtocolFee(vaultEndedStakingEarnings); +    vaultEndedStakingEarnings -= protocolFee; + +    // ... existing code ... +} +``` + +The code calculates vaultEndedStakingEarnings correctly when amountWithdrawn (the total ETH withdrawn from Lido) exceeds fixedETHDeposit (the initial ETH staked). However, if amountWithdrawn is less than or equal to fixedETHDeposit, signifying either a loss or no profit from staking, vaultEndedStakingEarnings remains 0. Subsequently, the applyProtocolFee function, which calculates the protocol fee based on vaultEndedStakingEarnings, results in a 0 protocol fee even when there might be earnings from fixed side early exit fees (feeEarnings). + +## Impact + +The impact of this issue is that the protocol might miss out on collecting protocol fees from the portion of Variable Side earnings attributed to accrued feeEarnings when amountWithdrawn is less than or equal to fixedETHDeposit. + +## Scenario + +Let's imagine a scenario where: + +Fixed Side deposits total 100 ETH. +Due to market fluctuations, the staked ETH on Lido yields only 98 ETH upon withdrawal at the vault's end. +During the vault's duration, Fixed Side early exit fees accumulate to 5 ETH. + +In this scenario, amountWithdrawn would be 98 ETH, fixedETHDeposit would be 100 ETH, and feeEarnings would be 5 ETH. The current code would calculate vaultEndedStakingEarnings as 0, leading to a 0 protocol fee, even though the Variable Side benefits from the 5 ETH in feeEarnings. + +## Proposed Fix + +To rectify this, the calculation of the base amount on which the protocol fee is applied should include feeEarnings. One way to modify the code is as follows: + +```solidity +// ... existing code ... + +uint256 protocolFeeBasis = 0; +if (amountWithdrawn > fixedETHDeposit) { +    vaultEndedStakingEarnings = amountWithdrawn - fixedETHDeposit; +    protocolFeeBasis = vaultEndedStakingEarnings; +} else { +    protocolFeeBasis = feeEarnings; +} + +uint256 protocolFee = applyProtocolFee(protocolFeeBasis); + +// If amountWithdrawn > fixedETHDeposit, we deduct the fee from vaultEndedStakingEarnings +// Otherwise, we deduct it from feeEarnings +if (amountWithdrawn > fixedETHDeposit) { +    vaultEndedStakingEarnings -= protocolFee; +} else { +    feeEarnings -= protocolFee; +} + +// ... existing code ... +``` + +This modification ensures that even if there are no staking earnings (amountWithdrawn <= fixedETHDeposit), the protocol fee is still calculated and deducted from the feeEarnings. \ No newline at end of file diff --git a/001/109.md b/001/109.md new file mode 100644 index 0000000..515ceab --- /dev/null +++ b/001/109.md @@ -0,0 +1,205 @@ +Acrobatic Charcoal Turkey + +High + +# Invalid calculations of total earnings can lead to a DoS due to an `ETF` error when withdrawing or cause a lock of funds for early withdrawal users. + +### Summary + +In the Saffron Lido Vault, variable users can withdraw any accrued income at any time. However, withdrawing stETH has consequences, including a reduced APR for all users. The Saffron protocol aims to compensate users who do not withdraw their income until the Vault’s end by implementing the following mechanism: + +> This withdrawal decreases the vault's daily income, reducing the income for all remaining users. To address this, we ensured that variable users receive the same amount at the end of the Saffron Lido Vault, regardless of whether anyone withdraws before the end. This was achieved by using ‘stakes’ in our calculations for variable users' income. + +As a result, users who withdraw early are penalized and compensate other users' APR for those early income withdrawals, ensuring that users who do not withdraw still receive the same APR as if all the fixed stETH shares remained in the Vault for its entire duration. + +The stETH operates as a rebalancing token, where the yield occurs on the shares the Vault holds. Therefore, early income withdrawals reduce the overall number of shares held by the Vault. + +Unfortunately, there is an issue with the equations used for the calculations. + +First, in the `withdrawal()` function: + +```solidity + (uint256 currentState, uint256 ethAmountOwed) = calculateVariableWithdrawState( + (lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits), + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes) + ); +``` + +In this case, user withdrawals are overinflated with each consecutive income claim. If a user claims all their income through `withdrawal()` right before the Vault ends, it creates a situation where more income and fees are accounted for than the actual ETH available after Lido withdrawal and fixed users' ETH returns. As a result, the last claiming variable user or protocol fee recipient may be unable to claim without an ETH donation. + +Second, in the `vaultEndedWithdraw()` function: + +```solidity + uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes, vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; + + if (totalEarnings > 0) { + (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( + totalEarnings, + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(vaultEndingETHBalance, vaultEndingStakesAmount) + ); + stakingShareAmount = stakingEarningsShare; + variableToWithdrawnStakingEarningsInShares[msg.sender] = currentState.mulDiv(vaultEndingStakesAmount,vaultEndingETHBalance); + variableToWithdrawnStakingEarnings[msg.sender] = currentState; + } +``` + +In this case, users who withdrew prematurely and did not withdraw all their income before the Vault’s end will have their earnings over-reduced. + +### Root Cause + +1. The root cause of the overinflation appears to be that shares have different values depending on when they were withdrawn from the LidoVault, and this discrepancy is not accounted for. + https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L526-L529 + +2. The issue with the fund lock seems to arise from protocol fees being accounted for too early when calculating `vaultEndedStakingEarnings`. + https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L682-L683 + https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L775 + +### Internal pre-conditions + +* Users withdraw income before the Vault ends. + +### External pre-conditions + +* Lido stETH share price increases as expected. + +### Attack Path + +This issue occurs naturally. + +**Case 1:** +1. A user withdraws all income continuously before the Vault ends. +2. Vault calculations become overinflated, and there will be insufficient ETH for the last user. + +**Case 2:** +1. A user withdraws some income before the Vault ends, but a significant portion of the income is withdrawn after the Vault ends. +2. The user is undercompensated, and part of their assets will be locked in the Vault. + +Since both cases can counterbalance each other in a multi-user Vault, this issue might go unnoticed for some time. However, users who withdraw income early will eventually have part of their income locked. + +### Impact + +* Funds could be locked. +* DoS could occur due to insufficient withdrawal funds. + +### PoC + +
Add to `1.LidoVault.test.ts` to demonstrate the scenario described above: + +```javascript + it('PoC: Invalid calculations in total earnings', async function () { + const { + lidoVault, + deployer, + addr1, + addr2, + addr3, + lidoVaultAddress, + } = await loadFixture(deployLidoVaultFixture) + + const { lidoMock, lidoWithdrawalQueueMock } = await setupMockLidoContracts(lidoVault) + + await lidoVault.connect(addr1).deposit(SIDE.FIXED, { value: parseEther('1000') }) + await lidoVault + .connect(addr2) + .deposit(SIDE.VARIABLE, { value: (variableDeposit * BigInt(5)) / BigInt(10) }) + await lidoVault + .connect(addr3) + .deposit(SIDE.VARIABLE, { value: (variableDeposit * BigInt(5)) / BigInt(10) }) + + await lidoVault.connect(addr1).claimFixedPremium() + + expect(await lidoVault.isStarted()).to.be.true + + const balanceAddr2Before = await ethers.provider.getBalance(addr2) + const balanceAddr3Before = await ethers.provider.getBalance(addr3) + + let gasAddr2 = BigInt(0); + let gasAddr3 = BigInt(0); + + const stakingEarnings = parseEther('1') + + let shares = await lidoMock.sharesOf(lidoVaultAddress) + let balance = await lidoMock.balanceOf(lidoVaultAddress) + await lidoMock.addStakingEarningsForTargetETH( + balance + stakingEarnings, + lidoVaultAddress + ) + + gasAddr2 += calculateGasFees(await (await lidoVault.connect(addr2).withdraw(SIDE.VARIABLE)).wait()) + gasAddr2 += calculateGasFees(await (await lidoVault.connect(addr2).finalizeVaultOngoingVariableWithdrawals()).wait()) + + shares = await lidoMock.sharesOf(lidoVaultAddress) + balance = await lidoMock.balanceOf(lidoVaultAddress) + await lidoMock.addStakingEarningsForTargetETH( + balance + ((stakingEarnings * shares) / parseEther('1000')), + lidoVaultAddress + ) + + // COMMENT two lines below for Case 2: + // ↓↓↓ From here ↓↓↓ + + gasAddr2 += calculateGasFees(await (await lidoVault.connect(addr2).withdraw(SIDE.VARIABLE)).wait()) + gasAddr2 += calculateGasFees(await (await lidoVault.connect(addr2).finalizeVaultOngoingVariableWithdrawals()).wait()) + + // ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ + + // end vault + const { endTime } = await getTimeState(lidoVault) + await time.increaseTo(endTime + BIG_INT_ONE) + + gasAddr2 += calculateGasFees(await (await lidoVault.connect(addr2).withdraw(SIDE.VARIABLE)).wait()) + gasAddr2 += calculateGasFees(await (await lidoVault.connect(addr2).finalizeVaultEndedWithdrawals(SIDE.VARIABLE)).wait()) + + gasAddr3 += calculateGasFees(await (await lidoVault.connect(addr3).finalizeVaultEndedWithdrawals(SIDE.VARIABLE)).wait()) + + const balanceAddr2After = await ethers.provider.getBalance(addr2) + const balanceAddr3After = await ethers.provider.getBalance(addr3) + + const balanceAddr1Before = await ethers.provider.getBalance(addr1) + const gas = calculateGasFees(await (await lidoVault.connect(addr1).finalizeVaultEndedWithdrawals(SIDE.FIXED)).wait()) + const balanceAddr1After = await ethers.provider.getBalance(addr1) + + console.log(`User 1 :`, balanceAddr2After - balanceAddr2Before + gasAddr2) + console.log(`User 2 :`, balanceAddr3After - balanceAddr3Before + gasAddr3) + console.log(`Fixed side :`, balanceAddr1After - balanceAddr1Before + gas) + + console.log(`LidoVault :`, await ethers.provider.getBalance(lidoVault)) + console.log(`Protocol fee :`, await lidoVault.appliedProtocolFee()) + await lidoVault.connect(deployer).finalizeVaultEndedWithdrawals(SIDE.VARIABLE) + + console.log(await ethers.provider.getBalance(lidoVault)) + }) +``` +
+ +Note: There are two cases within the same PoC. + +Console log for Case 1: + +```text +User 1 : 989505494505494505n <-- overinflated +User 2 : 990002497502497501n <-- overinflated +Fixed side : 1000000000000000000000n +lidoVault : 19992507492517493n +Protocol fee : 19995004995004994n + +Error: VM Exception while processing transaction: reverted with reason string 'ETF' +``` + +Console log for Case 2: + +```text +User 1 : 984502997002997003n <-- 0.005 ETH loss +User 2 : 990002497502497501n +Fixed side : 1000000000000000000000n +LidoVault : 24995004995014995n <-- stuck in Vault +Protocol fee : 19995004995004994n +``` + +### Mitigation + +In the `withdraw()` function, when handling premature income extraction, consider that shares withdrawn before the end of the Vault are worth less than those at the end. + +In the `vaultEndedWithdraw()` function, ensure that protocol fees are not incorrectly reducing total earnings for users who withdrew prematurely. + +Ensure all calculations favor the protocol without unfairly penalizing users. \ No newline at end of file diff --git a/001/113.md b/001/113.md new file mode 100644 index 0000000..e875d85 --- /dev/null +++ b/001/113.md @@ -0,0 +1,58 @@ +Acrobatic Charcoal Turkey + +Medium + +# Variable users will be penalized when withdrawing their compensation from a fixed user premature withdrawal before Vault end. + +### Summary + +The README states the following: + +> Users from the variable side receive this earlyExitFee as compensation for a reduced rate of income due to the diminished capital deployed to the underlying asset, in this case, Lido Liquid Staking protocol. + +The described compensation is in the form of ETH that is sitting in the contract and is not accruing any yield, and as such, should be allowed to withdraw at any time. + +Unfortunately, the only option to withdraw this accrued compensation is via `withdraw()`, which is not penalized when the Vault has ended. However, if the Vault is still active, calling `withdraw()` will also trigger the withdrawal of income from the rebased stETH. + +This is problematic because early stETH income withdrawal is penalized in Saffron Lido Vaults, due to how Vaults work. + +This penalization is described here: + +> This withdrawal decreases the vault's daily income, reducing the income for all remaining users. To address this, we ensured that variable users receive the same amount at the end of the Saffron Lido Vault, regardless of whether anyone withdraws before the end. This was achieved by using ‘stakes’ in our calculations for variable users' income. + +Therefore, every early withdrawal by variable users before the end will require them to compensate the missing APR for other users from their future income, leading to a lower than expected APR. + +### Root Cause + +The only place where users can claim their compensation from `earlyExitFee` is via `withdraw()`, which will also trigger income redemption if the Vault has not yet ended: +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L559-L566 + +There is no separate function for compensation claiming. + +### Internal pre-conditions + +* The fixed side user exited prematurely and paid the `earlyExitFee` compensation. +* The Vault has started but not yet ended. + +### External pre-conditions + +None. + +### Attack Path + +1. There is an `earlyExitFee` compensation waiting for the user in the system. The UI should showcase this. +2. Unaware users call `withdraw()` to claim this compensation. +3. The user prematurely withdraws their variable income, significantly reducing their future APR. + +### Impact + +* Unintended loss of funds and APR for all users. +* Protocol invariant contradiction. + +### PoC + +Not needed. + +### Mitigation + +Consider introducing a way to claim compensation without penalization if the Vault has not ended. \ No newline at end of file diff --git a/001/114.md b/001/114.md new file mode 100644 index 0000000..fced51d --- /dev/null +++ b/001/114.md @@ -0,0 +1,37 @@ +Crazy Ocean Nightingale + +Medium + +# Having exactly 0 steth when withdrawing via will DoS variable users withdrawing + +### Summary + +When the vault has ended, [LidoVault::withdraw(](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L423)) calls [LidoVault::vaultEndedWithdraw()](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L709), which checkpoints the stakes and eth balance. If they are 0 and the user is withdrawing a variable deposit, it will underflow in the `totalEarnings` calculation when subtracting the `totalProtocolFee`. As users do not get their withdraw right away, they can not allocate it and lose yield because of this, showing its time sensitiveness + +### Root Cause + +In `LidoVault:775`, there is an unhandled underflow or divison by 0. + +### Internal pre-conditions + +None. + +### External pre-conditions + +None. + +### Attack Path + +1. User withdraws but there are exactly 0 steth in the contract due to everyone having requested withdrawals. + +### Impact + +DoSed withdrawals until someone sends some steth to the protocol so it does not revert. This will lead to yield loss for the variable users which goes against the explicit design of the LidoVault which calculates precisely the return variable users should get. + +### PoC + +In `LidoVault::vaultEndedWithdraw()`, the total earnings are calculated as `uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings;`, which underflows if `withdrawnStakingEarningsInStakes` is null or `vaultEndingStakesAmount` is 0. + +### Mitigation + +If the stakes or the balance are 0, skip collecting earnings as there are no earnings to collect: however, there may be pending `feeEarnings` to collect. \ No newline at end of file diff --git a/001/122.md b/001/122.md new file mode 100644 index 0000000..eb49a19 --- /dev/null +++ b/001/122.md @@ -0,0 +1,38 @@ +Energetic Wooden Starfish + +Medium + +# `getCalculateVariableWithdrawStateWithStakingBalance()` does not work under certain conditions + +## Summary +- `getCalculateVariableWithdrawStateWithStakingBalance()` does not work under certain conditions +## Vulnerability Detail +- `getCalculateVariableWithdrawStateWithStakingBalance()` does not work as fixedETHDepositTokenTotalSupply doesn't get updated. +- Let's understand this issue through an example + - In this example let's suppose Fixed capacity = 1000 ETH and Variable capacity = 30 ETH + - UserA comes and deposits 500 ETH , UserB deposits 500 ETH in fixed side. + - UserC deposits 30 ETH in variable side. + - Vault gets started after above deposits. + - Now after sometime UserA withdraws his 500 ETH and claims his 500 ETH. + - After the above step UserB now calls getCalculateVariableWithdrawStateWithStakingBalance() which calculates + user's ongoing variable withdraw state. + - But this call would always fail due to this condition `require(lidoStETHBalance > fixedETHDeposits, "LBL");` + - In this scenario fixedETHDeposits would be 1000 ETH and lidoStETHBalance would nearly equal to 500 -505 Ether as + UserA has withdrawn his ETH but the fixedETHDeposits variable doesn't get updated. + - So during the calculation fixedETHDeposits uses un-updated value due to which it will always revert. + + + +## Impact +- User would not able to calculate ongoing variable withdraw state due to use of un-updated value. +- This value might be used in frontend to show ongoing variable withdraw state but due to faulty behaviour of function , the protocol team would not be able to show that. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L880 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L883 +## Tool used + +Manual Review + +## Recommendation +- Use updated value of fixedETHDeposits to calculate ongoing variable withdraw state \ No newline at end of file diff --git a/001/128.md b/001/128.md new file mode 100644 index 0000000..d9e5a38 --- /dev/null +++ b/001/128.md @@ -0,0 +1,61 @@ +Cheesy Velvet Bull + +Medium + +# Incorrect calculation in `getCalculateVariableWithdrawStateWithStakingBalance` function + +## Summary + +`getCalculateVariableWithdrawStateWithStakingBalance` function is using wrong variable for `fixedETHDeposits` which leads to incorrect calculation. + +## Vulnerability Detail + +`getCalculateVariableWithdrawStateWithStakingBalance` function is implemented for variable users who wants to know how much yield is accrued for them from staking balance. So that variable users can know their earnings from staking balance at present. + +For calculating variable users yield `getCalculateVariableWithdrawStateWithStakingBalance` function gets total earnings generated from staking in LIDO. For that they get current staking balance in `lidoStETHBalance` variable and fixed eth deposit in `fixedETHDeposits` variable and then deducting them to get earnings. + +```solidity +function getCalculateVariableWithdrawStateWithStakingBalance(address user) public view returns (uint256) { + uint256 lidoStETHBalance = stakingBalance(); + uint256 fixedETHDeposits = fixedETHDepositTokenTotalSupply; + require(lidoStETHBalance > fixedETHDeposits, "LBL"); + uint256 totalEarnings = (lidoStETHBalance - fixedETHDeposits) + withdrawnStakingEarnings + totalProtocolFee; + ... + } +``` + +The problem occurs because they get `fixedETHDeposits` from variable called `fixedETHDepositTokenTotalSupply`. Because this variable is tracking fixed deposits till vault starts. If any fixed deposit user withdraw his eth after vault has started then it will be not deducted in `fixedETHDepositTokenTotalSupply` variable which leads to incorrect calculation. + +```solidity +function getCalculateVariableWithdrawStateWithStakingBalance(address user) public view returns (uint256) { + ... +@> uint256 fixedETHDeposits = fixedETHDepositTokenTotalSupply; + ... + } +``` + +Vulnerability Flow: + +- Soppose vault has started with 1000 eth fixed side capacity and 30 eth variable side capacity (values taken from test file values). +- Now if any fixed side user withdraw his 500 eth deposits from the vault before vault has ended. +- After that if varible user wants to know his yield from staking balance so he will call `getCalculateVariableWithdrawStateWithStakingBalance` function. +- But `fixedETHDepositTokenTotalSupply` variable is not updated and will give 1000 eth balance. +- And current staking eth balance will be (500 + yield on 500) because half fixed deposit is withdrawn. +- So that it will revert because of lidoStETHBalance > fixedETHDeposits [(500+yield) - 1000]. +- Also, if fixed deposit withdraw is small then yield of remaining then it will give wrong value of earnings. + +## Impact + +Variable user cannot get correct amount to withdraw from staking earnings. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L880C3-L896C4 + +## Tool used + +Manual Review + +## Recommendation + +Use updated values for `fixedETHDeposits` to calculate total earnings. \ No newline at end of file diff --git a/001/129.md b/001/129.md new file mode 100644 index 0000000..caff74b --- /dev/null +++ b/001/129.md @@ -0,0 +1,453 @@ +Ancient Blood Starling + +High + +# The incorrect accounting of protocol fee will cause double charging fee and wrong distribution of earnings for variable users + +### Summary + +The incorrect accounting of protocol fee will cause double charging fee and wrong distribution of earnings for variable users. + +### Root Cause + +The calculation for a variable user's earnings, when they withdraw where `isStarted()` and `!isEnded()` + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L520-L545 + +```solidity + uint256 lidoStETHBalance = stakingBalance(); + uint256 fixedETHDeposits = fixedSidestETHOnStartCapacity; + + // staking earnings have accumulated on Lido + if (lidoStETHBalance > fixedETHDeposits + minStETHWithdrawalAmount()) { + uint256 currentStakes = stakingShares(); +1> (uint256 currentState, uint256 ethAmountOwed) = calculateVariableWithdrawState( + (lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits), + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes) + ); + if (ethAmountOwed >= minStETHWithdrawalAmount()) { + // estimate protocol fee and update total - will actually be applied on withdraw finalization +2> uint256 protocolFee = ethAmountOwed.mulDiv(protocolFeeBps, 10000); + totalProtocolFee += protocolFee; +3> uint256 stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed); + + withdrawnStakingEarnings += ethAmountOwed - protocolFee; +4> withdrawnStakingEarningsInStakes += stakesAmountOwed; + + variableToWithdrawnStakingEarnings[msg.sender] += ethAmountOwed - protocolFee; +5> variableToWithdrawnStakingEarningsInShares[msg.sender] += stakesAmountOwed; + variableToWithdrawnProtocolFee[msg.sender] += protocolFee; + variableToVaultOngoingWithdrawalRequestIds[msg.sender] = requestWithdrawViaETH( + msg.sender, + ethAmountOwed + ); + ... +``` + +The variable user's earnings is the variable `ethAmountOwed` at `1>`. Note that, the earnings also includes the protocol fee (`2>`). Then `ethAmountOwed` is converted to shares `stakesAmountOwed` at `3>`. Then the shares is added to `withdrawnStakingEarningsInStakes` and `variableToWithdrawnStakingEarningsInShares[msg.sender]` + +When the vault ends, the variable user's earnings is calculated at + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L773-L785 + +```solidity + uint256 stakingShareAmount = 0; + +1> uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; + + if (totalEarnings > 0) { +2> (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( + totalEarnings, +3> variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(vaultEndingETHBalance, vaultEndingStakesAmount) + ); + stakingShareAmount = stakingEarningsShare; + variableToWithdrawnStakingEarningsInShares[msg.sender] = currentState.mulDiv(vaultEndingStakesAmount,vaultEndingETHBalance); + variableToWithdrawnStakingEarnings[msg.sender] = currentState; + } +``` + +The variable user's earnings is the variable `stakingEarningsShare` at `2>`. `stakingEarningsShare` is calculated basing on `withdrawnStakingEarningsInStakes`, `variableToWithdrawnStakingEarningsInShares[msg.sender]` (`1>`, `3>`). + +We believe by including protocol fee shares in `withdrawnStakingEarningsInStakes`, `variableToWithdrawnStakingEarningsInShares[msg.sender]` will cause `stakingEarningsShare` to be wrongly calculated. + +Refer to the attack path and the PoC for a concrete example. + + +### Internal pre-conditions + +A variable user withdraws when `isStarted()` and `!isEnded()` + +### External pre-conditions + +_No response_ + +### Attack Path + +Let's have a vault with: +- `fixedSideCapacity = 100 ether` +- `variableSideCapacity = 10 ether` +- `protocolFeeBps = 2_000 (20%)` +- `stETHRate = 1` (1 shares equals to `1 stETH`) + +
+ First vulnerability path + +On the variable side: +- Alice deposits `10 ether` + +**The expected behavior** + +1. `stETHRate = 1.1`. Vault's balance (stETH): `110 ether` (increased 10% since the beginning). + - Alice withdraws `8 ether`. `totalProtocolFee = 2 ether` + - New vault's balance: `100 ether` +2. Vault ends. `stETHRate = 1.21`. Vault's balance (stETH): `110 ether` (increased 10% since the `1.`). + - Alice withdraws `8 ether`. Protocol fee: `2 ether` + - New vault's balance: `100 ether` + +This is the expected behavior of the vault. + +**The actual behavior** + +1. `stETHRate = 1.1`. Vault's balance (stETH): `110 ether` (increased 10% since the beginning). + - Alice withdraws `8 ether`. `totalProtocolFee = 2 ether` + - New vault's balance: `100 ether` +2. Vault ends. `stETHRate = 1.21`. Vault's balance (stETH): `110 ether` (increased 10% since the `1.`). + - Alice withdraws. +```solidity + uint256 stakingShareAmount = 0; + + uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; + + if (totalEarnings > 0) { + (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( + totalEarnings, + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(vaultEndingETHBalance, vaultEndingStakesAmount) + ); + stakingShareAmount = stakingEarningsShare; + variableToWithdrawnStakingEarningsInShares[msg.sender] = currentState.mulDiv(vaultEndingStakesAmount,vaultEndingETHBalance); + variableToWithdrawnStakingEarnings[msg.sender] = currentState; + } +``` +In this code + +$$totalEarnings = 110 \times \frac{10/1.1}{100 - 10/1.1} - 2 + 10 \times 0.8 = 17 $$ + +$$stakingEarningsShare = totalEarnings - \frac{10}{1.1} \times \frac{110}{100 - 10/1.1} = 6$$ + +She can only withdraw back `6 ether`, which is less than `2 ether` comparing to the expected behavior. Moreover, this `2 ether` is not credited to the `protocolFeeReceiver`, no one can claim it and it will stuck in the contract. + +
+ +
+ Second vulnerability path + +On the variable side: +- Alice deposits `5 ether` +- Bob deposits `5 ether` + +**The expected behavior** + +1. `stETHRate = 1.1`. Vault's balance (stETH): `110 ether` (increased 10% since the beginning). + - Alice withdraws `4 ether`. `totalProtocolFee = 1 ether` + - New vault's balance: `105 ether` +2. Vault ends. `stETHRate = 1.21`. Vault's balance (stETH): `115.5 ether` (increased 10% since the `1.`). + - Alice withdraws `4 ether`. Protocol fee: `1 ether` + - Bob withdraws `10.5 * 0.8 = 8.4 ether`. Protocol fee: `10.5 * 0.2 = 2.1 ether` + +This is the expected behavior of the vault. + +**The actual behavior** + +1. `stETHRate = 1.1`. Vault's balance (stETH): `110 ether` (increased 10% since the beginning). + - Alice withdraws `4 ether`. `totalProtocolFee = 1 ether` + - New vault's balance: `105 ether` +2. Vault ends. `stETHRate = 1.21`. Vault's balance (stETH): `115.5 ether` (increased 10% since the `1.`). + - Alice withdraws + +$$totalEarnings = 115.5 \times \frac{5/1.1}{100-5/1.1} - 1 + 15.5 \times 0.8 = 16.9$$ + +$$stakingEarningsShare = totalEarnings/2 - \frac{5}{1.1} \times \frac{115.5}{100 - 5/1.1} = 2.95$$ + + - Bob withdraws + +$$stakingEarningsShare = totalEarnings/2 - 0 = 8.45$$ + +In the current logic comparing to the expected behavior, Alice is charged with `1 ether` more protocol fee. The `protocolFeeReceiver` can not claim this `1 ether` more fee, and it will stuck in the contract. Moreover, `0.05 ether` earnings of Alice is credited to Bob. + +
+ +### Impact + +- The variable user, who withdraws when `isStarted()` and `!isEnded()`, will be charged more protocol fee when they withdraw when the vault ends +- The exceed protocol fee will be stuck in the contract +- Wrong distribution of the variable earnings + +### PoC + +Add a setter in `LidoVault.sol` to set `lido` and `lidoWithdrawalQueue` to the mock version for easier debugging + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L1003-L1007 + +```diff + /// @notice Lido contract +- ILido public constant lido = ILido(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84); ++ ILido public lido = ILido(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84); + + /// @notice Lido withdrawal queue contract +- ILidoWithdrawalQueueERC721 public constant lidoWithdrawalQueue = + ILidoWithdrawalQueueERC721(0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1); ++ ILidoWithdrawalQueueERC721 public lidoWithdrawalQueue = + ILidoWithdrawalQueueERC721(0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1); + ++ function setLidoInfo(address _lido, address _withdrawalQueue) public { ++ lido = ILido(_lido); ++ lidoWithdrawalQueue = ILidoWithdrawalQueueERC721(_withdrawalQueue); ++ } +``` + +Run command: `forge test --match-path test/PoC.t.sol -vv` + +```solidity +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import { VaultFactory } from "contracts/VaultFactory.sol"; +import { LidoVault } from "contracts/LidoVault.sol"; + +import { Test, console } from "forge-std/Test.sol"; + +contract MockLido { + uint256 public rate; + mapping(address => uint256) private _sharesOf; + + constructor() { + rate = 1e27; + } + + function submit(address _referral) external payable returns (uint256) { + uint256 shares = msg.value * 1e27 / rate; + _sharesOf[msg.sender] += shares; + return shares; + } + + function approve(address spender, uint256 amount) external returns (bool) { + return true; + } + + function getPooledEthByShares(uint256 _sharesAmount) external view returns (uint256) { + return _sharesAmount * rate / 1e27; + } + + function getSharesByPooledEth(uint256 _ethAmount) external view returns (uint256) { + return _ethAmount * 1e27 / rate; + } + + function balanceOf(address _account) external view returns (uint256) { + return _sharesOf[_account] * rate / 1e27; + } + + function sharesOf(address _account) external view returns (uint256) { + return _sharesOf[_account]; + } + + function setRate(uint256 _rate) external { + rate = _rate; + } + + function burnShares(uint256 _amount, address _owner) external { + uint256 shares = _amount * 1e27 / rate; + _sharesOf[_owner] -= shares; + } +} + +contract MockLidoWithdrawalQueueERC721 { + MockLido public lido; + + mapping(uint256 requestId => uint256 amount) public requestIdToAmount; + uint256 public currentRequestId; + + constructor(address _lido) { + lido = MockLido(_lido); + } + function claimWithdrawal(uint256 _requestId) external { + payable(msg.sender).call{value: requestIdToAmount[_requestId]}(""); + } + + function requestWithdrawals(uint256[] calldata _amounts, address _owner) external returns (uint256[] memory requestIds) { + lido.burnShares(_amounts[0], _owner); + requestIdToAmount[currentRequestId] = _amounts[0]; + + requestIds = new uint256[](1); + requestIds[0] = currentRequestId++; + } + receive() external payable { + } +} + + +contract PoC is Test { + MockLido lido; + MockLidoWithdrawalQueueERC721 lidoWithdrawalQueueERC721; + + VaultFactory factory; + LidoVault vault; + + address fixedDepositor = makeAddr('fixedDepositor'); + address alice = makeAddr('alice'); + address bob = makeAddr('bob'); + address feeReceiver = makeAddr('feeReceiver'); + + uint256 protocolFeeBps = 2_000; + uint256 fixedCap = 100 ether; + uint256 variableCap = 10 ether; + + function setUp() public { + lido = new MockLido(); + lidoWithdrawalQueueERC721 = new MockLidoWithdrawalQueueERC721(address(lido)); + + factory = new VaultFactory(protocolFeeBps, 0); + factory.setProtocolFeeReceiver(feeReceiver); + + factory.createVault(fixedCap, 1 days, variableCap); + (,address addr) = factory.vaultInfo(1); + vault = LidoVault(payable(addr)); + + vault.setLidoInfo(address(lido), address(lidoWithdrawalQueueERC721)); + + vm.deal(address(lidoWithdrawalQueueERC721), 1000 ether); + + vm.deal(fixedDepositor, fixedCap); + + vm.prank(fixedDepositor); + vault.deposit{value: fixedCap}(0); + } + + function testFirstVulnerabilityPath() public { + vm.deal(alice, variableCap); + + vm.prank(alice); + vault.deposit{value: alice.balance}(1); + + lido.setRate(1.1e27); + + vm.startPrank(alice); + vault.withdraw(1); + vault.finalizeVaultOngoingVariableWithdrawals(); + vm.stopPrank(); + + console.log("Alice's balance after first claim: %e", alice.balance); + + skip(1 days + 1); + + lido.setRate(1.21e27); + + vm.startPrank(alice); + vault.withdraw(1); + vault.finalizeVaultEndedWithdrawals(1); + vm.stopPrank(); + + console.log("Alice's balance at the end: %e", alice.balance); + + vm.prank(feeReceiver); + vault.withdraw(1); + + vm.startPrank(fixedDepositor); + vault.claimFixedPremium(); + vault.withdraw(0); + vm.stopPrank(); + + console.log("Vault's balance at the end: %e", address(vault).balance); + } + + function testSecondVulnerabilityPath() public { + vm.deal(alice, variableCap / 2); + vm.deal(bob, variableCap / 2); + + vm.prank(alice); + vault.deposit{value: alice.balance}(1); + + vm.prank(bob); + vault.deposit{value: bob.balance}(1); + + lido.setRate(1.1e27); + + vm.startPrank(alice); + vault.withdraw(1); + vault.finalizeVaultOngoingVariableWithdrawals(); + vm.stopPrank(); + + console.log("Alice's balance after first claim: %e", alice.balance); + + skip(1 days + 1); + + lido.setRate(1.21e27); + + vm.startPrank(alice); + vault.withdraw(1); + vault.finalizeVaultEndedWithdrawals(1); + vm.stopPrank(); + + vm.prank(bob); + vault.withdraw(1); + + console.log("Alice's balance at the end: %e", alice.balance); + console.log("Bob's balance at the end: %e", bob.balance); + + vm.prank(feeReceiver); + vault.withdraw(1); + + vm.startPrank(fixedDepositor); + vault.claimFixedPremium(); + vault.withdraw(0); + vm.stopPrank(); + + console.log("Vault's balance at the end: %e", address(vault).balance); + } +} +``` + +```bash +testFirstVulnerabilityPath() +Logs: + Alice's balance after first claim: 8e18 + Alice's balance at the end: 1.4e19 + Vault's balance at the end: 2e18 +``` + +- Alice's final balance is only `8 ether + 6 ether = 14 ether` +- The `feeReceiver` and `fixedDepositor` have already withdrawn, but there is still ETH left in the contract. + +```bash +testSecondVulnerabilityPath() +Logs: + Alice's balance after first claim: 4e18 + Alice's balance at the end: 6.95e18 + Bob's balance at the end: 8.449999999999999999e18 + Vault's balance at the end: 1.000000000000000001e18 +``` + +- Alice's final balance is only `4 ether + 2.95 ether = 6.95 ether` +- The `feeReceiver` and `fixedDepositor` have already withdrawn, but there is still ETH left in the contract. + +### Mitigation + +`stakesAmountOwed` at `LidoVault.sol:534` should exclude the `protocolFee` + +```diff + uint256 protocolFee = ethAmountOwed.mulDiv(protocolFeeBps, 10000); + totalProtocolFee += protocolFee; +- uint256 stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed); ++ uint256 stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed - protocolFee ); +``` + +`totalEarnings` at `LidoVault.sol:775` should exclude the deduction of `totalProtocolFee` + +```diff +- uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; ++ uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) + vaultEndedStakingEarnings; +``` + + + + diff --git a/001/130.md b/001/130.md new file mode 100644 index 0000000..e0d0e46 --- /dev/null +++ b/001/130.md @@ -0,0 +1,96 @@ +Festive Marmalade Unicorn + +Medium + +# Fixed-side depositors face potential losses due to unclear withdrawal amounts and early exit fees + +### Summary + +Fixed-side depositors can withdraw their staked ETH at any time. They are required to pay an early exit fee if they withdraw before the vault has ended. In some cases, the early exit fee can exceed the withdrawn amount, resulting in depositors receiving nothing. However, as the early exit fee decreases over time, there is a possibility for fixed-side depositors to receive some Ether. + +In the current implementation, fixed-side depositors cannot know the exact amount of their withdrawal and the early exit fee, leaving them unaware of the final amount they will receive. This situation is unfair to fixed side depositors. The `withdraw` function should include a parameter that represents the desired amount of Ether the depositors wish to withdraw. Additionally, it should check if the final amount is greater than this specified parameter to protect fixed-side depositors. + + +### Root Cause + +When a fixed-side depositor intends to withdraw their staked Ether after the vault has started but before it ends, he calls the [withdraw](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L423) function. + +It calculates the withdraw amount at `L493` and send withdrawal request to the `Lido` at `L500`. + +```solidity +File: lido-fiv\contracts\LidoVault.sol +472: } else if (!isEnded()) { // @audit-info vault started, not ended +473: if (side == FIXED) { + [...] +488: uint256 withdrawAmount = fixedETHDeposits.mulDiv(fixedBearerToken[msg.sender], fixedLidoSharesTotalSupply()); +489: uint256 lidoStETHBalance = stakingBalance(); +490: +491: if (fixedETHDeposits > lidoStETHBalance) { +492: // our staking balance if less than our stETH deposits at the start of the vault - only return a proportional amount of the balance to the fixed user +493: withdrawAmount = lidoStETHBalance.mulDiv(fixedBearerToken[msg.sender], fixedLidoSharesTotalSupply()); +494: } + [...] +499: fixedToVaultOngoingWithdrawalRequestIds[msg.sender] = WithdrawalRequest({ +500: requestIds: requestWithdrawViaETH(msg.sender, withdrawAmount), +501: timestamp: block.timestamp +502: }); + [...] +512: return; +``` +To finalize the withdrawal process, the depositor should call the [finalizeVaultOngoingFixedWithdrawals](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L595) function. +Here, it calculates the amount to send from the [claimFixedVaultOngoingWithdrawal](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L812) function and sends `Ether` to `msg.sender`. + +```solidity +File: lido-fiv\contracts\LidoVault.sol +595: function finalizeVaultOngoingFixedWithdrawals() external { +596: uint256 sendAmount = claimFixedVaultOngoingWithdrawal(msg.sender); + [...] +604: transferWithdrawnFunds(msg.sender, sendAmount); +607: } +``` +In the [claimFixedVaultOngoingWithdrawal](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L812) function, it first claims `ETH` from Lido. Then, it calculates an early exit fee and returns the final amount that fixed depositors can receive at `L842`. If an early exit fee is larger than the withdrawal amount, it becomes `0`. +```solidity +File: lido-fiv\contracts\LidoVault.sol +812: function claimFixedVaultOngoingWithdrawal(address user) internal returns (uint256) { + [...] +831: uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); +832: +833: uint256 earlyExitFees = calculateFixedEarlyExitFees(upfrontPremium, request.timestamp); +834: // make sure, that earlyExitFee cant be higher than initial deposit +835: earlyExitFees = Math.min(earlyExitFees, amountWithdrawn); + ... +842: return amountWithdrawn - earlyExitFees; // @audit-info this can be zero +``` + +When the depositor calls the [withdraw](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L423) function, he can't know the amount of early exit fee and the final amount he can receive. And there is no way to revert the withdrawal action. +No depositor wants to withdraw `0` Ether with consuming gas fee. + +However, as the early exit fee decreases over time, there is a possibility for fixed-side depositors to receive some `Ether` if he withdraws later. +As shown above, it is unfair for the fixed side depositors. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +A fixed side depositor is going to withdraw their `ETH` before the vault ends. + +### Attack Path + +1. Alice deposited some `ETH` to the vault. +2. Alice withdraw their `ETH` right after the vault starts. + +Due to early exit fees, Alice can't receive anything. + +### Impact + +Fixed side depositors could receive nothing due to early exit fees. As the early exit fee decreases over time, there is a possibility for fixed-side depositors to receive some `Ether`. This is unfair for fixed side depositors who want to withdraw their `ETH`. + +### PoC + +_No response_ + +### Mitigation + +It is recommended to add a parameter that represents the desired withdrawal amount and to check if the final amount is greater than this specified value. \ No newline at end of file diff --git a/001/133.md b/001/133.md new file mode 100644 index 0000000..3e0ff57 --- /dev/null +++ b/001/133.md @@ -0,0 +1,65 @@ +Tart Purple Spider + +Medium + +# Inconsistent Handling of Fixed Side Withdrawals in Ended Vaults + +## Details + +The Lido Vault contract allows users to deposit ETH and participate in Lido staking with both fixed and variable return options. Fixed-side withdrawals are handled differently when the vault ends, especially in loss scenarios where the final ETH balance is lower than the initial fixed-side deposits. + +The `vaultEndedWithdraw` function calculates the amount (`sendAmount`) to be returned to fixed-side depositors based on `vaultEndedFixedDepositsFunds`. This value is supposed to reflect the portion of the final balance allocated to fixed users. + +However, when the vault ends with losses (`vaultEndingETHBalance` < `fixedSidestETHOnStartCapacity`), `vaultEndedFixedDepositsFunds` is set to `vaultEndingETHBalance`. This can result in: + +- Overestimation: In cases of minor losses, fixed-side users might receive more than their fair share. +- Potential Loss: In scenarios with significant losses, the calculation may not accurately reflect the reduced share, potentially leading to discrepancies and losses. + +## Code Snippets + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L675 + +- **Vault Ending Calculation** (finalizeVaultEndedWithdrawals function): + +```solidity +if (amountWithdrawn > fixedETHDeposit) { + vaultEndedStakingEarnings = amountWithdrawn - fixedETHDeposit; + vaultEndedFixedDepositsFunds = fixedETHDeposit; +} else { + vaultEndedFixedDepositsFunds = amountWithdrawn; +} +``` + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L748 + +- **Fixed Side Withdrawal** (vaultEndedWithdraw function): + +```solidity +sendAmount = fixedBearerToken[msg.sender].mulDiv( + vaultEndedFixedDepositsFunds, + fixedLidoSharesTotalSupply() +); +``` + +## Impact + +This inconsistency can lead to inaccurate distribution of remaining funds to fixed-side depositors, potentially overestimating their share in loss scenarios and causing discrepancies or even losses. + +## Scenario + +1. A vault starts with `fixedSidestETHOnStartCapacity` of 1 ETH. +2. The vault ends with `vaultEndingETHBalance` of 0.8 ETH (a loss). +3. A fixed-side depositor tries to withdraw. +4. The code calculates their share based on `vaultEndedFixedDepositsFunds` (0.8 ETH), potentially overestimating their entitlement and not accounting for the loss. + +## Fix + +Add a check in `vaultEndedWithdraw` to ensure the calculated `sendAmount` doesn't exceed the proportional share of the actual `vaultEndingETHBalance`: + +```solidity +uint256 maxSendAmount = fixedBearerToken[msg.sender].mulDiv( + vaultEndingETHBalance, + fixedLidoSharesTotalSupply() +); +sendAmount = Math.min(sendAmount, maxSendAmount); +``` \ No newline at end of file diff --git a/001/134.md b/001/134.md new file mode 100644 index 0000000..f1fb310 --- /dev/null +++ b/001/134.md @@ -0,0 +1,210 @@ +Amusing Flaxen Bull + +High + +# Incorrect usage of shares will lead to insolvency + +### Summary + +The shares of previously withdrawn earnings are assumed to have the same value as those at the end of the staking period. This leads to distributing more to users than necessary, potentially causing insolvency. + +### Root Cause + +In [vaultEndedWithdraw()](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L775) when calculating the total earnings, `withdrawnStakingEarningsInStakes` is used. `withdrawnStakingEarningsInStakes` represents the shares of already claimed withdrawals, initiated before the vault duration ends. These shares are burned on withdrawal claim, according to the [Lido documentation](https://docs.lido.fi/contracts/lido#oracle-report). Hence they are not included in the number of shares at the end of the vault duration (`vaultEndingStakesAmount`) and also the price of the share at the time of the withdrawal and at the end of the vault duration end is expected to be different. Thus resulting in invalid `totalEarned` value, which is greater than it must be. + +### Internal pre-conditions + +1. There must be at least one withdrawal of a variable side, so that `withdrawnStakingEarningsInStakes` is non-zero. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Fixed side deposit and Variable side deposits are made, so that the vault is started. +2. Rewards are accrued because of the staking. +3. Variable side user calls `withdraw(1)`, which will create withdraw request for a part of these rewards. +4. `withdrawnStakingEarningsInStakes` is updated to represent the shares that will be burnt, and `withdrawnStakingEarnings` is updated to represent the withdrawn amount without the protocol fees. +5. When the request is claimed, the shares representing the withdrawn amount are burned from Lido contract +6. When the duration ends, the staking is finalized, all staked amount is unstaked and all withdraw requests are claimed. +7. Any user participating in the variable side call `withdraw(1)`, and according to the calculations he will receive more than he had to. + +### Impact + +The protocol suffers a loss of funds which may vary, because the more variaable side withdrawals are made in the beginning of the vault duration, the greater the error of the total earnings will be, because the ETH price for 1 share is expected to be increasing. + +### PoC + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import "@openzeppelin/contracts/utils/math/Math.sol"; + +import {Test, console2} from "forge-std/Test.sol"; + +import {VaultFactory} from "src/VaultFactory.sol"; +import {LidoVault} from "src/LidoVault.sol"; +import {ILido} from "src/interfaces/ILido.sol"; +import {MockLido} from "src/mocks/MockLido.sol"; +import {MockLidoWithdrawalQueue} from "src/mocks/MockLidoWithdrawalQueue.sol"; + +contract PoC is Test { + using Math for uint256; + + address payable constant LIDO = payable(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84); + address payable constant LIDO_WITHDRAWAL_QUEUE = payable(0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1); + + VaultFactory vf; + LidoVault lv; + + address fixedUser = makeAddr("fixedUser"); + address variableUser1 = makeAddr("variableUser1"); + address variableUser2 = makeAddr("variableUser2"); + address protocolFeeReceiver = makeAddr("protocolFeeReceiver"); + + uint256 fixedSideCapacity = 1 ether; + uint256 variableSideCapacity = 1 ether; + uint256 duration = 7 days; + + function setUp() public { + // Deploy the Lido mocks on the respective addresses + deployCodeTo("MockLido", LIDO); + deployCodeTo("MockLidoWithdrawalQueue", LIDO_WITHDRAWAL_QUEUE); + + MockLidoWithdrawalQueue(LIDO_WITHDRAWAL_QUEUE).initialize(LIDO); + + deal(LIDO_WITHDRAWAL_QUEUE, 100 ether); + + // Deploy the vault factory + vm.prank(protocolFeeReceiver); + vf = new VaultFactory(100, 100); + + uint256 vaultId = vf.nextVaultId(); + + vf.createVault(fixedSideCapacity, duration, variableSideCapacity); + (, address addr) = vf.vaultInfo(vaultId); + + lv = LidoVault(payable(addr)); + + deal(fixedUser, 10 ether); + deal(variableUser1, 10 ether); + deal(variableUser2, 10 ether); + } + + function test3() public { + uint256 sharePriceBeggining = MockLido(LIDO).getPooledEthByShares(1 ether); + + // Fixed side deposit + vm.prank(fixedUser); + lv.deposit{value: 1 ether}(0); + + // Variable side deposit + vm.prank(variableUser1); + lv.deposit{value: 0.5 ether}(1); + + vm.prank(variableUser2); + lv.deposit{value: 0.5 ether}(1); + + // Validate the vault have started + assert(lv.isStarted() == true); + assert(lv.isEnded() == false); + + // Claim the fixed premium + vm.prank(fixedUser); + lv.claimFixedPremium(); + + // Add rewards + MockLido(LIDO).addStakingEarnings(1 ether); + + // Create withdraw variable request + vm.prank(variableUser1); + lv.withdraw(1); + + // Claim the withdraw + uint256 variableUserBalanceBefore = variableUser1.balance; + vm.prank(variableUser1); + lv.finalizeVaultOngoingVariableWithdrawals(); + uint256 variableUserBalanceAfter = variableUser1.balance; + + uint256 variableUserWithdrawals = variableUserBalanceAfter - variableUserBalanceBefore; + uint256 protocolFee = lv.totalProtocolFee(); + + uint256 sharePriceAfterWithdraw = MockLido(LIDO).getPooledEthByShares(1 ether); + assert(sharePriceAfterWithdraw != sharePriceBeggining); + + // Add a lot of rewards + MockLido(LIDO).addStakingEarnings(1000 ether); + + // skip time + skip(duration + 1); + + // Validate the vault have ended + assert(lv.isEnded() == true); + + // Finalize the vault + vm.prank(fixedUser); + lv.withdraw(0); + + vm.prank(fixedUser); + lv.finalizeVaultEndedWithdrawals(0); + + // Validate that the share price is increased, because rewards have been added + uint256 sharePriceEnding = MockLido(LIDO).getPooledEthByShares(1 ether); + assert(sharePriceEnding > sharePriceAfterWithdraw); + assert(sharePriceEnding > sharePriceBeggining); + + // Check the earnings + // The total earnings = withdrawnEarnings + vaultEndedStakingEarnings - totalProtocolFees + // Only the withdrawnEarnings are calculated using shares in the LidoVault.sol::vaultEndedWithdraw() line 775 + // The withdrawnEarnings calculation below includes the totalProtocolFees + uint256 withdrawnEarningsCalculation = + lv.vaultEndingETHBalance().mulDiv(lv.withdrawnStakingEarningsInStakes(), lv.vaultEndingStakesAmount()); + + // Validate that the previously withdrawn earnings are less than the calculated + assert(withdrawnEarningsCalculation > variableUserWithdrawals + protocolFee); + } +} +``` + +### Mitigation + +LidoVault.sol::vaultEndedWithdraw() +```diff +... +} else { + require(variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0, "WAR"); + + if (msg.sender == protocolFeeReceiver && appliedProtocolFee > 0) { + return protocolFeeReceiverWithdraw(); + } + + uint256 bearerBalance = variableBearerToken[msg.sender]; + require(bearerBalance > 0, "NBT"); + + // Return proportional share of both earnings to caller + uint256 stakingShareAmount = 0; + +- uint256 totalEarnings = vaultEndingETHBalance.mulDiv( +- withdrawnStakingEarningsInStakes, vaultEndingStakesAmount +- ) - totalProtocolFee + vaultEndedStakingEarnings; ++ uint256 totalEarnings = withdrawnStakingEarnings + vaultEndedStakingEarnings; + if (totalEarnings > 0) { +- (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( +- totalEarnings, +- variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv( +- vaultEndingETHBalance, vaultEndingStakesAmount +- ) +- ); ++ (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( ++ totalEarnings, ++ variableToWithdrawnStakingEarnings[msg.sender] ++ ); + stakingShareAmount = stakingEarningsShare; + variableToWithdrawnStakingEarningsInShares[msg.sender] = + currentState.mulDiv(vaultEndingStakesAmount, vaultEndingETHBalance); + variableToWithdrawnStakingEarnings[msg.sender] = currentState; + } +... +``` \ No newline at end of file diff --git a/001/135.md b/001/135.md new file mode 100644 index 0000000..d68a4aa --- /dev/null +++ b/001/135.md @@ -0,0 +1,110 @@ +Festive Marmalade Unicorn + +High + +# Rounding errors in the calculation of `variableToWithdrawnStakingEarningsInShares` will lead to lack of ethers in the vault + +### Summary + +In the calculation of `variableToWithdrawnStakingEarningsInShares`, rounding down is used instead of rounding up. So, the lack of dust ether may result in that the last user of the vault cannot withdraw his fund. + +### Root Cause + +In LidoVault.sol:783, the calculation of `variableToWithdrawnStakingEarningsInShares` should use rounding up rather than rounding down. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L775-L785 +```solidity + uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; + + if (totalEarnings > 0) { + (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( + totalEarnings, +@> variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(vaultEndingETHBalance, vaultEndingStakesAmount) + ); + stakingShareAmount = stakingEarningsShare; +@> variableToWithdrawnStakingEarningsInShares[msg.sender] = currentState.mulDiv(vaultEndingStakesAmount,vaultEndingETHBalance); + variableToWithdrawnStakingEarnings[msg.sender] = currentState; + } +``` + +It is possible for some variable users to permanently withdraw dust amounts generated by rounding errors. +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L859-L874 +```solidity + function calculateVariableWithdrawState( + uint256 totalEarnings, + uint256 previousWithdrawnAmount + ) internal view returns (uint256, uint256) { + + uint256 bearerBalance = variableBearerToken[msg.sender]; + require(bearerBalance > 0, "NBT"); + + uint256 totalOwed = bearerBalance.mulDiv(totalEarnings, variableSideCapacity); + uint256 ethAmountOwed = 0; + if (previousWithdrawnAmount < totalOwed) { +@> ethAmountOwed = totalOwed - previousWithdrawnAmount; + } + + return (ethAmountOwed + previousWithdrawnAmount, ethAmountOwed); + } +``` +As a result, the lack of sufficient dust Ether may prevent the last user of the vault from successfully withdrawing their funds. + +### Internal pre-conditions + +Assume the following condition: + totalEarnings = 10000 + vaultEndingETHBalance = 123 + vaultEndingStakesAmount = 97 + variableSideCapacity = 1000 + +### External pre-conditions + +_No response_ + +### Attack Path + +Assume that: + variableBearerToken[Alice] = 170 +Then, + currentState = 10000 * 170 / 1000 = 1700 + currentState.mulDiv(vaultEndingStakesAmount,vaultEndingETHBalance) = 1700 * 97 /123 = 1340 + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(vaultEndingETHBalance, vaultEndingStakesAmount) = 1340 * 123 / 97 = 1699 + +It means that Alice can always 1700 - 1699 = 1wei forever. As a result, the last user may be unable to withdraw their funds due to a lack of sufficient dust amounts. + +Even in the absence of malicious users, this situation can still occur. + +### Impact + +The last user of the vault cannot withdraw his fund. + +### PoC + +_No response_ + +### Mitigation + +```solidity + (, uint256 ethAmountOwed) = calculateVariableWithdrawState( + (lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits), +- variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes) ++ variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes, Rounding.Up) + ); +``` + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L775-L785 +```diff + uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; + + if (totalEarnings > 0) { + (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( + totalEarnings, +- variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(vaultEndingETHBalance, vaultEndingStakesAmount) ++ variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(vaultEndingETHBalance, vaultEndingStakesAmount,Rounding.Up) + ); + stakingShareAmount = stakingEarningsShare; +- variableToWithdrawnStakingEarningsInShares[msg.sender] = currentState.mulDiv(vaultEndingStakesAmount,vaultEndingETHBalance); ++ variableToWithdrawnStakingEarningsInShares[msg.sender] = currentState.mulDiv(vaultEndingStakesAmount,vaultEndingETHBalance, Rounding.Up); + variableToWithdrawnStakingEarnings[msg.sender] = currentState; + } +``` \ No newline at end of file diff --git a/001/136.md b/001/136.md new file mode 100644 index 0000000..fe52d7b --- /dev/null +++ b/001/136.md @@ -0,0 +1,80 @@ +Festive Marmalade Unicorn + +Medium + +# An incorrect income distribution will lead to fund losses during slashing + +### Summary + +A variable user can withdraw their income before the Saffron Lido Vault concludes. However, total income may decrease due to slashing, allowing variable users to withdraw despite this reduction. Consequently, some users who delay their withdrawals may find that insufficient ethers remain in the vault to cover their funds. + +### Root Cause + +In LidoVault.sol:775, the calculation of `totalEarnings` is incorrect, particularly during slashing events, which could lead to potential fund losses for users. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L775-L785 +```solidity +775: uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; + +777: if (totalEarnings > 0) { + (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( + totalEarnings, + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(vaultEndingETHBalance, vaultEndingStakesAmount) + ); + stakingShareAmount = stakingEarningsShare; + variableToWithdrawnStakingEarningsInShares[msg.sender] = currentState.mulDiv(vaultEndingStakesAmount,vaultEndingETHBalance); + variableToWithdrawnStakingEarnings[msg.sender] = currentState; + } +``` + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L673-L680 +```solidity + uint256 amountWithdrawn = claimWithdrawals(msg.sender, vaultEndedWithdrawalRequestIds); + uint256 fixedETHDeposit = fixedSidestETHOnStartCapacity; + if (amountWithdrawn > fixedETHDeposit) { + vaultEndedStakingEarnings = amountWithdrawn - fixedETHDeposit; + vaultEndedFixedDepositsFunds = fixedETHDeposit; + } else { + vaultEndedFixedDepositsFunds = amountWithdrawn; + } +``` + +### Internal pre-conditions + +There is a vault with the following parameters. +fixedSideCapacity : 100 ETH +variableSideCapacity : 3 ETH + +### External pre-conditions + +Slashing occurs in the Lido Protocol. + +### Attack Path + +1. Alice deposits 100 ETH.(a fixed user) +2. Bob deposits 2 ETH and Charlie deposits 1 ETH.(variable users) +3. The stETH balance of vault increases to 103 ETH. +4. Bob withdraw his income (103 - 100) * 2 / 3 = 2 ETH. (For simplicity, we assume no protocol fee) +5. The vault ends when the stETH balance of vault increases to from 101 ETH to 99 ETH. +6. In LidoVault.sol:775, + `vaultEndedStakingEarnings` is 0, because 99 < 101 (See LidoVault.sol:L673-L680). + totalProtocolFee = 0. + vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) is about 2 ETH. + So, `totalEarnings` is about 2ETH. +7. Charlie can withdraw income of about 1ETH. +8. Alice should withdraw 99ETH. + +As a result of the above scenario, at least one of Alice and Charlie cannot withdraw their fund because there is no enough ether. + +### Impact + +1. Some fixed user may not return back his principal. +2. Some variable user may not receive his income. + +### PoC + +_No response_ + +### Mitigation + +Variable users should not be allowed to withdraw their income before the end. Or income distribution mechanism should be improved. \ No newline at end of file diff --git a/001/138.md b/001/138.md new file mode 100644 index 0000000..befd83b --- /dev/null +++ b/001/138.md @@ -0,0 +1,54 @@ +Tart Purple Spider + +Medium + +# Variable Side Interest Calculation Inaccuracy in Ended Vaults + +## Details + +Saffron protocol allows users to deposit ETH into vaults. These vaults have a fixed side, offering a fixed yield, and a variable side, offering a variable yield based on Lido staking rewards. + +The LidoVault contract manages these deposits, withdrawals, and interest calculations. When a vault ends, the vaultEndedWithdraw function calculates the proportional share of earnings for variable-side depositors. However, this calculation currently doesn't accurately account for the protocol fees already deducted from the withdrawnStakingEarnings when determining the total earnings to be distributed. + +The current implementation could lead to a slight overestimation of the total earnings distributable to variable-side depositors in a vault that has ended. This is because the totalEarnings calculation in the vaultEndedWithdraw function subtracts the totalProtocolFee from the sum of withdrawnStakingEarnings and vaultEndedStakingEarnings, but the withdrawnStakingEarnings have already had the protocol fee deducted during the active phase of the vault. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L709 + +```solidity +function vaultEndedWithdraw(uint256 side) internal { + // ... (other code) ... + uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; + // ... (rest of the function) +} +``` + +## Impact + +Although the discrepancy might be small, it represents an accounting inaccuracy that could erode trust in the protocol over time. Variable-side depositors may receive slightly less than their entitled share of the earnings, particularly if there were substantial withdrawals during the active vault phase, leading to a higher accumulated totalProtocolFee. + +## Scenario + +- A vault with a significant variableSideCapacity ends. +- During the active phase of the vault, there were numerous variable-side withdrawals, leading to a relatively large totalProtocolFee. +- When a remaining variable-side depositor attempts to withdraw their share after the vault ends, the totalEarnings calculation doesn't fully account for the deducted protocol fees from the withdrawnStakingEarnings, resulting in a slight overestimation of their share. + +## Fix + +Adjust the totalEarnings calculation within the vaultEndedWithdraw function to avoid double-counting the protocol fee deduction: + +```solidity +// Updated vaultEndedWithdraw function in LidoVault.sol +function vaultEndedWithdraw(uint256 side) internal { + // ... (other code) ... + + // Calculate totalEarnings considering already deducted protocol fees + uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) + vaultEndedStakingEarnings; + + // ... (rest of the function) +} +``` + +By removing - totalProtocolFee from the totalEarnings calculation, we ensure that the protocol fees are not subtracted twice. + diff --git a/001/144.md b/001/144.md new file mode 100644 index 0000000..3b5541a --- /dev/null +++ b/001/144.md @@ -0,0 +1,230 @@ +Amusing Flaxen Bull + +High + +# Unaccounted protocol fee will lead to funds getting locked + +### Summary + +Not accounting of a protocol fee on variable side withdraw will decrease the user's earnings and lock the funds in the contract. + +### Root Cause + +In [vaultEndedWithdraw()](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L780) when calculating the `stakingEarningsShare`, the [calculateVariableWithdrawState()](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L859) is used. This function accepts the total earnings and the previously withdrawn earnings. The previously withdrawn earnings are calculated using `variableToWithdrawnStakingEarningsInShares[msg.sender]`, which is tracking the shares equivalent of the amount withdrawn. But in [withdraw()](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L531-L545) we can see that the share amount also includes the protocol fees, for which we have not accounted. + +### Internal pre-conditions + +1. At least one variable side withdrawal to be made before the end of the vault duration. + +### External pre-conditions + +_No response_ + + +### Attack Path + +1. Fixed side deposit and Variable side deposits are made, so that the vault is started. +2. Rewards are accrued because of the staking. +3. Variable side user calls `withdraw(1)`, which will create withdraw request for a part of these rewards. +`variableToWithdrawnStakingEarningsInStakes[msg.sender]` is updated to represent the shares that will be burnt, and `variableToWithdrawnStakingEarnings[msg.sender]` is updated to represent the withdrawn amount without the protocol fees. +4. When the duration ends, the staking is finalized, all staked amount is unstaked and all withdraw requests are claimed. +5. Any user participating in the variable side call `withdraw(1)`, and according to the calculations he will receive less than he had to. +6. All users have withdrawn and the `protocolFeeRecipient` withdraws all fees + +### Impact + +- The user will receive less than expected rewards. The exact amount is `(protocolFee1 + protocolFee2 ... + protoclFeeN)`, where `N` is the number of withdrawals made before vault duration ends. +- The amount that the user will not receive will be left inaccessible in the contract. + +### PoC + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import "@openzeppelin/contracts/utils/math/Math.sol"; + +import {Test, console2} from "forge-std/Test.sol"; + +import {VaultFactory} from "src/VaultFactory.sol"; +import {LidoVault} from "src/LidoVault.sol"; +import {ILido} from "src/interfaces/ILido.sol"; +import {MockLido} from "src/mocks/MockLido.sol"; +import {MockLidoWithdrawalQueue} from "src/mocks/MockLidoWithdrawalQueue.sol"; + +contract PoC is Test { + using Math for uint256; + + address payable constant LIDO = payable(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84); + address payable constant LIDO_WITHDRAWAL_QUEUE = payable(0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1); + + VaultFactory vf; + LidoVault lv; + + address fixedUser = makeAddr("fixedUser"); + address variableUser1 = makeAddr("variableUser1"); + address variableUser2 = makeAddr("variableUser2"); + address protocolFeeReceiver = makeAddr("protocolFeeReceiver"); + + uint256 fixedSideCapacity = 1 ether; + uint256 variableSideCapacity = 1 ether; + uint256 duration = 7 days; + + function setUp() public { + // Deploy the Lido mocks on the respective addresses + deployCodeTo("MockLido", LIDO); + deployCodeTo("MockLidoWithdrawalQueue", LIDO_WITHDRAWAL_QUEUE); + + MockLidoWithdrawalQueue(LIDO_WITHDRAWAL_QUEUE).initialize(LIDO); + + deal(LIDO_WITHDRAWAL_QUEUE, 100 ether); + + // Deploy the vault factory + vm.prank(protocolFeeReceiver); + vf = new VaultFactory(100, 100); + + uint256 vaultId = vf.nextVaultId(); + + vf.createVault(fixedSideCapacity, duration, variableSideCapacity); + (, address addr) = vf.vaultInfo(vaultId); + + lv = LidoVault(payable(addr)); + + deal(fixedUser, 10 ether); + deal(variableUser1, 10 ether); + deal(variableUser2, 10 ether); + } + + function test2() public { + uint256 protocolStartingBalance = address(lv).balance; + // Fixed side deposit + vm.prank(fixedUser); + lv.deposit{value: 1 ether}(0); + + // Variable side deposit + vm.prank(variableUser1); + lv.deposit{value: 0.5 ether}(1); + + vm.prank(variableUser2); + lv.deposit{value: 0.5 ether}(1); + + // Validate the vault have started + assert(lv.isStarted() == true); + assert(lv.isEnded() == false); + + // Claim the fixed premium + vm.prank(fixedUser); + lv.claimFixedPremium(); + + // Add rewards + MockLido(LIDO).addStakingEarnings(1 ether); + + // Create withdraw variable request + vm.prank(variableUser1); + lv.withdraw(1); + + //Validate the request has been created + assert(lv.variableToWithdrawnStakingEarningsInShares(variableUser1) > 0); + assert(lv.totalProtocolFee() > 0); + uint256 firstWithdrawalFee = lv.totalProtocolFee(); + + // Finalize the withdraw + uint256 startBalance = variableUser1.balance; + vm.prank(variableUser1); + lv.finalizeVaultOngoingVariableWithdrawals(); + uint256 endBalance = variableUser1.balance; + + uint256 withdrawn = endBalance - startBalance; + + // Add rewards again + MockLido(LIDO).addStakingEarnings(1 ether); + + // Roll the time to the end of the vault + skip(duration + 1); + + // Ensure the duration has ended + assert(lv.isEnded() == true); + + // Finalize the vault + vm.prank(fixedUser); + lv.withdraw(0); // Triggers the withdraw from the LIDO of the whole balance + + // Claim the balance withdrawn and update the state variables + vm.prank(fixedUser); + lv.finalizeVaultEndedWithdrawals(0); + + // Ensure the withdrawals of the whole balance are settled + assert(lv.vaultEndedWithdrawalsFinalized() == true); + + // Calculating the amount that has been withdrawn before the vault ended + // using the formula from the LidoVault.sol::vaultEndedWithdraw() line 780 + uint256 withdrawnCalculations = lv.variableToWithdrawnStakingEarningsInShares(variableUser1).mulDiv( + lv.vaultEndingETHBalance(), lv.vaultEndingStakesAmount() + ); + + assert(withdrawn < withdrawnCalculations); + + // Everyone withdraws + vm.prank(variableUser1); + lv.withdraw(1); + + vm.prank(variableUser2); + lv.withdraw(1); + + // Claim the fees + vm.prank(protocolFeeReceiver); + lv.withdraw(1); + + assert(lv.appliedProtocolFee() == 0); + + uint256 protocolFinalBalance = address(lv).balance; + + uint256 finalBalance = protocolFinalBalance - protocolStartingBalance; + + // Validate that exactly the fee of the first withdrawal is remaining + // It will be locked in the contract + assert(finalBalance != 0); + assert(finalBalance == firstWithdrawalFee); + } +} +``` + +### Mitigation + +In the [LidoVault.sol](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L762-L786) +```diff + } else { + require(variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0, "WAR"); + + if (msg.sender == protocolFeeReceiver && appliedProtocolFee > 0) { + return protocolFeeReceiverWithdraw(); + } + + uint256 bearerBalance = variableBearerToken[msg.sender]; + require(bearerBalance > 0, "NBT"); + + // Return proportional share of both earnings to caller + uint256 stakingShareAmount = 0; + + uint256 totalEarnings = vaultEndingETHBalance.mulDiv( + withdrawnStakingEarningsInStakes, vaultEndingStakesAmount + ) - totalProtocolFee + vaultEndedStakingEarnings; + + if (totalEarnings > 0) { +- (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( +- totalEarnings, +- variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv( +- vaultEndingETHBalance, vaultEndingStakesAmount +- ) +- ); ++ (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( ++ totalEarnings, ++ variableToWithdrawnStakingEarnings[msg.sender] ++ ); + stakingShareAmount = stakingEarningsShare; + variableToWithdrawnStakingEarningsInShares[msg.sender] = + currentState.mulDiv(vaultEndingStakesAmount, vaultEndingETHBalance); + variableToWithdrawnStakingEarnings[msg.sender] = currentState; + } + +``` \ No newline at end of file diff --git a/001/146.md b/001/146.md new file mode 100644 index 0000000..81667a0 --- /dev/null +++ b/001/146.md @@ -0,0 +1,106 @@ +Festive Marmalade Unicorn + +High + +# Incorrect earning calculation while vault is in active + +### Summary + +When the variable depositors withdraw their earnings, the protocol calculates the `totalEarnings` and `previousWithdrawnAmount` using shares and the stETH balance. However, the calculation is incorrect because it multiplies the current stETH balance per stake by the previous withdrawn stakes. As a result, the variable depositors receive incorrect earnings, and the fixed depositors may receive a smaller amount. + +### Root Cause + +There is an incorrect calculation of the `totalEarnings` and `previousWithdrawnAmount` parameters in the `calculateVariableWithdrawState` function within the [`LidoVault.withdraw`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L527) function. + +```solidity + (uint256 currentState, uint256 ethAmountOwed) = calculateVariableWithdrawState( + (lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits), + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes) + ); +``` + +If the stETH balance per stake differs from the previous balance per stake, the `totalEarnings` is calculated incorrectly because it uses `withdrawnStakingEarningsInStakes`. Additionally, `previousWithdrawnAmount` is calculated incorrectly because the previous withdrawn shares are multiplied by the current balance per stake. + +```solidity +totalEarnings = lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits +previousWithdrawnAmount = variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes) +``` + +There is also an incorrect calculation of the `totalEarnings` and `previousWithdrawnAmount` parameters in the `calculateVariableWithdrawState` function within the [`LidoVault.vaultEndedWithdraw`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L775-L780) function. + +```solidity + uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; + + if (totalEarnings > 0) { + (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( + totalEarnings, + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(vaultEndingETHBalance, vaultEndingStakesAmount) + ); + } +``` + +### Internal pre-conditions + +1. There is a vault and Alice and Bob are variable side depositors and they deposits same ETH. +2. The vault is started with the following initial values. + - `fixedSideCapacity`: 1000 wei (use wei instead of ETH for the simplicity) + - `variableSideCapacity`: 30 wei + - `fixedSidestETHOnStartCapacity`: 1000 wei + - `fixedClaimTokenTotalSupply`: 1000 + - `protocolFeeBps`: 0 (for the simplicity) +3. At time `start < t1 < end`, lido gets 100 wei profit. +4. At time `start < t2 < end`, lido also gets 100 wei profit. + +### External pre-conditions + +_No response_ + +### Attack Path + +Let's consider the following scenario: + +- At time `t1`, the `lidoStETHBalance` is 1100 wei after gaining a profit of 100 wei, and Alice calls the `withdraw` function. + +```solidity +currentStakes = 1000 +fixedETHDeposits = 1000 +totalEarnings = lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits = 1100 - 1000 = 100. +previousWithdrawnAmount = 0 +totalOwed = bearerBalance.mulDiv(totalEarnings, variableSideCapacity) = 100 / 2 = 50 +ethAmountOwed = 50 - 0 = 50. +stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed) = 50 * 1000 / 1100 = 45 +withdrawnStakingEarningsInStakes = 45 +variableToWithdrawnStakingEarnings[`Alice`] = 50 +variableToWithdrawnStakingEarningsInShares[`Alice`] = 45 +lidoStETHBalance = 1050 +``` + +- At time `t2`, the `lidoStETHBalance` is 1250 wei after gaining a profit of 200 wei, and Bob calls the `withdraw` function. + +```solidity +currentStakes = 1000 - 45 = 955 +totalEarnings = lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits = 1250 * 1000 / 955 - 1000 = 308 +previousWithdrawnAmount = 0 +totalOwed = bearerBalance.mulDiv(totalEarnings, variableSideCapacity) = 308 / 2 = 154 +ethAmountOwed = 154 - 0 = 154 +stakesAmountOwed = lido.getSharesByPooledEth(ethAmountOwed) = 154 * 955 / 1250 = 117 +withdrawnStakingEarningsInStakes = 45 + 117 = 162 +variableToWithdrawnStakingEarnings[`Bob`] = 154 +variableToWithdrawnStakingEarningsInShares[`Bob`] = 117 +lidoStETHBalance = 1250 - 154 = 1096 +``` + +Total earnings is 300 and Alice and Bob should receive same earnings because they deposits the same ethers. +However, Bob receives 154 instead of 150 and this is unfair for Alice. + +### Impact + +The variable user may receive unfair earnings. + +### PoC + +_No response_ + +### Mitigation + +The variable depositors should not be allowed to withdraw earnings before the vault ended. \ No newline at end of file diff --git a/001/157.md b/001/157.md new file mode 100644 index 0000000..2ec4fcf --- /dev/null +++ b/001/157.md @@ -0,0 +1,87 @@ +Tiny Heather Viper + +Medium + +# Inaccurate Variable Withdraw Calculation in Slashing Scenarios + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L880 + + in function: `getCalculateVariableWithdrawStateWithStakingBalance` + +The function doesn't account for the scenario where the Lido stETH balance is less than the fixed ETH deposits. This can happen due to slashing incidents or other unexpected losses in the Lido Liquid Staking protocol. + + +```solidity +function getCalculateVariableWithdrawStateWithStakingBalance(address user) public view returns (uint256) { + uint256 lidoStETHBalance = stakingBalance(); + uint256 fixedETHDeposits = fixedETHDepositTokenTotalSupply; + require(lidoStETHBalance > fixedETHDeposits, "LBL"); + // ... rest of the function +} +``` + +The function requires that the Lido stETH balance is greater than the fixed ETH deposits. If this condition is not met, the function will revert with the error "LBL" (Lido Balance Low). + +However, this approach doesn't align with the stated acceptable risk in the documentation: + +"The Lido Liquid Staking protocol can experience slashing incidents (such as this https://blog.lido.fi/post-mortem-launchnodes-slashing-incident/). These incidents will decrease income from deposits to the Lido Liquid Staking protocol and could decrease the stETH balance. The contract must be operational after it, but it is acceptable for users to lose part of their income/deposit (for example, a fixed user receives less at the end of the Vault than he deposited at the start)." + +The current implementation prevents the contract from being operational in such scenarios, contradicting the stated design goal. + +To address this vulnerability and align with the project's risk acceptance, the function should handle the case where `lidoStETHBalance <= fixedETHDeposits`. Instead of reverting, it should calculate the variable withdraw state based on the available balance, even if it results in a loss for the users. + +This missed case could have a significant impact on the project, as it prevents variable side users from withdrawing their funds in scenarios where losses have occurred, which goes against the stated design principles of the contract. + + +#### 1. Trigger Condition: +The bug can be triggered when the Lido stETH balance becomes less than the fixed ETH deposits. This can happen due to slashing events in the Lido protocol, as mentioned in the documentation. + +#### 2. Why it can be triggered: +The contract doesn't have any mechanism to prevent or handle a situation where the stETH balance drops below the fixed deposits. The `stakingBalance()` function directly returns the stETH balance from Lido, which can fluctuate. + +#### 3. Impact and Flow: + +The impact of this bug is less severe because: + +a) It only affects a view function, not a state-changing function. +b) The main withdrawal functions (`withdraw`, `finalizeVaultOngoingVariableWithdrawals`, `vaultEndedWithdraw`) do not directly use this function. + +However, it still has some impact: + +1) It prevents variable side users from accurately estimating their withdrawable amount when the stETH balance is low. +2) It could cause issues in any external contracts or UI components that rely on this function for calculations. + + +#### PoC Example: + +1. Deploy the LidoVault contract +2. Users deposit into both fixed and variable sides, starting the vault +3. A slashing event occurs in Lido, reducing the stETH balance +4. Try to call `getCalculateVariableWithdrawStateWithStakingBalance`: + +```solidity +function testSlashingScenario() public { + // Setup vault and deposits... + + // Simulate a slashing event + uint256 initialBalance = vault.stakingBalance(); + uint256 slashedBalance = initialBalance * 90 / 100; // 10% slash + // (You'd need to mock the Lido contract to actually reduce the balance) + + // This call will revert + try vault.getCalculateVariableWithdrawStateWithStakingBalance(variableUser) { + fail("This should have reverted"); + } catch Error(string memory reason) { + assertEq(reason, "LBL"); + } + + // However, actual withdrawals would still work + vault.withdraw(VARIABLE); +} +``` + +#### 4. Actual Severity: +While this is a real bug, its severity is MEDIUM rather than HIGH because: + +- It doesn't directly affect the core withdrawal functionality. + diff --git a/001/159.md b/001/159.md new file mode 100644 index 0000000..d3b9eaf --- /dev/null +++ b/001/159.md @@ -0,0 +1,31 @@ +Noisy Eggshell Peacock + +High + +# wrong calculation of `totalOwed ` for the variable Users in `getCalculateVariableWithdrawStateWithStakingBalance()` + +## Summary +In `getCalculateVariableWithdrawStateWithStakingBalance()` , `fixedETHDeposits` takes value from `fixedETHDepositTokenTotalSupply` instead of `fixedSidestETHOnStartCapacity` resulting into wrong calculation of `totalOwed ` for the variable Users + +## Vulnerability Detail + +`getCalculateVariableWithdrawStateWithStakingBalance()` is a helper function that returns the withdrawAMount that a variable user can withdraw from vault at any time within the vault duration. + + +But the `fixedETHDeposits` used to calculate the withdrawAmount is taken as `fixedETHDepositTokenTotalSupply`.But the `fixedETHDepositTokenTotalSupply` is a fixed value reflecting the amount of total eth deposited by fixed users till the vault starts and doesnt convey how much `stEth` deposited by the fixed Users is remaining in the Lido Vault. + + + +## Impact +wrong calculation of `totalOwed ` for the variable Users in `getCalculateVariableWithdrawStateWithStakingBalance()`. + + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L882 + +## Tool used + +Manual Review + +## Recommendation +use `fixedSidestETHOnStartCapacity` \ No newline at end of file diff --git a/001/167.md b/001/167.md new file mode 100644 index 0000000..b067e19 --- /dev/null +++ b/001/167.md @@ -0,0 +1,35 @@ +Prehistoric Crepe Jellyfish + +Medium + +# user who request withdraw before vault start balance is still accounted in the vault . + +## Summary +if the user whos withdraw request was set before the vault started and withdrawn after vault start will increase ``fixedSidestETHOnStartCapacity`` . +## Vulnerability Detail +If the user request withdraw before vault start which increases the fixedSidestETHOnStartCapacity and that fixedSidestETHOnStartCapacity is used for calculating withdraw amount . +if the user has deposited a large amount of deposit and request before the vault starts then the user who withdraw will be calculated with that large staking balance which could lead to wrong price being withdrawn + +In Withdraw function the fixedETHDeposits is used , +```Solidity + uint256 fixedETHDeposits = fixedSidestETHOnStartCapacity; + + uint256 withdrawAmount = fixedETHDeposits.mulDiv(fixedBearerToken[msg.sender], fixedLidoSharesTotalSupply()); + uint256 lidoStETHBalance = stakingBalance(); + + if (fixedETHDeposits > lidoStETHBalance) { + + // our staking balance if less than our stETH deposits at the start of the vault - only return a proportional amount of the balance to the fixed user + withdrawAmount = lidoStETHBalance.mulDiv(fixedBearerToken[msg.sender], fixedLidoSharesTotalSupply()); + } +``` +## Impact +incorrect accounting +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L488 +## Tool used + +Manual Review + +## Recommendation +dont account balance of user who has withdrawn before vault staarted \ No newline at end of file diff --git a/001/168.md b/001/168.md new file mode 100644 index 0000000..31fda59 --- /dev/null +++ b/001/168.md @@ -0,0 +1,38 @@ +Noisy Eggshell Peacock + +High + +# Incorrect calculation for `previousWithdrawnAmount` in withdraw() for variable Users while Vault is ongoing + +## Summary +While calculating the share of yield for the Variable user when they call `withdraw()` while the Vault is ongoing , the calculation ends up in incorrect value since the `previousWithdrawnAmount` by that user is incorrectly calculated before passing to `calculateVariableWithdrawState()`. + +Also the correct implementaion for calculating ethAmountOwed is implented in a seperate function `getCalculateVariableWithdrawStateWithStakingBalance()`. + +## Vulnerability Detail + +[code](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L526-L529) +```solidity + (uint256 currentState, uint256 ethAmountOwed) = calculateVariableWithdrawState( + (lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits), + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes) + ); +``` +Here the `previousWithdrawnAmount` is calculated as `variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes)` but the actual value withdrawn by the user should be calculated as +```solidity + uint256 previousWithdrawnAmount = variableToWithdrawnStakingEarnings[user].mulDiv(10000, 10000 - protocolFeeBps); +``` + +The first one shows incorrect amount since the share Price in Lido can change from what it was when the user withdrawn initially. + + +## Impact +Incorrect calculation of `previousWithdrawnAmount` and variable users are grieved therefore. +## Code Snippet + +## Tool used + +Manual Review + +## Recommendation +use the correct implentaiton/ \ No newline at end of file diff --git a/001/169.md b/001/169.md new file mode 100644 index 0000000..c432cf4 --- /dev/null +++ b/001/169.md @@ -0,0 +1,52 @@ +Tangy Raisin Woodpecker + +Medium + +# withdraw() has Inconsistent protocol fee accounting between vault ongoing and vault ends + +## Summary +`withdraw()` has Inconsistent protocol fee accounting between vault ongoing and vault ends. `withdraw()` will calculate incorrect post-protocol fee earnings for variable-side users. + +## Vulnerability Detail +Before vault ends, when a variable-side user requests a withdrawal of earnings, the post fee earning is: `(proportional_totalEarnings - previousWithdrawnAmount) * (1 - protocolFeeBps)`. Note in this case both `(proportional_totalEarnings` and `previousWithdrawnAmount` are pre-fee values. +```solidity + function withdraw(uint256 side) external { +... + (uint256 currentState, uint256 ethAmountOwed) = calculateVariableWithdrawState( + (lidoStETHBalance.mulDiv(currentStakes + withdrawnStakingEarningsInStakes, currentStakes) - fixedETHDeposits), + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(lidoStETHBalance, currentStakes) + ); +... +``` + + + +After vault ends, when a variable-side user requests a withdrawal of earnings, the protocol fee is simply applied on proportional totalEarnings. The post fee earning is: `(proportional_totalEarnings - totalProtocolFee - previousWithdrawnAmount)` . Note that in this case `proportional_totalEarnings - totalProtocolFee` is a post-fee value, but `previousWithdrawnAmount` is still pre-fee values. + +```solidity + function vaultEndedWithdraw(uint256 side) internal { +... + //@audit After vault ends, totalEarnings is a post-fee value, but variableToWithdrawnStakingEarningsInShares[msg.sender] is always pre-fee value. Inconsistent with withdraw before vault ends. +|> uint256 totalEarnings = vaultEndingETHBalance.mulDiv(withdrawnStakingEarningsInStakes,vaultEndingStakesAmount) - totalProtocolFee + vaultEndedStakingEarnings; + (uint256 currentState, uint256 stakingEarningsShare) = calculateVariableWithdrawState( + totalEarnings, + variableToWithdrawnStakingEarningsInShares[msg.sender].mulDiv(vaultEndingETHBalance, vaultEndingStakesAmount) + ); + +``` +(https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L775) + +Since `previousWithdrawnAmount` is always pre-fee values, the earnings shouldn't include fees. And fees should be deducted afterwards. + + +## Impact +When vault ends, `withdraw()` will calculate incorrect post-protocol fee earnings for variable-side users. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L775 +## Tool used + +Manual Review + +## Recommendation +In `vaultEndedWithdraw()`, use pre-fee values for `totalEarnings`. \ No newline at end of file diff --git a/002/004.md b/002/004.md new file mode 100644 index 0000000..5648e18 --- /dev/null +++ b/002/004.md @@ -0,0 +1,162 @@ +Oblong Chiffon Mole + +Medium + +# Unrestricted Repeated Deposits Before Vault Initialization + +## Summary +The `LidoVault` contract allows users to deposit Ether into the vault before it starts. However, there is no mechanism to prevent a single user from making multiple deposits, potentially filling the entire vault capacity. This behavior can lead to unfair distribution of staking opportunities and may prevent other users from participating. + +## Vulnerability Detail +The vulnerability arises from the lack of checks to prevent repeated deposits by the same user before the vault has started. +```solidity +328: function deposit(uint256 side) external payable { +--- +330:@=> require(fixedSideCapacity != 0, "NI"); +331:@=> require(!isStarted(), "DAS"); +332: require(side == FIXED || side == VARIABLE, "IS"); +333: require(msg.value >= minimumDepositAmount, "MDA"); +--- +335: uint256 amount = msg.value; +336: if (side == FIXED) { +--- +339: uint256 minimumFixedDeposit = fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000); +340: require(amount >= minimumFixedDeposit, "MFD"); +--- +343:@=> require(amount <= fixedSideCapacity - fixedETHDepositTokenTotalSupply, "OED"); +--- +345:@=> uint256 remainingCapacity = fixedSideCapacity - fixedETHDepositTokenTotalSupply - amount; +346: require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); +--- +346: require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); +--- +350: uint256 stETHBalanceBefore = stakingBalance(); +351: uint256 shares = lido.submit{value: amount}(address(0)); // _referral address argument is optional use zero address +352: require(shares > 0, "ISS"); +--- +354: uint256 stETHReceived = (stakingBalance() - stETHBalanceBefore); +355: require((stETHReceived >= amount) || (amount - stETHReceived <= LIDO_ERROR_TOLERANCE_ETH), "ULD"); +356: emit FundsStaked(amount, shares, msg.sender); +--- +359: fixedClaimToken[msg.sender] += shares; +360: fixedClaimTokenTotalSupply += shares; +361: fixedETHDepositToken[msg.sender] += amount; +362: fixedETHDepositTokenTotalSupply += amount; +--- +364: emit FixedFundsDeposited(amount, shares, msg.sender); +365: } else { +--- +369:@=> require(amount <= variableSideCapacity - variableBearerTokenTotalSupply, "OED"); +370:@=> uint256 remainingCapacity = variableSideCapacity - variableBearerTokenTotalSupply - amount; +371: require(remainingCapacity == 0 || remainingCapacity >= minimumDepositAmount, "RC"); +--- +374: variableBearerToken[msg.sender] += amount; +375: variableBearerTokenTotalSupply += amount; +--- +377: emit VariableFundsDeposited(amount, msg.sender); +378: } +--- +381: if ( +382: fixedETHDepositTokenTotalSupply == fixedSideCapacity && +383: variableBearerTokenTotalSupply == variableSideCapacity +384: ) { +385: startTime = block.timestamp; +386: endTime = block.timestamp + duration; +387: fixedSidestETHOnStartCapacity = stakingBalance(); +388: fixedIntialTokenTotalSupply = fixedClaimTokenTotalSupply; +389: emit VaultStarted(block.timestamp, msg.sender); +390: } +391: } +``` +```solidity +require(fixedSideCapacity != 0, "NI"); +require(!isStarted(), "DAS"); +``` +The function checks if the vault has started, but does not check if the user has already deposited. +```solidity +require(amount <= fixedSideCapacity - fixedETHDepositTokenTotalSupply, "OED"); +uint256 remainingCapacity = fixedSideCapacity - fixedETHDepositTokenTotalSupply - amount; +``` +Allows deposits up to the remaining capacity without checking if the user has already contributed. +```solidity +require(amount <= variableSideCapacity - variableBearerTokenTotalSupply, "OED"); +uint256 remainingCapacity = variableSideCapacity - variableBearerTokenTotalSupply - amount; +``` +Similar to the fixed side, it allows deposits up to the remaining capacity without user-specific checks. + +## Impact +- A single user can monopolize the vault capacity, preventing others from participating. +- Legitimate users may be unable to deposit if one or more users fill the vault capacity prematurely. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L328-L391 + +## Tool used + +Manual Review + +## Recommendation +Implement a mechanism to track individual user deposits and enforce a maximum limit per user before the vault starts. +```diff + // Add a mapping to track user deposits ++ mapping(address => uint256) public userDeposits; + + // Define a maximum deposit limit per user ++ uint256 public constant MAX_DEPOSIT_PER_USER = 10 ether; + +function deposit(uint256 side) external payable { + require(fixedSideCapacity != 0, "NI"); + require(!isStarted(), "DAS"); + require(side == FIXED || side == VARIABLE, "IS"); + require(msg.value >= minimumDepositAmount, "MDA"); + + // Check if user's total deposit exceeds the maximum allowed ++ require(userDeposits[msg.sender] + msg.value <= MAX_DEPOSIT_PER_USER, "Exceeds max deposit per user"); + + uint256 amount = msg.value; + if (side == FIXED) { + uint256 minimumFixedDeposit = fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000); + require(amount >= minimumFixedDeposit, "MFD"); + + require(amount <= fixedSideCapacity - fixedETHDepositTokenTotalSupply, "OED"); + uint256 remainingCapacity = fixedSideCapacity - fixedETHDepositTokenTotalSupply - amount; + require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); + + uint256 stETHBalanceBefore = stakingBalance(); + uint256 shares = lido.submit{value: amount}(address(0)); + require(shares > 0, "ISS"); + uint256 stETHReceived = (stakingBalance() - stETHBalanceBefore); + require((stETHReceived >= amount) || (amount - stETHReceived <= LIDO_ERROR_TOLERANCE_ETH), "ULD"); + + fixedClaimToken[msg.sender] += shares; + fixedClaimTokenTotalSupply += shares; + fixedETHDepositToken[msg.sender] += amount; + fixedETHDepositTokenTotalSupply += amount; + +- emit FixedFundsDeposited(amount, shares, msg.sender); + } else { + require(amount <= variableSideCapacity - variableBearerTokenTotalSupply, "OED"); + uint256 remainingCapacity = variableSideCapacity - variableBearerTokenTotalSupply - amount; + require(remainingCapacity == 0 || remainingCapacity >= minimumDepositAmount, "RC"); + + variableBearerToken[msg.sender] += amount; + variableBearerTokenTotalSupply += amount; + +- emit VariableFundsDeposited(amount, msg.sender); + } + + // Update user's total deposits ++ userDeposits[msg.sender] += amount; + + if ( + fixedETHDepositTokenTotalSupply == fixedSideCapacity && + variableBearerTokenTotalSupply == variableSideCapacity + ) { + startTime = block.timestamp; + endTime = block.timestamp + duration; + fixedSidestETHOnStartCapacity = stakingBalance(); + fixedIntialTokenTotalSupply = fixedClaimTokenTotalSupply; + emit VaultStarted(block.timestamp, msg.sender); + } +} +``` \ No newline at end of file diff --git a/002/011.md b/002/011.md new file mode 100644 index 0000000..5cbced0 --- /dev/null +++ b/002/011.md @@ -0,0 +1,72 @@ +Dapper Ginger Cyborg + +Medium + +# Any malicious user can easily DoS last depositor + +### Summary + +A malicious actor can front-run a legitimate user’s deposit and withdraw their own funds, causing the remaining vault capacity after the legimate user's to drop below the `minimumFixedDeposit` threshold, causing the transaction to revert. + +### Root Cause + +In the `LidoVault.sol` contract, users can [deposit](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L336-L364) since they don't reach the `fixedSideCapacity`. But there is minimum `minimumFixedDeposit` that a user can deposit. + +⚠️ Note that we are here talking about the `fixed` side deposit, but it also works with the `variable` side. + +To make sure we are not blocked between `0` and `minimumFixedDeposit` for the last deposit (making anyone unable to deposit due to `minimumFixedDeposit`), and because we have to exactly reach the `fixedSideCapacity` to start the vault, the function check for each deposit that the remaining deposit is `= 0` or `>= minimumFixedDeposit`. + +```solidity + require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); +``` + +But we have an issue in the case where a malicious user has already deposit, and want the protocol to get some trouble. +In fact, the malicious user can front run any user that want to do the last deposit and withdraw his deposit. Then, the initial user deposit transaction is rejected if the `remainingCapacity` is too low. + +The attacker can make some deposit to make the contract state as `remainingCapacity < 2 * minimumFixedDeposit` + +In this case the attacker can do the same attack by sandwich all new deposit with: +- a transaction before to withdraw +- a transaction after to deposit again + +In this case, the attacker can do so as much time as he wants, making all users transaction to revert and make them loss transaction gas. + +> Why the case where `remainingCapacity < 2 * minimumFixedDeposit` make this possible ? + +In this case, the user has no choice to deposit the `remainingCapacity`. If he does not, and because his minimum deposit is `minimumFixedDeposit`, this line will revert: + +```solidity + require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); +``` + +And so, in this case, the attacker can withdraw his `minimumFixedDeposit` making transaction to revert in any case. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +- The attacker must have already deposited some ETH into the vault, contributing to the total deposit amount on the fixed side. +- The vault's remaining capacity must be less than 2 times the minimumFixedDeposit. +- A legitimate user must attempt to make a final deposit that would close the remaining capacity of the vault. + +### Attack Path + +1. The attacker deposits ETH into the fixed side of the vault, adding to the total capacity. +2. A legitimate user attempts to deposit an amount that, without interference, would bring the vault to full capacity. +3. The attacker front-runs the legitimate user’s transaction and withdraws a portion of their ETH, reducing the vault’s remaining capacity. +4. This reduction causes the legitimate user’s deposit to fail because the remaining capacity is now less than the required minimumFixedDeposit. +5. After the legitimate user’s transaction fails, the attacker can re-deposit their ETH to retain their position, potentially repeating this process to disrupt other users. + +### Impact + +The users will not be able to start the vault, and some users will lose gas. + +### PoC + +_No response_ + +### Mitigation + +The vault should start when `remainingCapacity < minimumFixedDeposit`. Same for the variable side. diff --git a/002/012.md b/002/012.md new file mode 100644 index 0000000..dce615b --- /dev/null +++ b/002/012.md @@ -0,0 +1,80 @@ +Rural Fuchsia Starfish + +Medium + +# `VaultFactory` Can Deploy Pools That Are Incapable Of Bearing Variable Yield + +### Summary + +The `VaultFactory` succeeds in deploying pools with insufficient `_variableSideCapacity`. + +### Root Cause + +The `_variableSideCapacity` specified by the caller to `VaultFactory::createVault` can be intrinsically lower than the [`minimumDepositAmount`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L62C28-L62C48). + +Since the `LidoVault` only validates that the `_variableSideCapacity` is non-zero, this can realize vaults which are unable to ever be started: + +```solidity +/// @inheritdoc ILidoVault +function initialize(InitializationParams memory params) external { + require(isFactoryCreated != true, "MBF"); + require(fixedSideCapacity == 0, "ORO"); + + // Validate args + require(params.vaultId != 0, "NEI"); + require(params.duration != 0, "NEI"); + require(params.fixedSideCapacity != 0, "NEI"); + require(params.variableSideCapacity != 0, "NEI"); + + /// @audit only performs sanity checks on `fixedSideCapacity` + require( + params.fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000) >= minimumDepositAmount, + "IFC" + ); +``` + +It is impossible to start the auction since: + +```solidity +function deposit(uint256 side) external payable { + + // fixedSideCapacity will not be zero of initialized + require(fixedSideCapacity != 0, "NI"); + require(!isStarted(), "DAS"); + require(side == FIXED || side == VARIABLE, "IS"); + require(msg.value >= minimumDepositAmount, "MDA"); /// @audit msg.value must be greater than minimumDepositAmount +``` + +But [`msg.value` cannot be](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L368C7-L372C1) in excess of the remaining `_variableSideCapacity`: + +```solidity +// no refunds allowed +require(amount <= variableSideCapacity - variableBearerTokenTotalSupply, "OED"); +``` + +This makes it impossible to make a variable side deposit. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Permissionless invocation to `VaultFactory` results in the deployment of a `LidoVault` using a `_variableSideCapacity` that is `> 0 && < minimumDepositAmount`. + +### Impact + +The `LidoVault` will be incapable of bearing variable yield, breaking core contract functionality. + + +### PoC + +_No response_ + +### Mitigation + +Ensure that the `_variableSideCapacity` is greater than or equal to the `minimumDepositAmount`. \ No newline at end of file diff --git a/002/015.md b/002/015.md new file mode 100644 index 0000000..3013cb5 --- /dev/null +++ b/002/015.md @@ -0,0 +1,64 @@ +Keen Blue Chimpanzee + +High + +# Malicious users can manipulate the remaining capacity to prolong the vault's start indefinitely + +## Summary +A malicious user can manipulate the Saffron vault's deposit and withdrawal mechanisms to prevent the vault from ever starting. + +By strategically withdrawing and depositing funds between the fixed and variable sides, the attacker can keep one side just below full capacity, blocking legitimate users from fully funding the vault. +## Vulnerability Detail +The vault requires both the fixed and variable sides to be fully funded before it starts. + +A user can withdraw their funds from a side that has reached full capacity and then immediately deposit those funds into the other side, effectively preventing both sides from being fully funded simultaneously. + +Let's consider the following scenario: +For simplicity, let's say the minimum deposit is 1 ETH and the full capacity is 1000 ETH for both sides. + +- the attacker deposits 1 ETH on both the fixed and variable sides. +- Legitimate users deposit enough to fill the fixed side to 1000 ETH and the variable side to 999 ETH. +The key part here is that one of the sides will get filled up first and we(the attacker) have stakes in both sides. In our example, the fixed side gets filled up, while the variable is almost filled. In reality, it doesn't matter if the variable side has 999eth or 990eth or any other number. +- A legitimate user attempts to deposit 1 ETH into the variable side to fill it. +- The attacker front-runs the legitimate user's transaction by withdrawing their 1 ETH from the fixed side (bringing it to 999 ETH) and depositing 1 ETH into the variable side (bringing it to 1000 ETH). +This can and would be done in the same transaction with the use of a smart contract that withdraws from one side, and deposits to the other side. +Let's clarify what the front run transaction does - 1) it withdraws from the already filled vault(fixed in our example) and 2) deposits into the yet-to-be-filled side(variable in our example) so that the legitimate user's transaction fails. 1) is done so we don't reach a state where both of them are filled and 2) is done to deny the legit user's transaction and ensure the attacker has stake in the filled vault +- The legitimate user's deposit fails(because the capacity is already reached), and the vault cannot start since the fixed side is now at 999 ETH while the variable is 1000 ETH. + +This cycle continues indefinitely as the attacker constantly withdraws and deposits between sides, preventing the vault from ever reaching full capacity. + +The key part is that the attacker makes sure that he has stake in the side that is filled and is ready to front-run legitimate depositors to the other side by withdrawing from the filled and depositing into the not-yet-filled side. + +This process can run indefinitely by the attacker constantly switching sides making sure that he'll always have a stake in the filled side which he can withdraw and then deposit into the other side. + +This can be done with success by using bots or keepers that monitor the vault's state as well as the mempool and act accordingly so the attack can be carried out, using high gas fees to make frontrunning highly likely. +## Impact +Denial of Service - effectively blocks the vault from starting indefinitely, preventing legitimate users from participating and locking their funds in a state where the vault never activates. +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L381-L384 +From `LidoVault.sol::deposit()`: +Code snippet on why the legit user's deposit will fail: +```solidity +require(amount <= fixedSideCapacity - fixedETHDepositTokenTotalSupply, "OED"); +``` +Code snippet on why the vault will never start(needs both sides to be fully filled): +```solidity + if ( + fixedETHDepositTokenTotalSupply == fixedSideCapacity && + variableBearerTokenTotalSupply == variableSideCapacity + ) { + startTime = block.timestamp; + endTime = block.timestamp + duration; + + fixedSidestETHOnStartCapacity = stakingBalance(); + fixedIntialTokenTotalSupply = fixedClaimTokenTotalSupply; + + emit VaultStarted(block.timestamp, msg.sender); + } +``` +## Tool used + +Manual Review + +## Recommendation +Implement measures to prevent frequent withdrawals and deposits by the same user before the vault starts. For example, a reasonable limit on how many deposits and withdrawals can be made by the same user and/or a timelock that needs to pass after you deposited before you can withdraw. Even a small timelock like 5min will solve this attack. \ No newline at end of file diff --git a/002/024.md b/002/024.md new file mode 100644 index 0000000..c3bea81 --- /dev/null +++ b/002/024.md @@ -0,0 +1,82 @@ +Rural Fuchsia Starfish + +Medium + +# Variable Side Withdrawal Slippage + +### Summary + +A variable side depositor's attempt to withdraw early from a pre-`isStarted()` vault can achieve different outcomes than intended due to nondeterministic transaction ordering. + +### Root Cause + +When the `LidoVault` is `!isStarted()`, a variable side depositor mints `variableBearerToken` [directly proportional](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L365C7-L378C6) to the `msg.value`: + +```solidity +// no refunds allowed +require(amount <= variableSideCapacity - variableBearerTokenTotalSupply, "OED"); +uint256 remainingCapacity = variableSideCapacity - variableBearerTokenTotalSupply - amount; +require(remainingCapacity == 0 || remainingCapacity >= minimumDepositAmount, "RC"); + +// Mint bearer tokens +variableBearerToken[msg.sender] += amount; /// @audit amount is == `msg.value` +variableBearerTokenTotalSupply += amount; +``` + +A variable side depositor may also withdraw their stake before the vault `isStarted()` with the expectation to [redeem in fixed terms](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L457C14-L469C8) of the `variableBearerToken`, allowing them to reclaim their entire deposit: + +```solidity +uint256 sendAmount = variableBearerToken[msg.sender]; +require(sendAmount > 0, "NBT"); + +variableBearerToken[msg.sender] -= sendAmount; /// @audit can just withdraw instantly with no ramifications +variableBearerTokenTotalSupply -= sendAmount; + +(bool sent, ) = msg.sender.call{value: sendAmount}(""); +require(sent, "ETF"); + +emit VariableFundsWithdrawn(sendAmount, msg.sender, false, false); +``` + +However, in the time period between submitting the transaction and it landing on chain, other users may have deposited enough capital to complete the `isStarted()` transition, yielding [drastically different withdrawal execution semantics](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L513C14-L556C10) for variable side withdrawal. + +This could happen due to transaction ordering through rational block proposers (i.e transaction gas outpricing), or could be explicitly brought about through frontrunning. + +### Internal pre-conditions + +1. `lidoStETHBalance > fixedETHDeposits + minStETHWithdrawalAmount()`, which can occur through donation or naturally through stETH rebasing. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Victim submits withdrawal request to vault, expecting to be redeemed in fixed terms. +2. Due to frontrunning or unpredictable ordering of transactions due to gas fees, the the victim's transaction lands some time later after the vault `isStarted`. + +### Impact + +Variable side depositors with an expectation to be redeemed proportionally for their assets on withdrawal may instead be coerced (intentionally or unintentionally) into make a withdrawal from a vault that has started, which has significantly different execution semantics compared to making a pre-`isStarted()` withdraw. + +Having known the vault was immediately close to starting, the variable side depositor may have opted not to submit the transaction and bear the reduced payout, especially now that the vault's [strict initialization criteria](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L380C5-L390C6) have been met and rewards will begin to acrue. + +This is a form of transaction slippage that can be prevented through the mitigation. + +### PoC + +_No response_ + +### Mitigation + +Avoid slippage by validating the withdrawer's expectation of the vault state: + +```diff +// @notice Withdraw from the vault +/// @param side ID of side to withdraw from ++ /// @param vaultIsStarted expected state of the vault +- function withdraw(uint256 side) external { ++ function withdraw(uint256 side, bool vaultIsStarted) external { + require(side == FIXED || side == VARIABLE, "IS"); ++ require(isStarted() == vaultIsStarted, "transaction slippage"); +``` \ No newline at end of file diff --git a/002/038.md b/002/038.md new file mode 100644 index 0000000..8befacb --- /dev/null +++ b/002/038.md @@ -0,0 +1,60 @@ +Rural Fuchsia Starfish + +Medium + +# Strict Comparisons Can Be Weaponized + +### Summary + +Since the `LidoVault` cannot be started until [precise conditions have been met](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L380C5-L390C6), it is possible for variable side takers to manipulate the `LidoVault` into an excessively long pre-`isStarted()` phase as a means of value extraction. + +### Root Cause + +There is no economic penalty suffered by variable side stakers for withdrawing early during the withdrawal phase: + +```solidity +uint256 sendAmount = variableBearerToken[msg.sender]; +require(sendAmount > 0, "NBT"); + +variableBearerToken[msg.sender] -= sendAmount; +variableBearerTokenTotalSupply -= sendAmount; + +(bool sent, ) = msg.sender.call{value: sendAmount}(""); /// @audit Redeems exactly their initial deposit. +require(sent, "ETF"); +``` + +This allows for variable side stakers to withdraw arbitrarily, even if the vault is very close to starting (i.e. one deposit remaining). + +Attempts by honest users to fulfill the remaining variable side capacity and start the vault **can therefore be frontrun** with a withdrawal by a malicious variable staker, bringing the vault marginally away from the completion threshold again. + +This means the `LidoVault` can be manipulated into not starting for an excessive duration. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Attacker takes up `variableSideCapacity - minimumDepositAmount` of variable side. +2. Attacker sees pending submission of `minimumDepositAmount` (which would start the vault) in the mempool, and frontruns this transaction to withdraw their variable stake. +3. Victim transaction is mined. +4. Attacker replaces `variableSideCapacity - 2 * minimumDepositAmount`. The `LidoVault` is again close to starting. +5. Repeat. + +### Impact + +1. The vault's pre-`isStarted()` phase can be prolonged to allow stakers extended exposure to stETH rebasing fees on the fixed side. +2. By manipulating the vault into perceived prolonged availability of only the `minimumDepositAmount`, the griefer can increase the quanity of vault shares being held by accounts which have strictly economically lower commitments to the vault. +3. A griefer can squat on variable side capacity at no expense. + +### PoC + +_No response_ + +### Mitigation + +Enforce either a penalty or a sufficiently long time delay to pre-`isStarted()` unstakers to avoid abuse. \ No newline at end of file diff --git a/002/077.md b/002/077.md new file mode 100644 index 0000000..61dc827 --- /dev/null +++ b/002/077.md @@ -0,0 +1,89 @@ +Cuddly Scarlet Antelope + +Medium + +# There will be cases where the last user needs to deposit much more than the minimum amount + +## Summary + +In order for the vault to start, the capacity must be filled at 100%. The minimum that a user can deposit is 5% so there are maximum of <= 20 users per vault, this is important because if there are much more users the other functions might not work because there are a lot of for loops which will cause transactions to be OOG. + +## Vulnerability Detail + +The problem occurs because of the minimum deposit of 5%. It is made in such way that when you deposit there is a variable that checks how much there is left and if the remaining amount is less than 5% your transaction will revert. Basically you can't deposit 96% because this will break the minimum amount of 5% and the last user can't deposit 4%. The problem is that sometimes an edge case will occur where the vault is filled at 91%. Which means that the last user will have to deposit 9%, because if he deposits 5% his transaciton will revert because the remaining will be 4% and he also can't deposit 4% because it is less than the minimum + +Let's examine this problem using the `FIXED` side + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L336 + +```solidity + + function deposit(uint256 side) external payable { + // fixedSideCapacity will not be zero of initialized + require(fixedSideCapacity != 0, "NI"); + + require(!isStarted(), "DAS"); + require(side == FIXED || side == VARIABLE, "IS"); + + require(msg.value >= minimumDepositAmount, "MDA"); + + uint256 amount = msg.value; + if (side == FIXED) { + // Fixed side deposits + + uint256 minimumFixedDeposit = fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000); +>> require(amount >= minimumFixedDeposit, "MFD"); + + // no refunds allowed + require(amount <= fixedSideCapacity - fixedETHDepositTokenTotalSupply, "OED"); + // do not allow remaining capacity to be less than minimum fixed deposit bps +>> uint256 remainingCapacity = fixedSideCapacity - fixedETHDepositTokenTotalSupply - amount; +>> require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); + + // Stake on Lido + /// returns stETH, and returns amount of Lido shares issued for the staked ETH + uint256 stETHBalanceBefore = stakingBalance(); + + uint256 shares = lido.submit{value: amount}(address(0)); // _referral address argument is optional use zero address + require(shares > 0, "ISS"); + // stETH transfered from Lido != ETH deposited to Lido - some rounding error + uint256 stETHReceived = (stakingBalance() - stETHBalanceBefore); + + require((stETHReceived >= amount) || (amount - stETHReceived <= LIDO_ERROR_TOLERANCE_ETH), "ULD"); + emit FundsStaked(amount, shares, msg.sender); + + // Mint claim tokens + + fixedClaimToken[msg.sender] += shares; + fixedClaimTokenTotalSupply += shares; + fixedETHDepositToken[msg.sender] += amount; + fixedETHDepositTokenTotalSupply += amount; + + emit FixedFundsDeposited(amount, shares, msg.sender); + } + + ...MORE CODE + } +``` + +As we can see the first require stops us from depositing less than the minimum, while the second one checks if the remaining amount is less than the minimum, this means that the scenario will occur. Once the collected amount is between 90% and 95% the last user will have to pay the whole remaining amount in order to participate in the vault, otherwise he will not be able to + + +## Impact + +Impact is medium because certain users will not be able to join the vault by paying the minimum amount. They will have to pay more in order to join, yes they will earn more yield than the others but this is not the case, the same rules have to be applied for every user so it is fair for everyone + +Likelihood is medium because each user will make different deposits, some will deposit the minimum of 5% while the others will pay more, but the case that I explained can be easily hit. + + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L336 + +## Tool used + +Manual Review + +## Recommendation + +In the current business logic it will be hard to mitigate this. Maybe instead of strict checks for the capacity, you can add like 5% more to it so the amount collected can be between 100% and 105%. Once it hits 100% or over the vault needs to start. \ No newline at end of file diff --git a/002/081.md b/002/081.md new file mode 100644 index 0000000..148affc --- /dev/null +++ b/002/081.md @@ -0,0 +1,61 @@ +Pet Porcelain Sheep + +Medium + +# Fixed side deposits requirementscan lead to DOS and delayed vault start + +## Summary + +The deposit function for the fixed-side has strict requirements for the final deposit amount, which can lead to DOS or significant delays in starting the vault. + +## Vulnerability Detail + +The `deposit` function for fixed-side investments assumes that the remaining amount that can be deposited for the exact last deposit `fixedSideCapacity - fixedETHDepositTokenTotalSupply`, will always be in the interval: `I = {0} U [minimumFixedDeposit, 2*minimumFixedDeposit - 1]` due to both of these conditions : +```js +require(amount >= minimumFixedDeposit, "MFD"); + +// And this + +uint256 remainingCapacity = fixedSideCapacity - fixedETHDepositTokenTotalSupply - amount; +require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); +``` + +This means the the last deposit amount has to be exactly equal to `fixedSideCapacity - fixedETHDepositTokenTotalSupply` so that the condition `remainingCapacity == 0` would be valid or else it is not going to work because `remainingCapacity ` will always be less than `minimumFixedDeposit`. + +This assumption creates two main issues: + +1. If `minimumDepositAmount` is greater than the `remainingCapacity`, it becomes impossible to make the final deposit to start the vault. This is because the function requires: + ```js + require(msg.value >= minimumDepositAmount, "MDA"); + ``` + +But also needs the final deposit to exactly match the remaining capacity meaning that `remainingCapacity == 0`: + ```js +require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); + ``` + +2. The `minimumFixedDeposit` can be a substantial amount, especially for vaults with large capacities. For example, if `fixedSideCapacity` is 2000 ETH and `minimumFixedDepositBps` is 500 bps, the `minimumFixedDeposit` would be 100 ETH. In the worst case, the final required deposit could be as high as (2 * 100 ETH) - 1, which is a very specific and large amount (approximately $600,000 at an ETH price of $3000). The user needs to have this specific amount at the exact right time (last fixed deposit). This can cause significant delays in the starting of a vault. + +## Impact + +- DOS of the vault start functionality if `minimumDepositAmount` is greater than the `remainingCapacity`. +- Significant delays in starting the vault due to the difficulty in finding a user willing and able to deposit the exact required amount (for the last deposit). +- Discouraged from the waiting time of the start of the vault, users may withdraw their eth, further delaying the process. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L333-L335 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L343-L346 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L381-L384 + +## Tool used + +Manual Review + +## Recommendation + +- The condition `require(msg.value >= minimumDepositAmount, "MDA");` is not needed for the fixed deposits and can only be left for the variable deposits. +- The last deposit should be facilitated and not require more minimum funds. You can lower the `minimumFixedDeposit` when `fixedSideCapacity - fixedETHDepositTokenTotalSupply` < `2 * minimumFixedDeposit`. +- Consider Implementing a maximum time limit for reaching capacity. If the time limit is exceeded, allow the vault to start with the current deposits. + + diff --git a/002/093.md b/002/093.md new file mode 100644 index 0000000..daa3a64 --- /dev/null +++ b/002/093.md @@ -0,0 +1,51 @@ +Innocent Blonde Finch + +Medium + +# Potencial Reentrancy in LidoVault::deposit function + + +## Summary +The `deposit` function doesn't follow the checks-effects-interactions(CEI) pattern strictly. The `lido.submit` call is made before updating state variables, which could potentially be exploited. + +## Vulnerability Detail + +The external call to `lido.submit` occurs before some of the state changes (updating `fixedClaimToken`, `fixedClaimTokenTotalSupply`, etc.). + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L351 + +```javascript + + uint256 stETHBalanceBefore = stakingBalance(); +@> uint256 shares = lido.submit{value: amount}(address(0)); // _referral address argument is optional use zero address + require(shares > 0, "ISS"); + // stETH transfered from Lido != ETH deposited to Lido - some rounding error + uint256 stETHReceived = (stakingBalance() - stETHBalanceBefore); + require((stETHReceived >= amount) || (amount - stETHReceived <= LIDO_ERROR_TOLERANCE_ETH), "ULD"); + emit FundsStaked(amount, shares, msg.sender); + + // Mint claim tokens + fixedClaimToken[msg.sender] += shares; + fixedClaimTokenTotalSupply += shares; + fixedETHDepositToken[msg.sender] += amount; + fixedETHDepositTokenTotalSupply += amount; + + emit FixedFundsDeposited(amount, shares, msg.sender); + +``` + + +A user could have `fallback/receive` function that calls the `LidoVault::deposit` again and claim another refund.They could continue the cycle till the contract balance is drained. + +## Impact +drain the token or claim until finished. + +## Recommended Mitigation: To prevent this we should have the `deposit` fucntion update before making the external call. Additionally, we should move the event emit above as well. + +1. Perform all checks first +2. Update the contract's state +3. Make the external calls at last + +## Tool used + +Manual Review \ No newline at end of file diff --git a/002/107.md b/002/107.md new file mode 100644 index 0000000..dd2cc8d --- /dev/null +++ b/002/107.md @@ -0,0 +1,98 @@ +Thankful Alabaster Porcupine + +High + +# Re-Entrancy Risks in deposit Function + +## Summary + +same issue found in https://consensys.io/diligence/audits/2023/08/lybra-finance/#re-entrancy-risks-associated-with-external-calls-with-other-liquid-staking-systems + +## Vulnerability Detail + +The `deposit` function in the LidoVault.sol file makes external calls to the Lido contract, which introduces potential reentrancy risks. Specifically, the function calls lido.submit{value: amount}(address(0)) to stake ETH on Lido. This external call can be exploited by a reentrancy attack, allowing an attacker to re-enter the function and manipulate the contract's state in an unintended manner. + +```solidity +/// @notice Deposit ETH into the vault +/// @param side ID of side to deposit into +function deposit(uint256 side) external payable { + // fixedSideCapacity will not be zero of initialized + require(fixedSideCapacity != 0, "NI"); + require(!isStarted(), "DAS"); + require(side == FIXED || side == VARIABLE, "IS"); + require(msg.value >= minimumDepositAmount, "MDA"); + + uint256 amount = msg.value; + if (side == FIXED) { + // Fixed side deposits + + uint256 minimumFixedDeposit = fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000); + require(amount >= minimumFixedDeposit, "MFD"); + + // no refunds allowed + require(amount <= fixedSideCapacity - fixedETHDepositTokenTotalSupply, "OED"); + // do not allow remaining capacity to be less than minimum fixed deposit bps + uint256 remainingCapacity = fixedSideCapacity - fixedETHDepositTokenTotalSupply - amount; + require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); + + // Stake on Lido + /// returns stETH, and returns amount of Lido shares issued for the staked ETH + uint256 stETHBalanceBefore = stakingBalance(); + uint256 shares = lido.submit{value: amount}(address(0)); // _referral address argument is optional use zero address + require(shares > 0, "ISS"); + // stETH transfered from Lido != ETH deposited to Lido - some rounding error + uint256 stETHReceived = (stakingBalance() - stETHBalanceBefore); + require((stETHReceived >= amount) || (amount - stETHReceived <= LIDO_ERROR_TOLERANCE_ETH), "ULD"); + emit FundsStaked(amount, shares, msg.sender); + + // Mint claim tokens + fixedClaimToken[msg.sender] += shares; + fixedClaimTokenTotalSupply += shares; + fixedETHDepositToken[msg.sender] += amount; + fixedETHDepositTokenTotalSupply += amount; + + emit FixedFundsDeposited(amount, shares, msg.sender); + } else { + // Variable side deposits + + // no refunds allowed + require(amount <= variableSideCapacity - variableBearerTokenTotalSupply, "OED"); + uint256 remainingCapacity = variableSideCapacity - variableBearerTokenTotalSupply - amount; + require(remainingCapacity == 0 || remainingCapacity >= minimumDepositAmount, "RC"); + + // Mint bearer tokens + variableBearerToken[msg.sender] += amount; + variableBearerTokenTotalSupply += amount; + + emit VariableFundsDeposited(amount, msg.sender); + } + + // Start the vault if we're at capacity + if ( + fixedETHDepositTokenTotalSupply == fixedSideCapacity && + variableBearerTokenTotalSupply == variableSideCapacity + ) { + startTime = block.timestamp; + endTime = block.timestamp + duration; + fixedSidestETHOnStartCapacity = stakingBalance(); + fixedIntialTokenTotalSupply = fixedClaimTokenTotalSupply; + emit VaultStarted(block.timestamp, msg.sender); + } +} +``` + +## Impact + +an attacker could repeatedly call the deposit function before the initial execution completes, potentially draining the contract's funds or manipulating its state in unintended ways + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L328C2-L391C4 + +## Tool used + +Manual Review + +## Recommendation + +To mitigate the reentrancy risk, it is recommended to use a reentrancy guard. \ No newline at end of file diff --git a/002/110.md b/002/110.md new file mode 100644 index 0000000..7e2ed78 --- /dev/null +++ b/002/110.md @@ -0,0 +1,45 @@ +Amusing Carrot Octopus + +Medium + +# Disorderly verification vulnerability in minimum deposits. + +## Summary + +## Vulnerability Detail +There are two different requirements for the deposit amount: +An absolute minimum (minimumDepositAmount): 0.01 ETH +A relative minimum (minimumFixedDeposit): 5% of fixedSideCapacity + +If the amount (which is msg.value) meets the first requirement (msg.value >= minimumDepositAmount), but does not meet the second (>= minimumFixedDeposit), the transaction will be reversed later. + +## Impact +A user knowing that the minimum for a deposit is equal to `minimumDepositAmount = 0.01 ether` for both FIXED and VARIABLE deposits, may want to deposit this minimum with the FIXED side, but depending on the fixedSideCapacity, it will fail if the msg.value is not 5% of fixedSideCapacity. + +```solidity +function deposit(uint256 side) external payable { + // fixedSideCapacity will not be zero of initialized + require(fixedSideCapacity != 0, "NI"); + require(!isStarted(), "DAS"); + require(side == FIXED || side == VARIABLE, "IS"); +@> require(msg.value >= minimumDepositAmount, "MDA"); + + uint256 amount = msg.value; // 0.2 + if (side == FIXED) { + // Fixed side deposits + uint256 minimumFixedDeposit = fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000); +@> require(amount >= minimumFixedDeposit, "MFD"); + } + } + ---------- REST OF CODE ------------ +``` + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L328-L340 + +## Tool used + +Manual Review + +## Recommendation +Ensure that all input checks are aligned and not contradictory. \ No newline at end of file diff --git a/002/117.md b/002/117.md new file mode 100644 index 0000000..3391ca9 --- /dev/null +++ b/002/117.md @@ -0,0 +1,40 @@ +Calm Bamboo Parrot + +Medium + +# Fixed-Side Capacity Calculation Error Leading to Unnecessary Deposit Rejections + +## Summary +A bug in the `remainingCapacity` calculation for the `fixedside` of the vault in `deposit` function leads to deposits being rejected unnecessarily when the remaining capacity is smaller than the `minimumFixedDeposit`. This could block further deposits and prevent the vault from reaching full capacity, thereby delaying or stopping operations. + +## Vulnerability Detail +The vulnerability occurs when the remaining capacity on the fixed side of the vault is less than the `minimumFixedDeposit`, but greater than zero. According to the current logic, deposits are allowed only if the remaining capacity is either zero or larger than the `minimumFixedDeposit`. This means that if a small deposit would fill the vault to its capacity, but the remaining capacity is below the `minimumFixedDeposit`, the deposit is rejected, which blocks further deposits. + +Example: +- Vault capacity: 10 ETH +- Current deposits: 9.01 ETH +- Remaining capacity: 0.99 ETH +- `minimumFixedDeposit`: 0.5 ETH (5% of 10 ETH) + +Since the remaining capacity (0.99 ETH) is greater than zero but less than the `minimumFixedDeposit` (0.5 ETH), the logic rejects any deposit. This results in the vault not reaching its full capacity. + +## Impact +The immediate impact is the inability of the vault to fill to capacity due to small remaining capacity being rejected. This issue can prevent the vault from starting, as both the fixed and variable sides need to be fully funded before operations can commence. As a result: +1. Fixed side participants may not be able to complete their deposit. +2. The vault will remain inactive, delaying or entirely blocking yield generation for both fixed and variable participants. + + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L328C1-L393C1 +## Tool used + +Manual Review + +## Recommendation +To resolve this issue, allow deposits that fully fill the remaining vault capacity without requiring the `minimumFixedDeposit` constraint. Adjust the logic so that if the remaining capacity is less than the `minimumFixedDeposit`, the final deposit can still be processed as long as it fills the vault. This will ensure that both fixed and variable sides can be fully funded, allowing the vault to begin operations. + +```solidity +if (remainingCapacity > 0 && remainingCapacity < minimumFixedDeposit && remainingCapacity != exact final deposit) { + revert("Deposit size too small."); +} +``` \ No newline at end of file diff --git a/002/119.md b/002/119.md new file mode 100644 index 0000000..eac7f75 --- /dev/null +++ b/002/119.md @@ -0,0 +1,103 @@ +Thankful Alabaster Porcupine + +High + +# Logic Bug in deposit Function Prevents Vault from Reaching Full Capacity + +## Summary + +## Vulnerability Detail + +The `deposit` function in the LidoVault contract contains a logic bug that can prevent the vault from reaching full capacity under certain conditions. Specifically, the issue arises from the checks on the remaining capacity for both fixed and variable side deposits. These checks ensure that the remaining capacity is either zero or at least the minimum deposit amount. However, there is a scenario where the remaining capacity could be less than the minimum deposit amount but greater than zero, which would prevent further deposits and leave the vault in a state where it cannot reach full capacity. + +```solidity +function deposit(uint256 side) external payable { + // fixedSideCapacity will not be zero of initialized + require(fixedSideCapacity != 0, "NI"); + require(!isStarted(), "DAS"); + require(side == FIXED || side == VARIABLE, "IS"); + require(msg.value >= minimumDepositAmount, "MDA"); + + uint256 amount = msg.value; + if (side == FIXED) { + // Fixed side deposits + + uint256 minimumFixedDeposit = fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000); + require(amount >= minimumFixedDeposit, "MFD"); + + // no refunds allowed + require(amount <= fixedSideCapacity - fixedETHDepositTokenTotalSupply, "OED"); + // do not allow remaining capacity to be less than minimum fixed deposit bps + uint256 remainingCapacity = fixedSideCapacity - fixedETHDepositTokenTotalSupply - amount; + require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); + + // Stake on Lido + /// returns stETH, and returns amount of Lido shares issued for the staked ETH + uint256 stETHBalanceBefore = stakingBalance(); + uint256 shares = lido.submit{value: amount}(address(0)); // _referral address argument is optional use zero address + require(shares > 0, "ISS"); + // stETH transfered from Lido != ETH deposited to Lido - some rounding error + uint256 stETHReceived = (stakingBalance() - stETHBalanceBefore); + require((stETHReceived >= amount) || (amount - stETHReceived <= LIDO_ERROR_TOLERANCE_ETH), "ULD"); + emit FundsStaked(amount, shares, msg.sender); + + // Mint claim tokens + fixedClaimToken[msg.sender] += shares; + fixedClaimTokenTotalSupply += shares; + fixedETHDepositToken[msg.sender] += amount; + fixedETHDepositTokenTotalSupply += amount; + + emit FixedFundsDeposited(amount, shares, msg.sender); + } else { + // Variable side deposits + + // no refunds allowed + require(amount <= variableSideCapacity - variableBearerTokenTotalSupply, "OED"); + uint256 remainingCapacity = variableSideCapacity - variableBearerTokenTotalSupply - amount; + require(remainingCapacity == 0 || remainingCapacity >= minimumDepositAmount, "RC"); + + // Mint bearer tokens + variableBearerToken[msg.sender] += amount; + variableBearerTokenTotalSupply += amount; + + emit VariableFundsDeposited(amount, msg.sender); + } + + // Start the vault if we're at capacity + if ( + fixedETHDepositTokenTotalSupply == fixedSideCapacity && + variableBearerTokenTotalSupply == variableSideCapacity + ) { + startTime = block.timestamp; + endTime = block.timestamp + duration; + fixedSidestETHOnStartCapacity = stakingBalance(); + fixedIntialTokenTotalSupply = fixedClaimTokenTotalSupply; + emit VaultStarted(block.timestamp, msg.sender); + } +} + +``` + +**Example Scenario:** +Consider the following scenario to illustrate the issue: + +* Suppose fixedSideCapacity is 1000 ETH, minimumFixedDepositBps is 500 (5%), and minimumDepositAmount is 0.01 ETH. +* If the current fixedETHDepositTokenTotalSupply is 995 ETH, the remaining capacity is 5 ETH. +* The minimum fixed deposit is 50 ETH (5% of 1000 ETH). +* The condition require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); would fail because 5 * * ETH is less than 50 ETH, preventing any further deposits. +This leaves the vault in a state where it cannot reach full capacity, and the vault will never start. + +## Impact + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L346 + +## Tool used + +The vault will never reach full capacity, preventing it from transitioning to the started phase. This means that users who have already deposited funds will not be able to benefit from the vault's intended functionality + +Manual Review + +## Recommendation + diff --git a/002/132.md b/002/132.md new file mode 100644 index 0000000..aa06f2e --- /dev/null +++ b/002/132.md @@ -0,0 +1,46 @@ +Keen Lead Squid + +High + +# FadoBagi - Incorrect Implementation of the Vault Lifecycle Allows for Vault Never Starting + +FadoBagi + +High + +# Incorrect Implementation of the Vault Lifecycle Allows for Vault Never Starting + +## Summary +The `LidoVault` contract requires both `fixedSideCapacity` and `variableSideCapacity` to be fully met before initiating the vault by setting the `startTime` within the `deposit` function. However, there is no mechanism to start the vault if these capacities are not reached, nor is there a way for users to withdraw their funds if the vault remains unstarted. This can result in funds being locked indefinitely. + +## Vulnerability Detail +In the `deposit` function, the contract checks whether both `fixedETHDepositTokenTotalSupply` equals `fixedSideCapacity` and `variableBearerTokenTotalSupply` equals `variableSideCapacity` to start the vault: + + // Start the vault if we're at capacity + if ( + fixedETHDepositTokenTotalSupply == fixedSideCapacity && + variableBearerTokenTotalSupply == variableSideCapacity + ) { + startTime = block.timestamp; + endTime = block.timestamp + duration; + fixedSidestETHOnStartCapacity = stakingBalance(); + fixedIntialTokenTotalSupply = fixedClaimTokenTotalSupply; + emit VaultStarted(block.timestamp, msg.sender); + } + +The vault only starts when both `fixedSideCapacity` and `variableSideCapacity` are fully met. If either capacity is not reached, the vault remains unstarted indefinitely. + +There is no timeout after which users can withdraw their funds if the vault does not start. Users are unable to retrieve their deposits if the vault conditions are never satisfied, leading to locked funds. + +## Impact +User's funds can remain locked indefinitely if the vault does not start due to unmet fixed or variable capacities. + +## Code Snippet +- **Condition Check Within `deposit` function:** +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L380-L391 + +## Tool used +Manual Review + +## Recommendation +Implement a fallback mechanism that allows users to withdraw their deposits if the vault does not start within a specified timeframe. Additionally, consider adding a function to start the vault manually or automatically after a certain period, even if full capacities are not met, but certain criteria are, ensuring that users retain access to their funds. \ No newline at end of file diff --git a/002/139.md b/002/139.md new file mode 100644 index 0000000..4b4770c --- /dev/null +++ b/002/139.md @@ -0,0 +1,39 @@ +Thankful Alabaster Porcupine + +Medium + +# Missing Check for variableSideCapacity != 0 in deposit Function + +## Summary + +## Vulnerability Detail + +In the `LidoVault.sol` contract, the `deposit` function currently checks if `fixedSideCapacity` is not zero to ensure that the vault has been `initialized` properly. However, there is no corresponding check for `variableSideCapacity` to ensure it is also not zero. This could potentially lead to issues if `variableSideCapacity` is not set correctly during initialization. + +```solidity + function deposit(uint256 side) external payable { + // fixedSideCapacity will not be zero of initialized + require(fixedSideCapacity != 0, "NI");//-cheks fixedsideCapacity is not zero + require(!isStarted(), "DAS");//-avoids deposits after start + require(side == FIXED || side == VARIABLE, "IS");//-can be anyone,fixed or variable + require(msg.value >= minimumDepositAmount, "MDA");//-amount cannot be less than minimumdepositAmount + + uint256 amount = msg.value;//-assining msg.value to amount + if (side == FIXED) {//-logic for side = fixed + // Fixed side deposits +``` + + +## Impact +if `varibleSideCapacity` is `0` then users cannot deposit eth + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L328C2-L333C55 + +## Tool used + +Manual Review + +## Recommendation +Add an explicit check for `variableSideCapacity != 0` in the deposit function to ensure that both capacities are validated before proceeding with the deposit logic. \ No newline at end of file diff --git a/002/140.md b/002/140.md new file mode 100644 index 0000000..16b1d8e --- /dev/null +++ b/002/140.md @@ -0,0 +1,40 @@ +Keen Lead Squid + +High + +# FadoBagi - Lack of Input Validation on Deposited Amounts + +FadoBagi + +High + +# Lack of Input Validation on Deposited Amounts + +## Summary +The `LidoVault` contract does not impose an upper limit on the amount a single user can deposit into either the fixed or variable sides. While it ensures that deposits do not exceed the remaining capacity, the absence of a per-user deposit cap allows a single user to dominate a side, restricting participation from others and centralizing risk and rewards. + +## Vulnerability Detail +In the `deposit` function, the contract verifies that the deposited amount does not exceed the remaining capacity but does not enforce a maximum deposit per user: + + function deposit(uint256 side) external payable { + // ... + require(msg.value >= minimumDepositAmount, "MDA"); + // ... + } + +The contract only checks that the deposit amount does not surpass the remaining capacity of the fixed or variable side. But a single user can deposit a disproportionately large amount, potentially filling the entire capacity of one side. + +Without a per-user deposit cap, a user can contribute significantly more than others. This limits participation from other users, centralizes risk and rewards. + +## Impact +A single user dominating a deposit side can lead to centralized risk and restricts other users from participating. + +## Code Snippet +**Deposit Capacity Checks:** +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L328-L333 + +## Tool used +Manual Review + +## Recommendation +Implement a maximum deposit limit per user to prevent any single address from dominating the fixed or variable sides. A `maxDepositPerUser` variable could be used and enforced within the `deposit` function. \ No newline at end of file diff --git a/002/143.md b/002/143.md new file mode 100644 index 0000000..8bd75cc --- /dev/null +++ b/002/143.md @@ -0,0 +1,73 @@ +Tart Purple Spider + +Medium + +# Insufficient Fixed-Side Capacity in Vault Creation Can Lead to Unusable Vaults + +## Details + +The VaultFactory contract's createVault function deploys a new LidoVault contract and initializes it with parameters like _fixedSideCapacity, _duration, and _variableSideCapacity. + +Currently, the createVault function lacks a check to ensure that the _fixedSideCapacity parameter is sufficiently large to accommodate the minimum fixed deposit requirement defined by minimumFixedDepositBps in the LidoVault contract. + +## Code Snippets + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L107 + +```solidity +function createVault( +    uint256 _fixedSideCapacity, +    uint256 _duration, +    uint256 _variableSideCapacity +) public virtual { +    // ... Existing code ... + +    // Initialize vault +    ILidoVault(vaultAddress).initialize(params); + +    // ... Existing code ... +} +``` + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L65 + +```solidity +uint256 public immutable minimumFixedDepositBps = 500; // default 5% +// ... other code ... +require(params.fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000) >= minimumDepositAmount, "IFC"); +``` + +## Impact + +This omission can lead to the deployment of vaults where it's practically impossible for fixed-side depositors to participate due to an excessively high minimum deposit requirement. Specifically, if the _fixedSideCapacity is set too low, the calculated minimumFixedDeposit might exceed the _fixedSideCapacity, making it impossible to fulfill. + +## Scenario + +- The minimumDepositAmount in LidoVault is set to 0.01 ETH. +- A user calls createVault() with _fixedSideCapacity set to 0.1 ETH (which is lower than minimumFixedDepositBps (5%) of _fixedSideCapacity) +- The vault is deployed, but fixed-side deposits are impossible because the minimum required deposit (5% of 0.1 ETH = 0.005 ETH) is less than the global minimum deposit amount (0.01 ETH). + +## Fix + +Introduce a check in the createVault function within VaultFactory.sol to enforce that the _fixedSideCapacity is greater than or equal to the calculated minimum fixed deposit amount. + +```solidity +function createVault( +    uint256 _fixedSideCapacity, +    uint256 _duration, +    uint256 _variableSideCapacity +) public virtual { +    // ... Existing code ... + +    // Calculate minimumFixedDeposit +    uint256 minimumFixedDeposit = _fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000); + +    // Ensure _fixedSideCapacity is sufficient +    require(_fixedSideCapacity >= minimumFixedDeposit, "FSC"); + +    // Initialize vault +    ILidoVault(vaultAddress).initialize(params); + +    // ... Existing code ... +} +``` \ No newline at end of file diff --git a/002/152.md b/002/152.md new file mode 100644 index 0000000..249ac01 --- /dev/null +++ b/002/152.md @@ -0,0 +1,31 @@ +Noisy Eggshell Peacock + +Medium + +# Variable users can deposit amount less than `minimumDepositAmount` deviating from the protocols design. + +## Summary +Protocol doesnt check whether the amount deposited by Variable users is >= `minimumDepositAmount` but at the same time it checks the whether the `remainingCapacity` is greater than or equal to `minimumDepositAmount`. +## Vulnerability Detail + +Variable users and fixed users are only allowed to deposit amount >= `minimumDepositAmount` .Check is implemented for fixed Users but not for variable. + +```solidity + /// @notice Minimum amount of ETH that can be deposited for variable or fixed side users + uint256 public immutable minimumDepositAmount = 0.01 ether; + ``` + +## Impact +Variable users can deposit amount less than `minimumDepositAmount` deviating from the protocols design. +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L61-L62 + + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L366-L377 +## Tool used + +Manual Review + +## Recommendation + +Check for `minimumDepositAmount` condition. \ No newline at end of file diff --git a/002/153.md b/002/153.md new file mode 100644 index 0000000..d0c849c --- /dev/null +++ b/002/153.md @@ -0,0 +1,80 @@ +Innocent Blonde Finch + +Medium + +# Potencial Denial of service in LidoVault::deposit + +## Summary + +The `deposit` function in the LidoVault contract exhibits a Denial of Service (DOS) vulnerability due to its implementation of minimum deposit requirements. This vulnerability manifests in two critical ways: firstly, it potentially excludes smaller depositors from participating in the vault, particularly on the fixed side where the minimum deposit is calculated as a percentage of the total capacity. Secondly, it can prevent the vault from reaching full capacity by disallowing deposits when the remaining capacity falls below the minimum fixed deposit amount but is greater than zero. These issues stem from well-intentioned checks designed to ensure meaningful participation, but their current implementation could inadvertently limit the vault's accessibility and efficiency. This vulnerability could significantly impact the vault's liquidity and overall performance to a diverse user base, potentially undermining the contract's core functionality. + +## Vulnerability Detail + +The `deposit` function implements two levels of minimum deposit checks: + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L333 + +1. A global minimum deposit amount for all deposits: + ```solidity + require(msg.value >= minimumDepositAmount, "MDA"); + ``` + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L339 + +2. A specific minimum for fixed-side deposits, calculated as a percentage of the total fixed side capacity: + ```solidity + uint256 minimumFixedDeposit = fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000); + require(amount >= minimumFixedDeposit, "MFD"); + ``` + +Additionally, there's a check to ensure the remaining capacity after a deposit is either zero or above the minimum fixed deposit: + + +These checks, while intended to ensure meaningful participation, can lead to exclusion of smaller depositors and prevent the vault from reaching full capacity. + +## Impact + +The impact of this vulnerability is twofold: + +1. Smaller depositors may be unable to participate in the vault, particularly on the fixed side where the minimum deposit is a percentage of the total capacity. This could significantly limit the accessibility of the vault to a broader user base. + +2. The vault may be unable to reach full capacity. If the remaining capacity falls below the minimum fixed deposit amount but is greater than zero, no further deposits can be made, leaving the vault partially unfilled. + +These issues could lead to reduced liquidity in the vault, potentially affecting its overall performance and attractiveness to users. + +## Code Snippet + +```javascript + +function deposit(uint256 side) external payable { +require(msg.value >= minimumDepositAmount, "MDA"); +uint256 amount = msg.value; +if (side == FIXED) { +uint256 minimumFixedDeposit = fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000); +require(amount >= minimumFixedDeposit, "MFD"); +uint256 remainingCapacity = fixedSideCapacity - fixedETHDepositTokenTotalSupply - amount; +require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); +// ... rest of the function +} +// ... rest of the function +} +``` + +## Tool used + +Manual Review + +## Recommendation + +To mitigate this vulnerability, consider implementing the following changes: + +1. Carefully review and set the `minimumDepositAmount` and `minimumFixedDepositBps` values to ensure they align with the target user base and don't unnecessarily exclude potential participants. + +2. Consider removing or adjusting the final capacity check to allow for full capacity to be reached: + +```diff + // Remove this check or adjust it to allow smaller final deposits +- require(remainingCapacity == 0 || remainingCapacity >= minimumFixedDeposit, "RC"); +``` + +By implementing these recommendations, the contract can maintain meaningful participation while increasing accessibility and ensuring the vault can reach full capacity. \ No newline at end of file diff --git a/002/161.md b/002/161.md new file mode 100644 index 0000000..12a3e6d --- /dev/null +++ b/002/161.md @@ -0,0 +1,128 @@ +Amusing Flaxen Bull + +Medium + +# Invalid `variableSideCapacity` will prevent the vault from starting + +### Summary + +A missing check in the [initialize](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L276) function will permit the `variableSideCapacity` to be less than the `minimumDepositAmount`, which will prevent the vault from starting. + +### Root Cause + +In [initialize](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L276) there is a missing check if the `variableSideCapacity` is less than the `minimumDepositAmount`. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Create a `LidoVault` using the [VaultFactory::createVault()]() and pass as argument for `variableSideCapacity` value that is less than `0.01`. +2. Fixed side users will be able to deposit and withdraw. +3. Variable side users will not be able to deposit => the vault will not start. + +### Impact + +Core functionality of the protocol is broken. + +### PoC + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; + +import {VaultFactory} from "src/VaultFactory.sol"; +import {LidoVault} from "src/LidoVault.sol"; +import {ILido} from "src/interfaces/ILido.sol"; +import {MockLido} from "src/mocks/MockLido.sol"; +import {MockLidoWithdrawalQueue} from "src/mocks/MockLidoWithdrawalQueue.sol"; + +contract PoC is Test { + address payable constant LIDO = payable(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84); + address payable constant LIDO_WITHDRAWAL_QUEUE = payable(0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1); + + VaultFactory vf; + + address user = makeAddr("user"); + address protocolFeeReceiver = makeAddr("protocolFeeReceiver"); + + function setUp() public { + deal(user, 10 ether); + + // Deploy the Lido mocks on the respective addresses + deployCodeTo("MockLido", LIDO); + + // Deploy the vault factory + vm.prank(protocolFeeReceiver); + vf = new VaultFactory(100, 100); + } + + function test() public { + uint256 fixedSideCapacity = 1 ether; + uint256 variableSideCapacity = 0.005 ether; + uint256 duration = 7 days; + uint256 vaultId = vf.nextVaultId(); + + vf.createVault(fixedSideCapacity, duration, variableSideCapacity); + (, address addr) = vf.vaultInfo(vaultId); + LidoVault lv = LidoVault(payable(addr)); + uint256 minDepositAmount = lv.minimumDepositAmount(); + + vm.prank(user); + lv.deposit{value: 1 ether}(0); // Deposit as fixed side + + assert(ILido(LIDO).balanceOf(address(lv)) == 1 ether); // Successfully executed fixed deposit + + vm.prank(user); + vm.expectRevert(bytes("MDA")); + lv.deposit{value: variableSideCapacity}(1); // Deposit as variable side the set capacity + + vm.prank(user); + vm.expectRevert(bytes("OED")); + lv.deposit{value: minDepositAmount}(1); + + assert(lv.isStarted() == false); // The vault will never start + } +} +``` + +### Mitigation + +```diff + function initialize(InitializationParams memory params) external { + require(isFactoryCreated != true, "MBF"); + // Only run once by vault factory in atomic operation right after cloning, then fixedSideCapacity is set + require(fixedSideCapacity == 0, "ORO"); + + // Validate args + require(params.vaultId != 0, "NEI"); + require(params.duration != 0, "NEI"); + require(params.fixedSideCapacity != 0, "NEI"); + require(params.variableSideCapacity != 0, "NEI"); ++ require(params.variableSideCapacity >= minimumDepositAmount, "NEI"); + require(params.earlyExitFeeBps != 0, "NEI"); + require(params.protocolFeeReceiver != address(0), "NEI"); + + require(params.fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000) >= minimumDepositAmount, "IFC"); + + // Initialize contract state variables + id = params.vaultId; + duration = params.duration; + fixedSideCapacity = params.fixedSideCapacity; + variableSideCapacity = params.variableSideCapacity; + earlyExitFeeBps = params.earlyExitFeeBps; + protocolFeeBps = params.protocolFeeBps; + protocolFeeReceiver = params.protocolFeeReceiver; + + emit VaultInitialized( + id, duration, variableSideCapacity, fixedSideCapacity, earlyExitFeeBps, protocolFeeBps, protocolFeeReceiver + ); + } +``` \ No newline at end of file diff --git a/003/018.md b/003/018.md new file mode 100644 index 0000000..06647f7 --- /dev/null +++ b/003/018.md @@ -0,0 +1,67 @@ +Scrawny Coal Snail + +High + +# Improper Transfer Check Leading to Potential Loss of Funds in the `withdrawAmountVariablePending()` function + +### Summary + +Setting the pending withdrawal amount to zero in `withdrawAmountVariablePending()` before executing the transfer can cause a permanent loss of funds for users, as any interruption in the transaction (rejection during signing, gas issues) will clear the user’s balance without transferring the funds. + + +### Root Cause + +In the `withdrawAmountVariablePending()` function, the choice to set the user's pending withdrawal amount to zero before executing the transfer is a mistake, as it results in the loss of funds if the transfer fails. This design flaw causes users’ balances to be reset even if the funds are not successfully transferred, leading to irreversible financial loss. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L652C1-L657C4 + +### Internal pre-conditions + +1. A user needs to have a non-zero balance in `variableToPendingWithdrawalAmount[msg.sender]`. +2. The contract must have enough ETH to cover the pending withdrawal amount for the user. +3. The user must call the `withdrawAmountVariablePending()` function to initiate the withdrawal process. + +### External pre-conditions + +1. The user interrupts the transaction, such as rejecting the signing request in their wallet. +2. The transaction runs out of gas during execution, causing it to fail. +3. Network issues or other unforeseen errors cause the transfer operation to fail, leading to incomplete execution of the transaction. + +### Attack Path + +1. The user calls the `withdrawAmountVariablePending()` function to withdraw their pending balance. +2. The function retrieves the pending amount and sets the user's balance to zero in the contract. +3. An external disruption occurs (e.g., the user rejects the signing request, runs out of gas, or a network error happens). +4. The transfer fails, but the pending amount remains zero, resulting in a permanent loss of the user’s funds without transferring them. + +### Impact + +1.**Loss of Funds**: The user can lose all their balance if the transfer fails. This is particularly critical in environments where contracts may fail unexpectedly due to gas issues or malicious actors. +2.**Denial of Service (DoS)**: A malicious actor could craft a contract to exploit this issue, causing repeated transfer failures, effectively locking user funds permanently. + +### PoC + +_No response_ + +### Mitigation + +The function should first set the user’s pending balance to zero, then attempt the transfer using call. If the transfer fails, the pending balance should be restored, and the transaction should revert. This approach ensures that user funds are protected from permanent loss due to transfer failures while maintaining security against reentrancy attacks, making the function more robust and reliable in handling external disruptions. + +```solidity +function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + require(amount > 0, "No amount to withdraw"); + + // Set balance to zero first + variableToPendingWithdrawalAmount[msg.sender] = 0; + + // Attempt the transfer + (bool success, ) = payable(msg.sender).call{value: amount}(""); + + // If transfer fails, revert the balance + if (!success) { + variableToPendingWithdrawalAmount[msg.sender] = amount; + revert("Transfer failed"); + } +} +``` \ No newline at end of file diff --git a/003/030.md b/003/030.md new file mode 100644 index 0000000..7cbff8d --- /dev/null +++ b/003/030.md @@ -0,0 +1,107 @@ +Passive Denim Yeti + +High + +# Any multisig end user (or a depositor who is a contract) who deposits on the VARIABLE side will have no ability to withdraw his pending amount and will never be able to recover his funds back + +## Summary +The problem stems from using `payable(msg.sender).transfer(amount)` approach in the `withdrawAmountVariablePending` function. That is because `transfer` will always revert for most of the Mutlisig-wallet-type depositors, because the gas it propagates will not be enough for them to perform appropriate logic on their behalf. + +**This leads to a permanent blocking of variable user's requested withdrawal funds, because each time `.transfer` will revert. Please note that there's no external trusted entity in the `LidoVault` contract who can help to recover the depositor's funds back in any way.** + +In summary, a `mgs.sender` who acts as a VARIABLE-side depositor who requires to process some logic and execute any logical functions in his `fallback` or `receive` Ethereum account hook will never be able to neither cancel an ongoing withdrawal that he has already requested (**thus blocking the whole withdrawal flow forever**) nor withdraw his pending variable funds and complete the ongoing variable withdrawal request of his own. + +#### Please consider the private and official discussion with the sponsors that confirms that neither multisig nor contract wallets SHOULD NOT be blocked from acting as any other normal EOA user on the protocol-level *(which confirms the validity of the issue additionally to the other explanations outlined below)*: +> **Me:** Could you please clarify: is using payable(msg.sender).transfer(amount); an intended design decicion to block most of the contract depositors, so that they're unable to withdraw? Are users supposed to be notified about that beforehand, namely that implementing any receive() logic is prohibited for the users on their behalf? *Or is it a potential problem to report?* + +--- +> **Me:** I apologize for being ambiguous about this one: what I rather meaned to ask is --- why transfer and not call? transfer propagates too little gas for msg.sender contract receivers to be able to handle custom receive ETH logic accordingly. + +### For context: + +> `transfer()` +> +> The syntax of the transfer function looks like that: +> +> `receivingAddress.transfer(amount);` +> The transfer function fails under two conditions: +> +> The balance of the sending smart contract is not large enough, +> The receiving contract rejects the payment. +> In a failure case, the transfer function reverts. +> +> If a payment is made, either the `fallback()` or `receive()` function in the receiving contract is triggered. This provides the opportunity for the receiving contract to react upon a payment. +> +> Transfer forwards 2300 gas to the receiving contract. This is only a very small amount that is sufficient to trigger an event. It is definitely not enough to execute complex code. + +--- +> **Sponsor replies:** To be honest... yeah, this does seem like a mistake on our part. I'll check with the team to verify; maybe I've forgotten why we did it this way. However, I'm a bit skeptical that this is not mistake. + +--- +> **Sponsor replies with a confirmation:** We have confirmed that this is indeed a valid issue. I'm new to the Watson platform, so I would appreciate it if you could guide me on any additional steps I need to take in light of this acknowledged error, if necessary. I will respond to the remaining questions tomorrow. + +--- +**Me:** ... I believe there're no more required actions on your behalf in that sense, I'll just report it on my side as an issue, and it will automatically be submitted when the contest ends. + +--- +**Sponsor confirms furthermore:** That was not intended, indeed. Thanks for the clarification about multisig wallets! + +--- + +Although other functions in the `LidoVault` contract use the proper `send` reserved function, `withdrawAmountVariablePending()` (and thus `finalizeVaultOngoingVariableWithdrawals`) will always fail to withdraw ETH for contract-type depositors. + +Although in some case it would not be considered critical, in this particular situation it is pivotal to support withdrawals for any depositor, because there's no way to change the recipient of the withdrawal available for the depositors. + +There's a guarantee for the users to be able to withdraw at any time. However, contract `msg.sender`s and multisig `msg.sender`s withdrawers on the Variable side will lose access to their funds **forever**, because they won't be able to withdraw early nor cancel the early withdraw request either. + +## Vulnerability Detail +```solidity + /// @notice withdrawal of funds for Variable side + function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; + payable(msg.sender).transfer(amount); + } +``` + +The variable side multisig-wallet-type depositors will never be able to receive their pending variable premium ETH amount nor to cancel the in-progress withdrawal request of theirs because `.transfer` will keep failing. + +## Impact +1. Generally multisig wallets are not upgradeable per se, so depositors who require a higher gas limit for their `receive` or `fallback` functions will never be able to recover their ETH back neither during the active nor during the end phases of the `LidoVault`'s lifecycle, if they have ever once requested a variable premature withdrawal of their deposit during the `LidoVault`'s active stage. +2. This will block the protocol from supporting the integratees in the future too!! Preventing any further integrations with the protocol. But this is a sub-issue. + +The main concern is that variable side depositors who are multisig wallet-type actors will lose access to their funds permanently, because they'll never be able to withdraw ETH neither through `withdrawAmountVariablePending` nor through `finalizeVaultOngoingVariableWithdrawals`. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L656 + +## Tool used +Manual review and clarification and confirmation with the sponsors. Thank you guys for the great feedback and help during the contest! + +## Recommendation +Use `call` instead of `transfer` to unblock contract depositors from receiving their ETH back. + +For instance, modify the `withdrawAmountVariablePending` function as follows: +```diff + /// @notice withdrawal of funds for Variable side + function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; +- payable(msg.sender).transfer(amount); ++ (bool success, ) = payable(msg.sender).call{value: amount}(""); ++ require(success, "receival of ETH failed on the receiver's behalf"); + } +``` + +--- + +P.S. One more clarification with Wang: + +> firstly, make sure the sponsor understood your question clearly and you understood their answer, because there was a situation recently where one Watson didn't understand the intended design, submitted an issue and it ended up that the sponsor's reply was imprecise. + +> +> secondly, make sure this broken intended design is sufficient for Medium severity. +> +> **then, in the report, you can mention that you confirmed with the sponsor what the intended design is, and that it's broken** +> +> during the judging contest, if this issue is controversial (lots of signals on different outcomes), we will be able to confirm this privately. Or you can share a screenshot of your discussion with the sponsor in the contest chat \ No newline at end of file diff --git a/003/033.md b/003/033.md new file mode 100644 index 0000000..fbe8663 --- /dev/null +++ b/003/033.md @@ -0,0 +1,36 @@ +Big Viridian Lynx + +High + +# Uses .transfer instead of .call to push ETH funds to users, permanently locking the rewards for some users + +## Summary +During a specific portion of `finalizeVaultOngoingVariableWithdrawals()` the function `withdrawAmountVariablePending()` is called which pushes ETH funds to users with `.transfer()` instead of `.call()`. + +The `.transfer()` gas stipend of 2300 is insufficient for multisig wallet users, or any user that is a smart contract with logic in their receive/fallback which exceeds 2300, like state updates. + +Users that are smart contracts or multi-sig wallets can successfully deposit and then request a withdrawal, but will not be able to withdraw any rewards. The system tracks the same user/msg.sender throughout the process, so their rewards are permanently lost. + +There are two ways to cash out rewards for vault ongoing variable users. + +1. User does it themselves via `finalizeVaultOngoingVariableWithdrawals()`, no issues here because it uses .call(). +2. Protocol fee receiver will first prepare the rewards on behalf of the user via `feeReceiverFinalizeVaultOngoingVariableWithdrawals()`, and then it's expected that the user will call `finalizeVaultOngoingVariableWithdrawals()` to grab the rewards. The issue is in this path because it uses .transfer(). + +The `protocolFeeReceiver` calls `feeReceiverFinalizeVaultOngoingVariableWithdrawals()` which prepares the users rewards to a non-zero amount in their `variableToPendingWithdrawalAmount` mapping. Then, the user tries to withdraw through `finalizeVaultOngoingVariableWithdrawals()`, which uses `.transfer()` because they have a non-zero prepared amount waiting for them. The transaction will revert when the user (multi-sig, smart contract) requires more than 2300 gas to finish executing their receive/fallback function. Funds are permanently locked with no other ways to withdraw. + +## Vulnerability Detail +See Summary. + +## Impact +Multisig and smart contract users that can successfully deposit will not be able to withdraw rewards, after requesting a withdraw, that have been prepared by the protocol fee receiver. + +## Code Snippet +* Function that uses .transfer(): https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L653-#L657 +* Function that prepares the users amount: https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L649 +* Withdraw function that reverts when rewards prepared by protocol fee receiver & user is multi-sig/smart contract: https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L610-#L617 + +## Tool used +Manual Review + +## Recommendation +Use `.call()` instead of `.transfer()` during `withdrawAmountVariablePending()`. \ No newline at end of file diff --git a/003/046.md b/003/046.md new file mode 100644 index 0000000..d0eb8d4 --- /dev/null +++ b/003/046.md @@ -0,0 +1,117 @@ +Swift Pine Shrimp + +High + +# LidoVault contract has Unauthorised Access to withdrawAmountVariablePending function Allows Malicious Withdrawals + +# Sherlock.xyz Security Audit Findings + +### Title: Unauthorized Access to `withdrawAmountVariablePending` Allows Malicious Withdrawals + +### Project: LidoVault Contract +### Date: September 2024 +### Audited by: fat32 + +### Location: +- [LidoVault.sol: L653-L657](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L653-L657) + +### Vulnerability Summary: + +The function `withdrawAmountVariablePending` in the `LidoVault` contract allows any caller to withdraw the pending variable amount for any user. This is because it does not enforce proper access control, leading to a potential loss of funds for users. The function should be restricted to allow only the specific user (or an authorized party) to withdraw their pending funds. + +### Impact: + +A malicious actor can exploit this vulnerability to withdraw the entire pending variable amount of any user, resulting in significant loss of user funds. The attacker does not need to hold any special privileges, making this vulnerability critical. + +### Severity: **High** + +- **Type**: Access Control +- **Impact**: Unauthorized fund withdrawal +- **Likelihood**: High (due to the ease of exploitation) + +### Vulnerable Code: +```solidity +function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; + payable(msg.sender).transfer(amount); +} +``` + +### Proof of Concept (POC): + +The following Foundry test demonstrates how an unauthorized user can exploit the vulnerability to withdraw funds: +```txt +lido-fiv/test/LidoVaultAccessControl.t.sol +``` +```solidity +// SPDX-License-Identifier: MIT +pragma solidity 0.8.18; + +import {Test, console} from "forge-std/Test.sol"; +import {LidoVault} from "../contracts/LidoVault.sol"; + +contract LidoVaultAccessControlTest is Test { + LidoVault public lidovault; + function setUp() public { + lidovault = new LidoVault(bool(true)); + } + + function testAccess0() external { + uint256 side = 1; + address user = address(0xbEEF); + address msgSender = address(0xbEEF); + vm.startPrank(msgSender); + lidovault.withdrawAmountVariablePending(); + vm.stopPrank(); + } + +} +``` + +## Log Results +```txt +lido-fiv % forge test -vvvv --match-contract LidoVaultAccessControlTest +[⠊] Compiling... +[⠒] Compiling 1 files with Solc 0.8.18 +[⠢] Solc 0.8.18 finished in 1.03s +Compiler run successful with warnings: +... +Ran 1 test for test/LidoVaultAccessControl.t.sol:LidoVaultAccessControlTest +[PASS] testAccess0() (gas: 13693) +Traces: + [13693] LidoVaultAccessControlTest::testAccess0() + ├─ [0] VM::startPrank(0x000000000000000000000000000000000000bEEF) + │ └─ ← [Return] + ├─ [5235] LidoVault::withdrawAmountVariablePending() + │ ├─ [0] 0x000000000000000000000000000000000000bEEF::fallback() + │ │ └─ ← [Stop] + │ └─ ← [Stop] + ├─ [0] VM::stopPrank() + │ └─ ← [Return] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.47ms (353.92µs CPU time) + +Ran 1 test suite in 129.06ms (1.47ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) +``` + +### Mitigation: + +To prevent unauthorized access, add proper access control to restrict withdrawals to only the user who owns the pending variable amount. This can be done by using `msg.sender` in conjunction with the withdrawal amount mapping. + +Here’s the recommended fix: + +#### Solidity Mitigation: +```solidity +function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + require(amount > 0, "No pending withdrawal amount"); + variableToPendingWithdrawalAmount[msg.sender] = 0; + payable(msg.sender).transfer(amount); +} +``` + +### Conclusion: + +This vulnerability is critical as it allows any user to withdraw another user's pending variable amount without restriction. Applying access control and ensuring that only the appropriate user can withdraw their funds mitigates this issue. \ No newline at end of file diff --git a/003/061.md b/003/061.md new file mode 100644 index 0000000..98cceee --- /dev/null +++ b/003/061.md @@ -0,0 +1,58 @@ +Real Tangelo Lion + +Medium + +# Lack of error handling within the `withdrawAmountVariablePending` function + +### Summary + +The `transfer` function invoked in the `withdrawAmountVariablePending` function does not have any error handling mechanisms which means we need to include that in our code. Failure to do so may cause unexpected function behaviour, more so when `withdrawAmountVariablePending` is invoked in other functions that may depend on it (such as the `finalizeVaultOngoingVariableWithdrawals function`) +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/interfaces/ILido.sol#L15 + +### Root Cause + +As seen in the `withdrawAmountVariablePending` function, https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L656 + +there's is no error handling taking place after transfering an amount to the receipient. + +### Attack Path + +Here's how the attack path would potentially look like: + +#### Identify Vulnerable Function: +The attacker identifies that the `withdrawAmountVariablePending` function lacks proper error handling. + +#### Deploy a Malicious Contract: +The attacker deploys a contract with a fallback function or a receive function designed to exploit the lack of error handling. This function could consume all gas or revert the transaction when receiving Ether. + +#### Trigger the Vulnerable Function: +The attacker calls the `withdrawAmountVariablePending` function from their malicious contract, attempting to withdraw pending Ether. +#### Exploit the Transfer: +When the contract attempts to transfer Ether to the attacker's contract, the fallback or receive function is triggered, causing the transfer to fail. +Without proper error handling, the function might incorrectly update the state, assuming the transfer was successful. + +### Impact + +Here's a couple of possible scenarios with regards to the impact of a lack of error handling mechanism: + +#### Failed Transfers: +* Issue: If the ETH transfer fails (e.g., due to insufficient gas, network issues, or an issue with the recipient address), the function does not handle this failure, potentially leaving the user without their funds. +* Impact: Users may not receive their pending withdrawals, leading to dissatisfaction and potential financial loss. + +#### State Inconsistency: + +* Issue: Without error handling, the function might not revert the state changes if the transfer fails, leading to inconsistencies. For example, the pending amount could be set to zero even though the transfer did not succeed. +* Impact: This could result in users being unable to access their funds, as the contract believes the transfer was successful. + +### Mitigation + +Make the following changes to your code: +```diff +function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; +- payable(msg.sender).transfer(amount); ++ (bool success, ) = payable(msg.sender).transfer(amount); ++ require(success, "Transfer failed") + } +``` \ No newline at end of file diff --git a/003/068.md b/003/068.md new file mode 100644 index 0000000..9124637 --- /dev/null +++ b/003/068.md @@ -0,0 +1,137 @@ +Digital Chocolate Hedgehog + +Medium + +# Deprecated ETH Transfer Method in `LidoVault::withdrawAmountVariablePending` May Prevent Variable Side Pending Rewards from Being Withdrawn. + +### Summary + +The `LidoVault::withdrawAmountVariablePending` function is responsible for allowing variable side depositors to withdraw pending rewards once the vault has started accruing stacking rewards. However, the function currently relies on the deprecated `payable(address).transfer(amount)` method, which imposes a fixed gas stipend of only 2,300 units. This amount may be inadequate for certain contracts that require additional logic to process the transfer, potentially leading to a revert and preventing the withdrawal of rewards. + +The rationale behind using the `transfer` method in the `withdrawAmountVariablePending` function is unclear, especially since the rest of the code employs the `call` method. This choice might stem from the way the `finalizeVaultOngoingVariableWithdrawals` function operates; it first calls `withdrawAmountVariablePending` and then updates several state variables. While using `transfer` helps mitigate reentrancy risks, it inadvertently restricts some contracts from interacting with the protocol and withdrawing their variable side rewards, as previously mentioned. + +[https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L653-L657](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L653-L657) + +```solidity + function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; +@> payable(msg.sender).transfer(amount); + } +``` + +[https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L610C12-L633](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L610C12-L633) + +```solidity + function finalizeVaultOngoingVariableWithdrawals() external { + uint256[] memory requestIds = variableToVaultOngoingWithdrawalRequestIds[msg.sender]; + if(variableToPendingWithdrawalAmount[msg.sender] != 0) { +@> withdrawAmountVariablePending(); +@> // Using `call` instead of `transfer` may introduce a reentrancy vulnerability given that fact that after this call +@> // some storage variables are updated + + if(requestIds.length == 0) { + return; + } + } + require(requestIds.length != 0, "WNR"); + + delete variableToVaultOngoingWithdrawalRequestIds[msg.sender]; + + uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); + + uint256 protocolFee = applyProtocolFee(amountWithdrawn); + + uint256 sendAmount = amountWithdrawn + calculateVariableFeeEarningsShare() - protocolFee; + + bool _isEnded = isEnded(); + transferWithdrawnFunds(msg.sender, sendAmount); + + emit LidoWithdrawalFinalized(msg.sender, requestIds, VARIABLE, true, _isEnded); + emit VariableFundsWithdrawn(sendAmount, msg.sender, true, _isEnded); + } +``` + +### Root Cause + +In https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L656 the contract uses `transfer` instead of a low level `call` function. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +A contract that calls `LidoVault::finalizeVaultOngoingVariableWithdrawals` or `LidoVault::withdrawAmountVariablePending` contains additional logic within its `fallback` or `receive` function(s) requiring more than 2,300 gas to handle the processing of variable side stacking reward claims. + +### Attack Path + +_No response_ + +### Impact + +A contract that invokes `LidoVault::finalizeVaultOngoingVariableWithdrawals` or `LidoVault::withdrawAmountVariablePending` will experience a transaction revert, preventing it from successfully claiming its rewards. + +### PoC + +_No response_ + +### Mitigation + + +To address this vulnerability, it is advisable to replace the `transfer` method with a low-level `.call()` for transferring Ether in `withdrawAmountVariablePending`. Additionally, the `finalizeVaultOngoingVariableWithdrawals` function could be refactored to consolidate the two ETH transfers into a single call at the end of its execution. This approach would enhance the reliability of the withdrawal process while maintaining security. + +```diff +- function withdrawAmountVariablePending() public { ++ function withdrawAmountVariablePending() external { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; + ++ (bool sent, ) = msg.sender.call{value: amount}(""); ++ require(sent, "ETF"); +- payable(msg.sender).transfer(amount); +} +``` + +```diff +function finalizeVaultOngoingVariableWithdrawals() external { + uint256[] memory requestIds = variableToVaultOngoingWithdrawalRequestIds[msg.sender]; + ++ uint256 amountOne; + + if (variableToPendingWithdrawalAmount[msg.sender] != 0) { ++ amountOne = variableToPendingWithdrawalAmount[msg.sender]; ++ variableToPendingWithdrawalAmount[msg.sender] = 0; + +- withdrawAmountVariablePending(); +- if(requestIds.length == 0) { +- return; +- } + } + ++ uint256 amountTwo; + +- require(requestIds.length != 0, "WNR"); ++ if (requestIds.length != 0) { + delete variableToVaultOngoingWithdrawalRequestIds[msg.sender]; + + uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); + + uint256 protocolFee = applyProtocolFee(amountWithdrawn); + +- uint256 sendAmount = amountWithdrawn + calculateVariableFeeEarningsShare() - protocolFee; ++ amountTwo = amountWithdrawn + calculateVariableFeeEarningsShare() - protocolFee; + + bool _isEnded = isEnded(); +- transferWithdrawnFunds(msg.sender, sendAmount); + + emit LidoWithdrawalFinalized(msg.sender, requestIds, VARIABLE, true, _isEnded); +- emit VariableFundsWithdrawn(sendAmount, msg.sender, true, _isEnded); ++ emit VariableFundsWithdrawn(amountTwo, msg.sender, true, _isEnded); ++ } + ++ if (amountOne + amountTwo > 0) { ++ transferWithdrawnFunds(msg.sender, amountOne + amountTwo); ++ } +} +``` diff --git a/003/070.md b/003/070.md new file mode 100644 index 0000000..1790209 --- /dev/null +++ b/003/070.md @@ -0,0 +1,24 @@ +Mammoth Pink Peacock + +Medium + +# Send ether with call instead of transfer + +## Summary +Use call instead of transfer to send ether. And return value must be checked if sending ether is successful or not. Sending ether with the transfer is no longer recommended. +## Vulnerability Detail + function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; + @>> payable(msg.sender).transfer(amount); + } +## Impact + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L656 +## Tool used + +Manual Review + +## Recommendation +use call instead of transfer. \ No newline at end of file diff --git a/003/076.md b/003/076.md new file mode 100644 index 0000000..3c35171 --- /dev/null +++ b/003/076.md @@ -0,0 +1,36 @@ +Sparkly Bubblegum Yeti + +High + +# some smart contract vault users will not be able to withdraw + +## Summary +Some smart contract vault users will not be able to withdraw +## Vulnerability Detail + +When withdrawing ETH from the saffron's vault, `transfer()` is used, instead of `call{}()`. However in case the `msg.sender` is a smart contract, it's fallback or receive function might cost more than `2300 gas(transfer's max gas cost)`. This will lead to withdraw failures and stuck ETH in the vault + +```solidity + /// @notice withdrawal of funds for Variable side + function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; + payable(msg.sender).transfer(amount); <@ + } +``` + +Affected functions: `LidoVault::withdrawAmountVariablePending()` and `LidoVault::finalizeVaultOngoingVariableWithdrawals()` + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L653-L657 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L610-L613 + +## Impact +Smart contracts with custom fallback/receive functions will revert and not receive the withdrawals. Stuck funds in the vault +## Code Snippet + +## Tool used + +Manual Review + +## Recommendation +Use `call `instead of `transfer `for transferring ETH. Protect from reentrancy possibilities if needed, after this change. \ No newline at end of file diff --git a/003/095.md b/003/095.md new file mode 100644 index 0000000..4a2d296 --- /dev/null +++ b/003/095.md @@ -0,0 +1,38 @@ +Amusing Carrot Octopus + +Medium + +# call()` should be used instead of `transfer()` on an `address payable + +## Summary + +## Vulnerability Detail + +Sending would fail if the fallback or receive function of msg.sender has a high gas consumption logic above 2300. +Whether it's a contract or a multisg wallet. + +```solidity +function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; + payable(msg.sender).transfer(amount); + } +``` + +## Impact +Using the transfer() function for an address will inevitably cause the transaction to fail when : + +The requester's smart contract does not implement a payment function. +The requester's smart contract implements a payable fallback that uses more than 2300 gas units. +The requester's smart contract implements a payable fallback function that requires less than 2300 units of gas, but is called via proxy, increasing the call's gas usage to more than 2300. + +In addition, the use of more than 2300 gas units may be mandatory for some multisig wallets. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L652-L657 + +## Tool used +Manual Review + +## Recommendation +I recommend using call() instead of transfer(). \ No newline at end of file diff --git a/003/098.md b/003/098.md new file mode 100644 index 0000000..71a4b2c --- /dev/null +++ b/003/098.md @@ -0,0 +1,52 @@ +Odd Olive Pony + +Medium + +# Pending withdraw amounts cannot be claimed by inadequate smart contract + +Lines of code: +## Vulnerability details +The use of `transfer()` function for an address will make the transaction fail when the claimer is: +- Smart contract without a payable function. +- Smart contract without a fallback which uses more than 2300 gas unit. +- Smart contract with correct fallback but called through proxy, raising the gas usage above 2300. +- Multisig with +2300 gas. + +Issue inspired by https://solodit.xyz/issues/m-01-call-should-be-used-instead-of-transfer-on-an-address-payable-code4rena-backd-backd-contest-git + +## Impact +Smart contracts with one of the conditions above calling their variable side pending withdraws (`withdrawAmountVariablePending`) will have their transaction revert, having their pending funds locked funds in the contract. +```js + /// @notice withdrawal of funds for Variable side + function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; +@> payable(msg.sender).transfer(amount); + } +``` + +Another issue caused by the same root is when calling `finalizeVaultOngoingVariableWithdrawals`: +```js + function finalizeVaultOngoingVariableWithdrawals() external { + uint256[] memory requestIds = variableToVaultOngoingWithdrawalRequestIds[msg.sender]; + if(variableToPendingWithdrawalAmount[msg.sender] != 0) { +@> withdrawAmountVariablePending(); + if(requestIds.length == 0) { + return; + } + } +... +``` +When it first checks if the user has pending variable withdraws, causing the transaction to fail, not following up with the function logic. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L613 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L653 + +## Tool used + +Manual Review + +## Recommendation +Use `call` instead \ No newline at end of file diff --git a/003/101.md b/003/101.md new file mode 100644 index 0000000..ea21c8e --- /dev/null +++ b/003/101.md @@ -0,0 +1,38 @@ +Noisy Cinnamon Orca + +Medium + +# OOG due to transfer + +## Summary +In `LidoVault::withdrawAmountVariablePending` `transfer()` is used for transferring ether to the msg.sender. + +## Vulnerability Detail +As we know `transfer()` sends 2300 gas meaning that a more sophisticated wallet may not be able to receive such a transfer. Such wallet will have to be a smart contract wallet which is using the SLOAD opcode for example. In the mentioned cases all withdrawals will revert due to OOG errors and the ether will be locked in the `LidoVault` contract. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L653-L657 + +## Impact +This issue will lead to some users not being able to withdraw. +Example of wallets that will have such a problem: +1. Gnosis Safe (Safe Wallet) +2. Argent Wallet +3. Authereum +4. Loopring Wallet + +Information source: [here](https://help.safe.global/en/articles/40813-why-can-t-i-transfer-eth-from-a-contract-into-a-safe "https://help.safe.global/en/articles/40813-why-can-t-i-transfer-eth-from-a-contract-into-a-safe") + +## Code Snippet +```solidity +function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; + payable(msg.sender).transfer(amount); +} +``` + +## Tool used +Manual Review + +## Recommendation +To mitigate this issue just change the call from `.transfer()` to `.call()` which does not have a gas cap. diff --git a/003/120.md b/003/120.md new file mode 100644 index 0000000..1f45ebc --- /dev/null +++ b/003/120.md @@ -0,0 +1,178 @@ +Vast Wooden Cod + +Medium + +# MultiSig Wallets Can't Receive Native ETH Due To The `transfer()` Function Gas Constraint Causing Variable Users Unable to Receive Their Pending Variable Amount. + +## Summary: + +In `LidoVault` contract, variable users can get back their variable amount on an ongoing vault. However when fee receiver finalizes an variable user's ongoing withdrawal requests using `feeReceiverFinalizeVaultOngoingVariableWithdrawals` function, their variable users withdrawal amount is updated in `variableToPendingWithdrawalAmount` mapping, which can be later withdrawn by the variable user using `withdrawAmountVariablePending` function. However, the `withdrawAmountVariablePending` function uses `transfer()` function to send the variable amount to the variable user, which can fail if the user uses a multiSig wallet due to the gas constraint. This can cause the variable user to be unable to receive their pending variable amount. + +## Vulnerability Details: + +The `transfer` function is a low-level call that automatically forwards 2300 gas to the recipient (msg.sender) when transferring native tokens (e.g., ETH). However, `transfer()` only forwards 2300 gas, which is not enough for the recipient to execute any non-trivial logic in a receive() or fallback function. For instance, it is not enough for Safes (such as [this one](https://etherscan.io/address/0xd1e6626310fd54eceb5b9a51da2ec329d6d4b68a)) to receive funds, which require more than 6k gas for the call to reach the implementation contract and emit an event. + +## Impact: + +**Variable users** of the `LidoVault` contract who use multiSig wallets will be unable to receive their pending variable amount, as the `transfer()` function will fail in `withdrawAmountVariablePending` function due to the gas constraint. This can lead to a loss of funds for the variable users. + +## Code Snippet: + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L653-L657 + +## Proof Of Concept: + +1. Variable user deposits into the Lido vault for the yeild generated from the fixed users captial. +2. Variable user requests a withdrawal of their variable amount. +3. Fee receiver finalizes the variable user's withdrawal request using `feeReceiverFinalizeVaultOngoingVariableWithdrawals` function. +4. Variable user tries to withdraw their pending variable amount using `withdrawAmountVariablePending` function. +5. The `transfer()` function fails due to the gas constraint, causing the variable user to be unable to receive their pending variable amount. +6. The variable user loses their funds. + +### Proof Of Code: + +#### To run the test, follow the steps below: + +1. Modify the `uint256 lidosEthlidoStETHBalance` variable in the `withdraw` function to ⬇️ + +```diff +- lidostEthlidoStETHBalance = stakingBalance(); + // @info this modified to simulate a rebasing effect ++ lidostEthlidoStETHBalance = stakingBalance() + 20 ether; +``` + +1. Modify the `uint256 withdrawnAmount` variable in `_claimWithdrawals` function to ⬇️ + +```diff +- uint256 withdrawnAmount = address(this).balance - beforeBalance; + // @info this modified to simulate a eth is received from lido ++ uint256 withdrawnAmount = 20 ether; +``` + +1. Run the test using the following code: + +```bash + forge test --mt test_MultiSigVariableUsersWithdraw -vvvv +``` + +```javascript +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test, console, Vm} from "forge-std/Test.sol"; +import {VaultFactory} from "../src/VaultFactory.sol"; +import {LidoVault} from "../src/LidoVault.sol"; +import {ILidoWithdrawalQueueERC721} from "../src/interfaces/ILidoWithdrawalQueueERC721.sol"; + +contract BugTest is Test { + VaultFactory public factory; + LidoVault public vault; + address owner = makeAddr("owner"); + address alice = address(0x123); + address bob = address(0x456); + address charlie = address(0x789); + address vaultAddr; + uint256 fixedSideCapacity = 100 ether; + uint256 variableSideCapacity = 30 ether; + uint256 duration = 30 days; + + function setUp() public { + vm.createSelectFork("https://rpc.ankr.com/eth"); + uint256 protocolFeeBps = 100; + uint256 earlyExitFeeBps = 1000; + vm.startPrank(owner); + factory = new VaultFactory(protocolFeeBps, earlyExitFeeBps); + // @info Setting the protocol fee receiver + factory.setProtocolFeeReceiver(owner); + // @info Creating a vault + factory.createVault(fixedSideCapacity, duration, variableSideCapacity); + (, vaultAddr) = factory.vaultInfo(1); + vault = LidoVault(payable(vaultAddr)); + vm.stopPrank(); + } + + function test_MultiSigVariableUsersWithdraw() public { + MultiSigWallet multiSig = new MultiSigWallet(); + address multiSigAddr = address(multiSig); + uint256 amount = 50 ether; + deal(alice, 100 ether); + deal(multiSigAddr, 15 ether); + deal(charlie, 15 ether); + // @info Alice deposited in Fixed side + vm.startPrank(alice); + vault.deposit{value: amount * 2}(0); + vm.stopPrank(); + + // @info Bob deposited in Variable side + vm.startPrank(multiSigAddr); + vault.deposit{value: 15 ether}(1); + vm.stopPrank(); + + // @info Charlie deposited in Variable side + vm.startPrank(charlie); + vault.deposit{value: 15 ether}(1); + vm.stopPrank(); + + // @info Bob Withdraws in the middle of the vault + vm.warp(block.timestamp + 1 days); + vm.startPrank(multiSigAddr); + vault.withdraw(1); + vm.stopPrank(); + + uint256[] memory requestId = vault.getVariableToVaultOngoingWithdrawalRequestIds(multiSigAddr); + // @info mock call simulating claim withdrawal + mockClaimWithdrawal(requestId[0], 120 ether); + + // @info Fee receiver finalizes variable side withdrawals + vm.startPrank(owner); + vault.feeReceiverFinalizeVaultOngoingVariableWithdrawals(multiSigAddr); + vm.stopPrank(); + + vm.startPrank(multiSigAddr); + vm.expectRevert("NO_GAS"); + // @info TX fails due to gas constraint + vault.withdrawAmountVariablePending(); + vm.stopPrank(); + } + + // @info This function is created to simulate the claim withdrawal function in lido + // as finalization of rebasing in fork testing is not possible + function mockClaimWithdrawal(uint256 requestId, uint256 returnValue) internal { + vm.mockCall( + address(vault.lidoWithdrawalQueue()), + abi.encodeWithSelector(ILidoWithdrawalQueueERC721.claimWithdrawal.selector, requestId), + abi.encode(returnValue) + ); + deal(address(this), 1000 ether); + vm.prank(address(this)); + payable(address(vault)).transfer(100 ether); + } +} + +contract MultiSigWallet { + receive() external payable { + require(gasleft() > 6000, "NO_GAS"); + } +} +``` + +## Tool Used: + +- Foundry +- Manual Analysis + +## Recommendation: + +To prevent this scenario, the `LidoVault` contract should use a different method to send the variable amount to the variable user, such as `call()`. +Here is the recommended mitigation: + +```diff +function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; + // @audit use .call instead of transfer, multisg users cannot withdraw +- payable(msg.sender).transfer(amount); ++ (bool success, ) = msg.sender.call{value: amount}(""); ++ require(success, "Transfer failed"); +} +``` diff --git a/003/125.md b/003/125.md new file mode 100644 index 0000000..5890d1b --- /dev/null +++ b/003/125.md @@ -0,0 +1,50 @@ +Pet Porcelain Sheep + +Medium + +# Withdrawals can be locked forever if recipient is a contract + +## Summary +The `LidoVault` contract uses the `transfer()` function to send ETH to users during the `finalizeVaultOngoingVariableWithdrawals` process. This can lead to permanent locking of funds if the recipient is a contract with a `receive()` or `fallback()` function that requires more than 2300 gas. This is for instance, not enough for Safes implementation which require > 6k gas. + + +## Vulnerability Detail + +In the `withdrawAmountVariablePending()` function, ETH is sent to the user using the `transfer()` method: + +```js +function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; +@> payable(msg.sender).transfer(amount); +} +``` + +This function is called within `finalizeVaultOngoingVariableWithdrawals()` when there's a pending withdrawal amount. +```js + function finalizeVaultOngoingVariableWithdrawals() external { + uint256[] memory requestIds = variableToVaultOngoingWithdrawalRequestIds[msg.sender]; + if(variableToPendingWithdrawalAmount[msg.sender] != 0) { +@> withdrawAmountVariablePending(); + if(requestIds.length == 0) { + return; + } + } +``` +The transfer() function only forwards 2300 gas, which is insufficient for contracts with non-trivial receive() or fallback() functions, such as multi-signature wallets or certain smart contract wallets. + + +## Impact + +If a caller interacts with the vault using a contract account (e.g., a multi-signature wallet like Safe), `finalizeVaultOngoingVariableWithdrawals` will require the caller to be the same address that initiated the withrawal request. +The transfer requires more than 2300 gas to receive ETH, reach the implementation contract and and emit an event. If the caller also calls `feeReceiverFinalizeVaultOngoingVariableWithdrawals` in order to receive the variable fees, their withdrawals in will fail permanently. This leads to a complete loss of funds for affected users, as they won't be able to retrieve their ETH from the vault. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L656 +## Tool used + +Manual Review + +## Recommendation + +Replace the use of `transfer()` with a low-level `call()` to send ETH. \ No newline at end of file diff --git a/003/131.md b/003/131.md new file mode 100644 index 0000000..deab4a1 --- /dev/null +++ b/003/131.md @@ -0,0 +1,78 @@ +Cheesy Clay Grasshopper + +Medium + +# Use call() instead of transfer() when sending ETH in withdrawAmountVariablePending() + +## Summary +When sending ETH in `withdrawAmountVariablePending()`, the transaction can revert if msg.sender is a contract. + +## Vulnerability Detail +If a fee receiver finalizes a variable user's request through `feeReceiverFinalizeVaultOngoingVariableWithdrawals()`, the user can redeem their assets with `withdrawAmountVariablePending()` but if the variable side user is a smart contract (account abstraction wallet, gnosis safe etc.) the redeem transaction will revert if it requires more than 2300 gas sent with transfer(). +Also, if that user sent another request after (but before the vault ends), they won't be able to redeem any rewards because of checks in `finalizeVaultOngoingVariableWithdrawals()` and `vaultEndedWithdraw()` +```solidity +function feeReceiverFinalizeVaultOngoingVariableWithdrawals(address user) external { + require(msg.sender == protocolFeeReceiver, "IFR"); + uint256[] memory requestIds = variableToVaultOngoingWithdrawalRequestIds[user]; + require(requestIds.length != 0, "WNR"); + + delete variableToVaultOngoingWithdrawalRequestIds[user]; + + uint256 amountWithdrawn = claimWithdrawals(user, requestIds); + + uint256 protocolFee = applyProtocolFee(amountWithdrawn); + + uint256 sendAmount = amountWithdrawn + calculateVariableFeeEarningsShareWithUser(user) - protocolFee; + variableToPendingWithdrawalAmount[user] += sendAmount; + } + +function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; + payable(msg.sender).transfer(amount); + } +``` + +```solidity +function finalizeVaultOngoingVariableWithdrawals() external { + uint256[] memory requestIds = variableToVaultOngoingWithdrawalRequestIds[msg.sender]; + if(variableToPendingWithdrawalAmount[msg.sender] != 0) { + @> withdrawAmountVariablePending(); + if(requestIds.length == 0) { + return; + } + } + ... + } +``` +```solidity +function vaultEndedWithdraw(uint256 side) internal { + ... + if (side == FIXED) { + ... + } else { + require(variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0, "WAR"); + ... + } +} +``` + +## Impact +Variable side users with smart contract wallets could lose their rewards if their request is finalized by the fee receiver, Furthermore, they won't be able to redeem later requests if sent while the vault is ongoing. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L637-L657 + +## Tool used +Manual Review + +## Recommendation +Use call() instead of transfer() when sending ETH +```solidity +function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; + (bool ok, ) = payable(msg.sender).call{value: amount}(""); + require(ok, "call failed"); + } +``` \ No newline at end of file diff --git a/003/142.md b/003/142.md new file mode 100644 index 0000000..ef4bb61 --- /dev/null +++ b/003/142.md @@ -0,0 +1,38 @@ +Quaint Ebony Duck + +High + +# Using `transfer` to send ETH is preventing contract users from making withdrawals. + + +## Summary + +Using `transfer` to send ETH in the `withdrawAmountVariablePending` function can lead to issues, as transfer can only send ETH to individuals, not contracts. This may cause the `withdrawAmountVariablePending` function to always revert when a user tries to withdraw after vault started. + +## Vulnerability Detail + +Since there are no restrictions preventing contract users from making deposits, users can deposit ETH into the variable side of the vault using the deposit function. the contract users can use `withdraw` function after the vault started and some request ids generated in `variableToVaultOngoingWithdrawalRequestIds`. So far, everything has been functioning well. However, users cant finalize the withdraw process with `finalizeVaultOngoingVariableWithdrawals` function becuase of using `transfer` instead of `call`. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L656 + +## Impact + +contact users are unable to withdraw after vault started and before ended and lose eth. + +## Tool used + +Manual Review + +## Recommendation + +Instead of using `transfer` protocol can simply use `call` : + +```diff + function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; +- payable(msg.sender).transfer(amount); ++ (bool sent, ) = payable(msg.sender).call{value: amount}(""); ++ require(sent, "ETF"); + } +``` diff --git a/003/160.md b/003/160.md new file mode 100644 index 0000000..86aa7e6 --- /dev/null +++ b/003/160.md @@ -0,0 +1,26 @@ +Clumsy Raisin Tarantula + +Medium + +# Users may incorrectly believe their withdrawal succeeded when transfer fails due to gas limit constraints. + +## Summary +The withdrawAmountVariablePending function uses transfer to send ETH to the recipient, which is risky because it doesn't handle potential failures robustly. The transfer function imposes a 2,300 gas limit on the recipient, but there are multiple reasons why a transfer may fail beyond gas limitations. When transfer fails, the transaction reverts, but the contract does not explicitly catch the failure, which may cause the user to incorrectly believe the withdrawal succeeded. This issue is exacerbated as the function is called during the finalizeVaultOngoingVariableWithdrawals process, potentially leaving users in a state where they think they’ve withdrawn funds when, in fact, they haven’t. + +## Vulnerability Detail +The issue arises from using transfer, which forwards a fixed 2,300 gas to the recipient. This can cause failures due to gas limitations, missing fallback functions, reverting logic, self-destructed contracts, ETH-rejecting contracts, or insufficient balance. The critical problem is that the contract does not explicitly catch or handle the transfer failure, meaning if the transfer fails, users may think their withdrawal succeeded when, in fact, no ETH was transferred. + +## Impact +- Failed Withdrawals: Users will believe their withdrawal succeeded because the contract logic does not explicitly catch and handle the failure of the transfer. This could lead to users losing access to their funds, resulting in user frustration and potential financial loss. + +- Potential Denial of Service: Certain users, particularly those using smart contract wallets or multisig wallets, may repeatedly experience failed withdrawals due to gas limitations or the lack of proper fallback functions, effectively preventing them from accessing their funds. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L656 + +## Tool used + +Manual Review + +## Recommendation +Replace the use of transfer with call, which provides more flexibility in gas handling and allows for better error handling. Additionally, implement proper error handling to ensure that if the transfer fails, the contract can catch the failure and revert the transaction in a user-friendly way. \ No newline at end of file diff --git a/003/162.md b/003/162.md new file mode 100644 index 0000000..59e3c37 --- /dev/null +++ b/003/162.md @@ -0,0 +1,30 @@ +Clean Citron Hornet + +High + +# Some user are not able to withdraw `variableToPendingWithdrawalAmount` + +## Summary +`withdrawAmountVariablePending` will fail in the worst case +## Vulnerability Detail +In function `withdrawAmountVariablePending`, it use `transfer` to transfer `variableToPendingWithdrawalAmount[msg.sender]` to `msg.sender` + +```solidity + function withdrawAmountVariablePending() public { + uint256 amount = variableToPendingWithdrawalAmount[msg.sender]; + variableToPendingWithdrawalAmount[msg.sender] = 0; + payable(msg.sender).transfer(amount); + } +``` + +But the problem is from [solidity docs](https://docs.soliditylang.org/en/v0.8.25/contracts.html#receive-ether-function) when using function transfer, calling fallback and receive function of receiver is limited to 2300 gas. Which mean if receiver is a contract that have `receive()` or `fallback()` function that consume more than 2300 gas, it will revert, lead to user are not able to claim withdraw request. +## Impact +User are not able to withdrawAmountVariablePending in the worst case +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L656 +## Tool used + +Manual Review + +## Recommendation +Using `call()` instead of `transfer()` \ No newline at end of file diff --git a/004/002.md b/004/002.md new file mode 100644 index 0000000..ca164e1 --- /dev/null +++ b/004/002.md @@ -0,0 +1,57 @@ +Brisk Dijon Moth + +High + +# `initialize` in `LidoVault.sol` allows anyone to change key storage variables + +## Summary +`initialize` in `LidoVault.sol` allows anyone to change key storage variables +## Vulnerability Detail +When `isFactoryCreated` is set to false anyone will be able to call `initialize` more than once changing key storage variables such as `protocolFeeReceiver = params.protocolFeeReceiver;` which will allow anyone to steal fees. +```solidity + function initialize(InitializationParams memory params) external { + require(isFactoryCreated != true, "MBF"); + // Only run once by vault factory in atomic operation right after cloning, then fixedSideCapacity is set + require(fixedSideCapacity == 0, "ORO"); + + // Validate args + require(params.vaultId != 0, "NEI"); + require(params.duration != 0, "NEI"); + require(params.fixedSideCapacity != 0, "NEI"); + require(params.variableSideCapacity != 0, "NEI"); + require(params.earlyExitFeeBps != 0, "NEI"); + require(params.protocolFeeReceiver != address(0), "NEI"); + + require(params.fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000) >= minimumDepositAmount, "IFC"); + + // Initialize contract state variables + id = params.vaultId; + duration = params.duration; + fixedSideCapacity = params.fixedSideCapacity; + variableSideCapacity = params.variableSideCapacity; + earlyExitFeeBps = params.earlyExitFeeBps; + protocolFeeBps = params.protocolFeeBps; + protocolFeeReceiver = params.protocolFeeReceiver; + + emit VaultInitialized( + id, + duration, + variableSideCapacity, + fixedSideCapacity, + earlyExitFeeBps, + protocolFeeBps, + protocolFeeReceiver + ); + } + +``` +## Impact +Anyone can change key storage variables +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/f09cf4ce42044a0f22d49e763f76fabe47bd8fe8/lido-fiv/contracts/LidoVault.sol#L276 +## Tool used + +Manual Review + +## Recommendation +Allow initializer to be run only once \ No newline at end of file diff --git a/004/043.md b/004/043.md new file mode 100644 index 0000000..5ab51dc --- /dev/null +++ b/004/043.md @@ -0,0 +1,164 @@ +Swift Amber Rook + +High + +# [H-01] Front-running Vulnerability in `LidoVault` Initialization + +### Summary + +The lack of access control in the `initialize()` function of the `LidoVault` contract will cause a front-running attack for protocol users as an attacker will monitor the blockchain for newly cloned contracts and call the `initialize()` function with arbitrary parameters before the legitimate initialization by the factory occurs. + +### Root Cause + +In `LidoVault.sol:276`, the `initialize()` function is publicly accessible and lacks any access control. The OpenZeppelin `Clones` library creates a proxy of the contract using only the runtime code of the implementation, which means the constructor is not called during cloning. As a result, state variables (like `isFactoryCreated`) are initialized to their default values (e.g., false for booleans), and any party can call the `initialize()` function after the contract has been cloned, leading to the vulnerability​( +[OpenZeppelin Forum](https://forum.openzeppelin.com/t/initializing-inherited-private-immutable-variables-when-cloning-a-contract/36068/7) + +Vulnerable Code: +* https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L276 +* https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L113-L133 + +### Internal pre-conditions + +1. The factory contract calls `Clones.clone(vaultContract)` to create a new vault. This newly cloned contract has default state values (e.g., isFactoryCreated is false). +2. The `initialize()` function of the cloned vault is publicly callable, without any restrictions or access controls. +3. The `initialize()` function has no ownership or factory verification, allowing any address to execute it. + +### External pre-conditions + +1. An attacker monitors the blockchain or mempool for new vault creation events. +2. The factory does not immediately initialize the cloned vault in the same transaction in which the clone is created, leaving a time window for the attacker to exploit. +3. The attacker submits a transaction to call `initialize()` before the legitimate factory does, gaining control over the vault’s state initialization. + +### Attack Path + +1. Factory clones the vault: The `VaultFactory` contract creates a new instance of the `LidoVault` by calling `Clones.clone(vaultContract)`. This creates a proxy contract that has the same behavior as the original LidoVault but is uninitialized at the time of creation. + +2. Attacker monitors the blockchain: The attacker, using tools to track contract events or the mempool, detects the cloning of the vault before it has been initialized. + +3. Attacker submits an `initialize()` call: The attacker quickly submits a transaction to call the public `initialize()` function with their own parameters. Since the cloned vault’s `isFactoryCreated` flag is initially false, the call is successful. + +4. Legitimate initialization fails: Once the attacker has initialized the vault, the factory’s subsequent attempt to initialize it will fail due to the check `require(isFactoryCreated != true, "MBF");`. The attacker’s parameters (such as `vaultId`, `protocolFeeReceiver`, etc.) are now locked in, preventing the legitimate use of the vault. + +### Impact + +* Loss of control over vault parameters: The attacker can set arbitrary or malicious values during initialization, such as a high `earlyExitFeeBps` or setting themselves as the `protocolFeeReceiver`. This compromises the vault’s intended behavior and economic model. + +* Lockout from legitimate initialization: Once the vault is initialized by the attacker, the factory or legitimate users can no longer initialize the vault correctly. This leads to a denial of service, rendering the vault unusable for its intended purpose. + +* Financial losses: Depending on the parameters set, the attacker could redirect protocol fees to their address or make the vault economically unfavorable for legitimate users, causing direct financial loss to the protocol or its users. + +### PoC + +```solidity +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.0; + +import "forge-std/Test.sol"; +import "./LidoVault.sol"; +import "./VaultFactory.sol"; + +contract FrontRunningTest is Test { + LidoVault vault; + VaultFactory factory; + + function setUp() public { + // Deploy the vault factory + factory = new VaultFactory(); + + // Create a vault via the factory + factory.createVault(1000, 30 days, 2000); + } + + function testFrontRunning() public { + // Simulate front-running by calling initialize before the factory does + vault.initialize( + InitializationParams({ + vaultId: 1, + duration: 10, + fixedSideCapacity: 1, + variableSideCapacity: 1, + earlyExitFeeBps: 100, + protocolFeeBps: 100, + protocolFeeReceiver: address(0xdead) + }) + ); + + // Check that the factory can no longer initialize the vault + vm.expectRevert("MBF"); + factory.initializeVault(1); + } +} + +``` + +### Mitigation + +To prevent this front-running vulnerability, the following mitigations are recommended: + +1. Access Control on `initialize()` Function: +The `initialize()` function should have restricted access, such that only the `VaultFactory` contract can call it. This can be achieved by adding an `onlyFactory` modifier that checks if the caller is the factory. + +```solidity +modifier onlyFactory() { + require(msg.sender == factory, "Caller is not the factory"); + _; +} + +function initialize(InitializationParams memory params) external onlyFactory { + require(isFactoryCreated != true, "Already initialized"); + require(fixedSideCapacity == 0, "ORO"); + + // Initialize state variables + id = params.vaultId; + duration = params.duration; + fixedSideCapacity = params.fixedSideCapacity; + variableSideCapacity = params.variableSideCapacity; + earlyExitFeeBps = params.earlyExitFeeBps; + protocolFeeBps = params.protocolFeeBps; + protocolFeeReceiver = params.protocolFeeReceiver; + + isFactoryCreated = true; +} + +``` + +2 . Immediate Initialization after Cloning +To further mitigate front-running risks, the `VaultFactory` contract should initialize the vault immediately in the same transaction as the cloning. This reduces the window of opportunity for an attacker to front-run the transaction. + +Example in the `VaultFactory` contract: +```solidity +function createVault( + uint256 _fixedSideCapacity, + uint256 _duration, + uint256 _variableSideCapacity +) public virtual { + // Clone the vault + address vaultAddress = Clones.clone(vaultContract); + + // Ensure the vault address is valid + require(vaultAddress != address(0), "Failed to clone vault"); + + // Increment the vault ID and store vault info + uint256 vaultId = nextVaultId++; + vaultInfo[vaultId] = VaultInfo({creator: msg.sender, addr: vaultAddress}); + vaultAddrToId[vaultAddress] = vaultId; + + // Prepare initialization parameters + InitializationParams memory params = InitializationParams({ + vaultId: vaultId, + duration: _duration, + fixedSideCapacity: _fixedSideCapacity, + variableSideCapacity: _variableSideCapacity, + earlyExitFeeBps: earlyExitFeeBps, + protocolFeeBps: protocolFeeBps, + protocolFeeReceiver: protocolFeeReceiver + }); + + // Initialize the vault immediately after cloning + ILidoVault(vaultAddress).initialize(params); + + // Emit event after vault creation and initialization + emit VaultCreated(vaultId, _duration, _fixedSideCapacity, _variableSideCapacity, earlyExitFeeBps, protocolFeeBps, protocolFeeReceiver, msg.sender, vaultAddress); +} + +``` \ No newline at end of file diff --git a/004/047.md b/004/047.md new file mode 100644 index 0000000..50998a5 --- /dev/null +++ b/004/047.md @@ -0,0 +1,106 @@ +Brief Latte Buffalo + +High + +# Re-initialization Process isn't clear in Cloned Vaults + +## Summary +The `initialize()` function is called after the vault is cloned, but there’s no built-in protection to prevent it from being called multiple times. If someone calls `initialize()` again on the same vault, it can overwrite its state with new values. +And `contructor()` function is called once, `isFactoryCreated` is always set by `true`. +Thus, `initialize()` function is no necessary in cloned vaults, Initialize process is not clear. +There are several problems arise in the process. +```solidity +constructor(bool _initialize) { + isFactoryCreated = _initialize; + } +``` + + +```solidity + function initialize(InitializationParams memory params) external { + require(isFactoryCreated != true, "MBF"); + // Only run once by vault factory in atomic operation right after cloning, then fixedSideCapacity is set + require(fixedSideCapacity == 0, "ORO"); + + // Validate args + require(params.vaultId != 0, "NEI"); + require(params.duration != 0, "NEI"); + require(params.fixedSideCapacity != 0, "NEI"); + require(params.variableSideCapacity != 0, "NEI"); + require(params.earlyExitFeeBps != 0, "NEI"); + require(params.protocolFeeReceiver != address(0), "NEI"); + + require(params.fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000) >= minimumDepositAmount, "IFC"); + + // Initialize contract state variables + id = params.vaultId; + duration = params.duration; + fixedSideCapacity = params.fixedSideCapacity; + variableSideCapacity = params.variableSideCapacity; + earlyExitFeeBps = params.earlyExitFeeBps; + protocolFeeBps = params.protocolFeeBps; + protocolFeeReceiver = params.protocolFeeReceiver; + + emit VaultInitialized( + id, + duration, + variableSideCapacity, + fixedSideCapacity, + earlyExitFeeBps, + protocolFeeBps, + protocolFeeReceiver + ); + } + ``` +```solidity + function createVault( + uint256 _fixedSideCapacity, + uint256 _duration, + uint256 _variableSideCapacity + ) public virtual { + // Deploy vault (Note: this does not run constructor) + address vaultAddress = Clones.clone(vaultContract); + + require(vaultAddress != address(0), "FTC"); + + // Store vault info + uint256 vaultId = nextVaultId++; + vaultInfo[vaultId] = VaultInfo({creator: msg.sender, addr: vaultAddress}); + vaultAddrToId[vaultAddress] = vaultId; + + InitializationParams memory params = InitializationParams({ + vaultId: vaultId, + duration: _duration, + fixedSideCapacity: _fixedSideCapacity, + variableSideCapacity: _variableSideCapacity, + earlyExitFeeBps: earlyExitFeeBps, + protocolFeeBps: protocolFeeBps, + protocolFeeReceiver: protocolFeeReceiver + }); + + // Initialize vault + ILidoVault(vaultAddress).initialize(params); + + emit VaultCreated( + vaultId, + _duration, + _fixedSideCapacity, + _variableSideCapacity, + earlyExitFeeBps, + protocolFeeBps, + protocolFeeReceiver, + msg.sender, + vaultAddress + ); + } + ``` +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L276 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L107 +## Tool used + +Manual Review + +## Recommendation +Implement an `initialized` boolean flag in the LidoVault contract and ensure that the `initialize()` function can only be called once. +And clear the process of initializing considering several problems during the process. diff --git a/004/049.md b/004/049.md new file mode 100644 index 0000000..f461c8e --- /dev/null +++ b/004/049.md @@ -0,0 +1,29 @@ +Brief Latte Buffalo + +Medium + +# Missing Access Control for Initialization + +## Vulnerability Detail +The cloned vault's initialize() function can be called by anyone. Although it's immediately initialized by the factory, a potential risk arises if someone else calls it before or after the factory initializes the vault. +```solidity + function initialize(InitializationParams memory params) external { + require(isFactoryCreated != true, "MBF"); + // Only run once by vault factory in atomic operation right after cloning, then fixedSideCapacity is set + require(fixedSideCapacity == 0, "ORO"); + + // Validate args + require(params.vaultId != 0, "NEI"); + require(params.duration != 0, "NEI"); + .... +} +``` +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L107 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L271 +## Tool used + +Manual Review + +## Recommendation +Use access control to restrict who can initialize the vault. Only the `VaultFactory` should be able to call `initialize()`. \ No newline at end of file diff --git a/004/051.md b/004/051.md new file mode 100644 index 0000000..49cd352 --- /dev/null +++ b/004/051.md @@ -0,0 +1,111 @@ +Brief Latte Buffalo + +High + +# Cloning Risk and Uninitialized State Variables, Potential Race Condition with createVault(), No Limitations on Vault Creation + +## Summary +- The contract uses the `Clones.clone()` method to deploy new vaults. Since the constructor is not run in the cloned vault, it's important that the `initialize()` function properly sets all necessary variables. Missing or incomplete initialization could leave the vault in an invalid state. +- The factory relies on cloning the vault and then initializing it in two separate steps. In a worst-case scenario, a malicious actor might try to interact with the vault before it is fully initialized, leading to inconsistent behavior or vulnerabilities. +- Anyone can call the `createVault()` function and deploy a new vault. This could lead to spam vault creation and excessive consumption of gas. There is no access control or rate limiting in place to prevent abuse. + +```solidity +/// @notice Deploys a new vault + function createVault( + uint256 _fixedSideCapacity, + uint256 _duration, + uint256 _variableSideCapacity + ) public virtual { + // Deploy vault (Note: this does not run constructor) + address vaultAddress = Clones.clone(vaultContract); + + require(vaultAddress != address(0), "FTC"); + + // Store vault info + uint256 vaultId = nextVaultId++; + vaultInfo[vaultId] = VaultInfo({creator: msg.sender, addr: vaultAddress}); + vaultAddrToId[vaultAddress] = vaultId; + + InitializationParams memory params = InitializationParams({ + vaultId: vaultId, + duration: _duration, + fixedSideCapacity: _fixedSideCapacity, + variableSideCapacity: _variableSideCapacity, + earlyExitFeeBps: earlyExitFeeBps, + protocolFeeBps: protocolFeeBps, + protocolFeeReceiver: protocolFeeReceiver + }); + + // Initialize vault + ILidoVault(vaultAddress).initialize(params); + + emit VaultCreated( + vaultId, + _duration, + _fixedSideCapacity, + _variableSideCapacity, + earlyExitFeeBps, + protocolFeeBps, + protocolFeeReceiver, + msg.sender, + vaultAddress + ); + } + +``` + +```solidity + constructor(bool _initialize) { + isFactoryCreated = _initialize; + } + + /// @inheritdoc ILidoVault + function initialize(InitializationParams memory params) external { + require(isFactoryCreated != true, "MBF"); + // Only run once by vault factory in atomic operation right after cloning, then fixedSideCapacity is set + require(fixedSideCapacity == 0, "ORO"); + + // Validate args + require(params.vaultId != 0, "NEI"); + require(params.duration != 0, "NEI"); + require(params.fixedSideCapacity != 0, "NEI"); + require(params.variableSideCapacity != 0, "NEI"); + require(params.earlyExitFeeBps != 0, "NEI"); + require(params.protocolFeeReceiver != address(0), "NEI"); + + require(params.fixedSideCapacity.mulDiv(minimumFixedDepositBps, 10_000) >= minimumDepositAmount, "IFC"); + + // Initialize contract state variables + id = params.vaultId; + duration = params.duration; + fixedSideCapacity = params.fixedSideCapacity; + variableSideCapacity = params.variableSideCapacity; + earlyExitFeeBps = params.earlyExitFeeBps; + protocolFeeBps = params.protocolFeeBps; + protocolFeeReceiver = params.protocolFeeReceiver; + + emit VaultInitialized( + id, + duration, + variableSideCapacity, + fixedSideCapacity, + earlyExitFeeBps, + protocolFeeBps, + protocolFeeReceiver + ); + } +``` + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L107 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L271 + +## Tool used + +Manual Review + +## Recommendation +- Ensure that the `initialize()` function in LidoVault correctly initializes every necessary variable and that default values are handled carefully. +- While Solidity transactions are atomic and would prevent partial execution, it’s always safer to ensure that initialization happens immediately after the vault is cloned. Refactoring this logic within a single function call prevents any gaps between cloning and initialization. +- Implement access control so only authorized users (e.g., owners or whitelisted addresses) can create new vaults. +- Alternatively, you can charge a fee for creating a vault to deter spam vault creation. diff --git a/004/060.md b/004/060.md new file mode 100644 index 0000000..1209f80 --- /dev/null +++ b/004/060.md @@ -0,0 +1,207 @@ +Amusing Chili Reindeer + +Medium + +# Vault::initialize can be frontrunned allowing anyone to change vault's parameter such as protocolFeeReceiver + +## Summary +Vault::initialize can be frontrunned allowing anyone to change vault's parameter such as protocolFeeReceiver + +## Vulnerability Detail +Vault::initialize doesnt have a mechanism (such as msg.sender check) that protect this method from unauthorized calls, allowing anyone to frontrun and initialize the vault with arbitrary parameters. +The only condition is that this vault's isFactoryCreated value equals to false, this value is set in constructor +```solidity + constructor(bool _initialize) { + isFactoryCreated = _initialize; + } +``` +And used in initialize method +```solidity + function initialize(InitializationParams memory params) external { + require(isFactoryCreated != true, "MBF"); //<@ only check + // Only run once by vault factory in atomic operation right after cloning, then fixedSideCapacity is set + require(fixedSideCapacity == 0, "ORO"); +``` +If this is the case (vault.isFactoryCreated = false), an attacker could call initialize with arbitrary parameters, such as setting himself as protocolFeeReceiver using a higher gas price to front run legitimate initialization call. + +To demonstrate this issue the following PoC is provided. +Deployer D address deploys a new vault with initialize constructor param set to false. +Deployer next calls vault::initialize setting himself as protocolFeeReceiver +Attacker front runs deployer initialize call setting attacker address as protocolFeeReceiver +Deployer initialize call failed and attacker is now the protocolFeeReceiver + +To execute this test save this code as LVFrontrunInitialize.test.ts in test dir: +```js +import { time, loadFixture } from '@nomicfoundation/hardhat-toolbox/network-helpers' +import { anyValue, anyUint } from '@nomicfoundation/hardhat-chai-matchers/withArgs' +import { assert, expect } from 'chai' +import { AddressLike, BigNumberish, ContractTransactionReceipt, parseEther, formatEther } from 'ethers' +import { ethers } from 'hardhat' + +import { LidoVault, VaultFactory } from '../typechain-types' +import { ContractMethodArgs } from '../typechain-types/common' +import { ILidoVaultInitializer } from '../typechain-types/contracts/LidoVault' +import { + compareWithBpsTolerance, + decodeLidoErrorData, + finalizeLidoWithdrawalRequests, + getLidoContract, + getWithdrawalQueueERC721Contract, + submitOracleReport, + BIG_INT_ZERO, + BIG_INT_ONE, + BIG_INT_10K, + DEFAULTS, + SIDE, + ONE_ADDRESS, + ZERO_ADDRESS, + setupMockLidoContracts, +} from './helpers' + +describe('AAALidoVault', function () { + let nextVaultId = 0 + + async function deployVault({ + durationSeconds = DEFAULTS.durationSeconds, + fixedSideCapacity = DEFAULTS.fixedSideCapacity, + variableSideCapacity = fixedSideCapacity * BigInt(DEFAULTS.fixedPremiumBps) / BigInt(10000), + earlyExitFeeBps = DEFAULTS.earlyExitFeeBps, + protocolFeeBps = DEFAULTS.protocolFeeBps, + protocolFeeReceiver, + admin, + }: { + durationSeconds?: number + fixedSideCapacity?: BigInt + variableSideCapacity?: BigInt + earlyExitFeeBps?: number + protocolFeeBps?: number + protocolFeeReceiver?: string + admin?: string + }) { + let LidoVaultFactory = await ethers.getContractFactory('LidoVault') + + let deployer + let addr1 + let addr2 + let addr3 + let addr4 + let addrs + ;[deployer, addr1, addr2, addr3, addr4, ...addrs] = await ethers.getSigners() + + const feeReceiver = protocolFeeReceiver ?? deployer + + const vaultId = ++nextVaultId + const lidoVault: LidoVault = (await LidoVaultFactory.deploy(false)) as any + + const lidoVaultAddress = await lidoVault.getAddress() + const lidoContract = await getLidoContract(deployer) + const lidoWithdrawalQueueContract = getWithdrawalQueueERC721Contract(deployer) + + return { + lidoVault, + deployer, + addr1, + addr2, + addr3, + addr4, + addrs, + vaultId, + protocolFeeReceiver: feeReceiver, + lidoVaultAddress, + lidoContract, + lidoWithdrawalQueueContract, + } + } + + const deployLidoVaultFixture = () => deployVault({}) + describe('AAAVault not Started', function () { + describe('Deposit', function () { + + describe('Fixed Side', function () { + it('AAATest', async function () { + this.timeout(30000); + const { lidoVault, deployer, addr1, addr2, addr3, lidoWithdrawalQueueContract } = await loadFixture(deployLidoVaultFixture) + console.log("== Addresses ==") + console.log("deployer\t\t\t",deployer.address); + console.log("addr3\t\t\t\t",addr3.address); + + // == Disable automining to simulate frontrun + await ethers.provider.send("evm_setAutomine", [false]); + + // == deployer tries to initialize vault + console.log("\ndeployer tries to initialize vault with his address as protocolFeeReceiver"); + await lidoVault.connect(deployer).initialize( + { + vaultId: 1234, + duration: 1, + fixedSideCapacity: BigInt(100_000_000_0000_000_000), + variableSideCapacity: 3, + earlyExitFeeBps: 4, + protocolFeeBps: 5, + protocolFeeReceiver: deployer.address + } as ILidoVaultInitializer.InitializationParamsStruct, + { gasPrice: 50000, gasLimit: 1000000 } + ) + + console.log("addr3 frontruns and calls vault.initialize with his address as protocolFeeReceiver") + await lidoVault.connect(addr3).initialize( + { + vaultId: 1337, + duration: 1, + fixedSideCapacity: BigInt(110_000_000_0000_000_000), + variableSideCapacity: 3, + earlyExitFeeBps: 4, + protocolFeeBps: 5, + protocolFeeReceiver: addr3.address + } as ILidoVaultInitializer.InitializationParamsStruct, + { gasPrice: 100000, gasLimit: 1000000 } + ) + await ethers.provider.send("hardhat_mine", ["1"]); + await ethers.provider.send("evm_setAutomine", [true]); + + console.log("\nIf frontrunning worked vault's protocolFeeReceiver == address3"); + console.log( + "lidoVault.protocolFeeReceiver() ", + await lidoVault.protocolFeeReceiver() + ); + }) + }) + }) + }) +}) +``` + +Next start a forked node (in this case anvil was used ) +```bash +reset;anvil --fork-url https://rpc.ankr.com/eth --fork-block-number 18562954 --fork-chain-id 31337 --block-base-fee-per-gas 10000 +``` +Execute this test with: +```bash +npx hardhat test test/LVFrontrunInitialize.test.ts --network localhost +``` +Observe deposit call is front runned and now attacker is the protocolFeeReceiver address + +## Impact +Vault integrity is impacted because unauthorized arbitrary parameter changes are possible + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L276-L279 + + +## Tool used + +Manual Review + +## Recommendation +Implement an additional check to only allow initialization from vault deployer: +```solidity + address deployer; //<@ new var + constructor(bool _initialize) { + isFactoryCreated = _initialize; + deployer = msg.sender; + } + + function initialize(InitializationParams memory params) external { + //... + require(isFactoryCreated != true && msg.sender == deployer, "MBF"); //<@ additional check +``` \ No newline at end of file diff --git a/004/064.md b/004/064.md new file mode 100644 index 0000000..3055127 --- /dev/null +++ b/004/064.md @@ -0,0 +1,163 @@ +Scrawny Coal Snail + +High + +# Front-running Vulnerability in LidoVault Initialization + +### Summary + +The lack of access control in the initialize() function of the LidoVault contract will cause a front-running attack for protocol users as an attacker will monitor the blockchain for newly cloned contracts and call the initialize() function with arbitrary parameters before the legitimate initialization by the factory occurs. + + +### Root Cause + +In https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L276, the initialize() function is publicly accessible and lacks any access control. The OpenZeppelin Clones library creates a proxy of the contract using only the runtime code of the implementation, which means the constructor is not called during cloning. As a result, state variables (like isFactoryCreated) are initialized to their default values (e.g., false for booleans), and any party can call the initialize() function after the contract has been cloned, leading to the vulnerability​( +OpenZeppelin Forum + + +### Internal pre-conditions + + +The factory contract calls Clones.clone(vaultContract) to create a new vault. This newly cloned contract has default state values (e.g., isFactoryCreated is false). +The initialize() function of the cloned vault is publicly callable, without any restrictions or access controls. +The initialize() function has no ownership or factory verification, allowing any address to execute it. + + +### External pre-conditions + +An attacker monitors the blockchain or mempool for new vault creation events. +The factory does not immediately initialize the cloned vault in the same transaction in which the clone is created, leaving a time window for the attacker to exploit. +The attacker submits a transaction to call initialize() before the legitimate factory does, gaining control over the vault’s state initialization. + + +### Attack Path + +Factory clones the vault: The VaultFactory contract creates a new instance of the LidoVault by calling Clones.clone(vaultContract). This creates a proxy contract that has the same behavior as the original LidoVault but is uninitialized at the time of creation. + +Attacker monitors the blockchain: The attacker, using tools to track contract events or the mempool, detects the cloning of the vault before it has been initialized. + +Attacker submits an initialize() call: The attacker quickly submits a transaction to call the public initialize() function with their own parameters. Since the cloned vault’s isFactoryCreated flag is initially false, the call is successful. + +Legitimate initialization fails: Once the attacker has initialized the vault, the factory’s subsequent attempt to initialize it will fail due to the check require(isFactoryCreated != true, "MBF");. The attacker’s parameters (such as vaultId, protocolFeeReceiver, etc.) are now locked in, preventing the legitimate use of the vault. + + +### Impact + +Loss of control over vault parameters: The attacker can set arbitrary or malicious values during initialization, such as a high earlyExitFeeBps or setting themselves as the protocolFeeReceiver. This compromises the vault’s intended behavior and economic model. + +Lockout from legitimate initialization: Once the vault is initialized by the attacker, the factory or legitimate users can no longer initialize the vault correctly. This leads to a denial of service, rendering the vault unusable for its intended purpose. + +Financial losses: Depending on the parameters set, the attacker could redirect protocol fees to their address or make the vault economically unfavorable for legitimate users, causing direct financial loss to the protocol or its users. + + +### PoC + +```solidity +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.0; + +import "forge-std/Test.sol"; +import "./LidoVault.sol"; +import "./VaultFactory.sol"; + +contract FrontRunningTest is Test { + LidoVault vault; + VaultFactory factory; + + function setUp() public { + // Deploy the vault factory + factory = new VaultFactory(); + + // Create a vault via the factory + factory.createVault(1000, 30 days, 2000); + } + + function testFrontRunning() public { + // Simulate front-running by calling initialize before the factory does + vault.initialize( + InitializationParams({ + vaultId: 1, + duration: 10, + fixedSideCapacity: 1, + variableSideCapacity: 1, + earlyExitFeeBps: 100, + protocolFeeBps: 100, + protocolFeeReceiver: address(0xdead) + }) + ); + + // Check that the factory can no longer initialize the vault + vm.expectRevert("MBF"); + factory.initializeVault(1); + } +} +``` + +### Mitigation + +To prevent this front-running vulnerability, the following mitigations are recommended: + +Access Control on initialize() Function: +The initialize() function should have restricted access, such that only the VaultFactory contract can call it. This can be achieved by adding an onlyFactory modifier that checks if the caller is the factory. +```solidity +modifier onlyFactory() { + require(msg.sender == factory, "Caller is not the factory"); + _; +} + +function initialize(InitializationParams memory params) external onlyFactory { + require(isFactoryCreated != true, "Already initialized"); + require(fixedSideCapacity == 0, "ORO"); + + // Initialize state variables + id = params.vaultId; + duration = params.duration; + fixedSideCapacity = params.fixedSideCapacity; + variableSideCapacity = params.variableSideCapacity; + earlyExitFeeBps = params.earlyExitFeeBps; + protocolFeeBps = params.protocolFeeBps; + protocolFeeReceiver = params.protocolFeeReceiver; + + isFactoryCreated = true; +} +``` +2 . Immediate Initialization after Cloning +To further mitigate front-running risks, the VaultFactory contract should initialize the vault immediately in the same transaction as the cloning. This reduces the window of opportunity for an attacker to front-run the transaction. + +Example in the VaultFactory contract: + +```solidity +function createVault( + uint256 _fixedSideCapacity, + uint256 _duration, + uint256 _variableSideCapacity +) public virtual { + // Clone the vault + address vaultAddress = Clones.clone(vaultContract); + + // Ensure the vault address is valid + require(vaultAddress != address(0), "Failed to clone vault"); + + // Increment the vault ID and store vault info + uint256 vaultId = nextVaultId++; + vaultInfo[vaultId] = VaultInfo({creator: msg.sender, addr: vaultAddress}); + vaultAddrToId[vaultAddress] = vaultId; + + // Prepare initialization parameters + InitializationParams memory params = InitializationParams({ + vaultId: vaultId, + duration: _duration, + fixedSideCapacity: _fixedSideCapacity, + variableSideCapacity: _variableSideCapacity, + earlyExitFeeBps: earlyExitFeeBps, + protocolFeeBps: protocolFeeBps, + protocolFeeReceiver: protocolFeeReceiver + }); + + // Initialize the vault immediately after cloning + ILidoVault(vaultAddress).initialize(params); + + // Emit event after vault creation and initialization + emit VaultCreated(vaultId, _duration, _fixedSideCapacity, _variableSideCapacity, earlyExitFeeBps, protocolFeeBps, protocolFeeReceiver, msg.sender, vaultAddress); +} +``` \ No newline at end of file diff --git a/004/067.md b/004/067.md new file mode 100644 index 0000000..e5411c0 --- /dev/null +++ b/004/067.md @@ -0,0 +1,153 @@ +Striped Punch Wallaby + +High + +# Front-running Vulnerability in LidoVault Initialization + +### Summary + +The lack of access control in the initialize() function of the LidoVault contract will cause a front-running attack for protocol users as an attacker will monitor the blockchain for newly cloned contracts and call the initialize() function with arbitrary parameters before the legitimate initialization by the factory occurs. + +### Root Cause + +In LidoVault.sol:276, the initialize() function is publicly accessible and lacks any access control. The OpenZeppelin Clones library creates a proxy of the contract using only the runtime code of the implementation, which means the constructor is not called during cloning. As a result, state variables (like isFactoryCreated) are initialized to their default values (e.g., false for booleans), and any party can call the initialize() function after the contract has been cloned, leading to the vulnerability + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L276C3-L309C4 + +### Internal pre-conditions + +-The factory contract calls Clones.clone(vaultContract) to create a new vault. This newly cloned contract has default state values (e.g., isFactoryCreated is false). +-The initialize() function of the cloned vault is publicly callable, without any restrictions or access controls. +-The initialize() function has no ownership or factory verification, allowing any address to execute it. + +### External pre-conditions + +An attacker monitors the blockchain or mempool for new vault creation events. +The factory does not immediately initialize the cloned vault in the same transaction in which the clone is created, leaving a time window for the attacker to exploit. +The attacker submits a transaction to call initialize() before the legitimate factory does, gaining control over the vault’s state initialization. + +### Attack Path + +Factory clones the vault: The VaultFactory contract creates a new instance of the LidoVault by calling Clones.clone(vaultContract). This creates a proxy contract that has the same behavior as the original LidoVault but is uninitialized at the time of creation. + +Attacker monitors the blockchain: The attacker, using tools to track contract events or the mempool, detects the cloning of the vault before it has been initialized. + +Attacker submits an initialize() call: The attacker quickly submits a transaction to call the public initialize() function with their own parameters. Since the cloned vault’s isFactoryCreated flag is initially false, the call is successful. + +Legitimate initialization fails: Once the attacker has initialized the vault, the factory’s subsequent attempt to initialize it will fail due to the check require(isFactoryCreated != true, "MBF");. The attacker’s parameters (such as vaultId, protocolFeeReceiver, etc.) are now locked in, preventing the legitimate use of the vault. + +### Impact + +Loss of control over vault parameters: The attacker can set arbitrary or malicious values during initialization, such as a high earlyExitFeeBps or setting themselves as the protocolFeeReceiver. This compromises the vault’s intended behavior and economic model. + +Lockout from legitimate initialization: Once the vault is initialized by the attacker, the factory or legitimate users can no longer initialize the vault correctly. This leads to a denial of service, rendering the vault unusable for its intended purpose. + +Financial losses: Depending on the parameters set, the attacker could redirect protocol fees to their address or make the vault economically unfavorable for legitimate users, causing direct financial loss to the protocol or its users. + +### PoC + +```solidity +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.0; + +import "forge-std/Test.sol"; +import "./LidoVault.sol"; +import "./VaultFactory.sol"; + +contract FrontRunningTest is Test { + LidoVault vault; + VaultFactory factory; + + function setUp() public { + // Deploy the vault factory + factory = new VaultFactory(); + + // Create a vault via the factory + factory.createVault(1000, 30 days, 2000); + } + + function testFrontRunning() public { + // Simulate front-running by calling initialize before the factory does + vault.initialize( + InitializationParams({ + vaultId: 1, + duration: 10, + fixedSideCapacity: 1, + variableSideCapacity: 1, + earlyExitFeeBps: 100, + protocolFeeBps: 100, + protocolFeeReceiver: address(0xdead) + }) + ); + + // Check that the factory can no longer initialize the vault + vm.expectRevert("MBF"); + factory.initializeVault(1); + } +} +``` + +### Mitigation + +To prevent this front-running vulnerability, the following mitigations are recommended: + +Access Control on initialize() Function: +The initialize() function should have restricted access, such that only the VaultFactory contract can call it. This can be achieved by adding an onlyFactory modifier that checks if the caller is the factory. +modifier onlyFactory() { + require(msg.sender == factory, "Caller is not the factory"); + _; +} + +function initialize(InitializationParams memory params) external onlyFactory { + require(isFactoryCreated != true, "Already initialized"); + require(fixedSideCapacity == 0, "ORO"); + + // Initialize state variables + id = params.vaultId; + duration = params.duration; + fixedSideCapacity = params.fixedSideCapacity; + variableSideCapacity = params.variableSideCapacity; + earlyExitFeeBps = params.earlyExitFeeBps; + protocolFeeBps = params.protocolFeeBps; + protocolFeeReceiver = params.protocolFeeReceiver; + + isFactoryCreated = true; +} +2 . Immediate Initialization after Cloning +To further mitigate front-running risks, the VaultFactory contract should initialize the vault immediately in the same transaction as the cloning. This reduces the window of opportunity for an attacker to front-run the transaction. + +Example in the VaultFactory contract: + +function createVault( + uint256 _fixedSideCapacity, + uint256 _duration, + uint256 _variableSideCapacity +) public virtual { + // Clone the vault + address vaultAddress = Clones.clone(vaultContract); + + // Ensure the vault address is valid + require(vaultAddress != address(0), "Failed to clone vault"); + + // Increment the vault ID and store vault info + uint256 vaultId = nextVaultId++; + vaultInfo[vaultId] = VaultInfo({creator: msg.sender, addr: vaultAddress}); + vaultAddrToId[vaultAddress] = vaultId; + + // Prepare initialization parameters + InitializationParams memory params = InitializationParams({ + vaultId: vaultId, + duration: _duration, + fixedSideCapacity: _fixedSideCapacity, + variableSideCapacity: _variableSideCapacity, + earlyExitFeeBps: earlyExitFeeBps, + protocolFeeBps: protocolFeeBps, + protocolFeeReceiver: protocolFeeReceiver + }); + + // Initialize the vault immediately after cloning + ILidoVault(vaultAddress).initialize(params); + + // Emit event after vault creation and initialization + emit VaultCreated(vaultId, _duration, _fixedSideCapacity, _variableSideCapacity, earlyExitFeeBps, protocolFeeBps, protocolFeeReceiver, msg.sender, vaultAddress); +} \ No newline at end of file diff --git a/004/137.md b/004/137.md new file mode 100644 index 0000000..95a56ab --- /dev/null +++ b/004/137.md @@ -0,0 +1,108 @@ +Old Violet Salamander + +High + +# `LidoVault` created by `VaultFactory::createVault` can never be initialized. It also makes all the `VaultFactory` setters useless. + +## Summary + +`LidoVault` created by `VaultFactory::createVault` can never be initialized. This happens because `isFactoryCreated` is already set to `true` which blocks the `LidoVault::initialize` call. + +This also means that all the setters in `VaultFactory` such as `setProtocolFeeBps`, `setProtocolFeeReceiver` and `setEarlyExitFeeBps` are in-effect useless. + + +## Vulnerability Detail + +Please refer to the `Code Snippet` section below when referring to line numbers. + +1. See `@1>` line in `VaultFactory::constructor`. Here `VaultFactory::vaultContract` is set to an address of a `LidoVault` contract which is passed a `true` value in its `constructor`. +2. See line `@2>` in `LidoVault::constructor`. Here `isFactoryCreated` is set to whatever value was passed to the constructor. +3. Therefore, `VaultFactory::vaultContract` points to a contract with its `isFactoryCreated` set to `true`. +4. See line `@3>` in `VaultFactory::createVault` which tries to call `LidoVault::initialize` function. +5. See line `@4>` in `LidoVault::initialize` which blocks the initialization call from going through. Here the check `require(isFactoryCreated != true,...` will fail. + + +## Impact + +Impact is High as none of the core parameters can be set. Also, all the setters in `VaultFactory` such as `setProtocolFeeBps`, `setProtocolFeeReceiver` and `setEarlyExitFeeBps` are in-effect useless. + + +## Code Snippet + +See [VaultFactory::constructor](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L86): + +```javascript + constructor(uint256 _protocolFeeBps, uint256 _earlyExitFeeBps) { + require(_protocolFeeBps < 10_000, "IPB"); + protocolFeeReceiver = msg.sender; + protocolFeeBps = _protocolFeeBps; + earlyExitFeeBps = _earlyExitFeeBps; +@1> vaultContract = address(new LidoVault(true)); + emit VaultCodeSet(msg.sender); + } +``` + +See [LidoVault::constructor](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L272): + +```javascript + constructor(bool _initialize) { +@2> isFactoryCreated = _initialize; + } +``` + +See [VaultFactory::createVault](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L133): + +```javascript + function createVault( + uint256 _fixedSideCapacity, + uint256 _duration, + uint256 _variableSideCapacity + ) public virtual { + // Deploy vault (Note: this does not run constructor) + address vaultAddress = Clones.clone(vaultContract); + ... + + InitializationParams memory params = InitializationParams({ + vaultId: vaultId, + duration: _duration, + fixedSideCapacity: _fixedSideCapacity, + variableSideCapacity: _variableSideCapacity, + earlyExitFeeBps: earlyExitFeeBps, + protocolFeeBps: protocolFeeBps, + protocolFeeReceiver: protocolFeeReceiver + }); + + // Initialize vault +@3> ILidoVault(vaultAddress).initialize(params); + ... + } +``` + + +See [LidoVault::initialize](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L277): + +```javascript + function initialize(InitializationParams memory params) external { +@4> require(isFactoryCreated != true, "MBF"); + ... + // Initialize contract state variables + id = params.vaultId; + duration = params.duration; + fixedSideCapacity = params.fixedSideCapacity; + variableSideCapacity = params.variableSideCapacity; + earlyExitFeeBps = params.earlyExitFeeBps; + protocolFeeBps = params.protocolFeeBps; + protocolFeeReceiver = params.protocolFeeReceiver; + ... +``` + + +## Tool used + +Manual Review + + +## Recommendation + +Remove the `isFactoryCreated` check from `initialize`. Instead create a lock which ensures that the code block may be executed just once similar to `initializer` modifier used by OpenZeppelin's proxy code. + diff --git a/004/148.md b/004/148.md new file mode 100644 index 0000000..a3b5622 --- /dev/null +++ b/004/148.md @@ -0,0 +1,53 @@ +Old Violet Salamander + +Medium + +# Core vault parameters may be reset at any time by any user for any non-factory created `LidoVault` + +## Summary + +There is no mechanism which prevents `LidoVault::initialize` from getting called repeatedly by any user for a non-factory created LidoVault, i.e. whose `isFactoryCreated` value is set to `false`. + + +## Vulnerability Detail + +Please refer to the `Code Snippet` section below when referring to line numbers. + +See line `@1>`. The condition ` require(isFactoryCreated != true,...` will always be true for any non-factory created LidoVault. As this is always true, this function may be called and executed any number of times. + +Also, there is no access control. So any user may call it. + + +## Impact + +High, as for any such LidoVault contract all the core parameters such as duration , fixedSideCapacity, variableSideCapacity, all fees etc. can be controled or changed by any user. + + +## Code Snippet + +See [LidoVault::initialize](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L277): + +```javascript + function initialize(InitializationParams memory params) external { +@1> require(isFactoryCreated != true, "MBF"); + ... + // Initialize contract state variables + id = params.vaultId; + duration = params.duration; + fixedSideCapacity = params.fixedSideCapacity; + variableSideCapacity = params.variableSideCapacity; + earlyExitFeeBps = params.earlyExitFeeBps; + protocolFeeBps = params.protocolFeeBps; + protocolFeeReceiver = params.protocolFeeReceiver; + ... +``` + + +## Tool used + +Manual Review + + +## Recommendation + +Consider adding access control mechanism and a one-time use lock to prevent re-initialization. \ No newline at end of file diff --git a/005/006.md b/005/006.md new file mode 100644 index 0000000..acc69fc --- /dev/null +++ b/005/006.md @@ -0,0 +1,131 @@ +Oblong Chiffon Mole + +Medium + +# Incomplete Withdrawal Handling in LidoVault + +## Summary +The `LidoVault` contract has several functions for handling withdrawals that lack mechanisms for ensuring timely execution and handling failures. This can lead to potential data loss and user dissatisfaction due to inaccessible funds. + +## Vulnerability Detail +1. `finalizeVaultNotStartedFixedWithdrawals` +Issue: Data loss if not called before the vault starts. +```solidity +577: function finalizeVaultNotStartedFixedWithdrawals() external { +578:@=> uint256[] memory requestIds = fixedToVaultNotStartedWithdrawalRequestIds[msg.sender]; +579:@=> require(requestIds.length != 0, "WNR"); +--- +581: delete fixedToVaultNotStartedWithdrawalRequestIds[msg.sender]; +--- +584: uint256 sendAmount = claimWithdrawals(msg.sender, requestIds); +--- +586: bool _isStarted = isStarted(); +587: bool _isEnded = isEnded(); +588: transferWithdrawnFunds(msg.sender, sendAmount); +--- +590: emit LidoWithdrawalFinalized(msg.sender, requestIds, FIXED, _isStarted, _isEnded); +591: emit FixedFundsWithdrawn(sendAmount, msg.sender, _isStarted, _isEnded); +592: } +``` +This function relies on manual invocation before the vault starts. If not called, users may lose the opportunity to withdraw funds before the vault transitions to the "started" state. + +2. `finalizeVaultOngoingFixedWithdrawals` +Issue: No mechanism to handle failed withdrawals. +```solidity +595: function finalizeVaultOngoingFixedWithdrawals() external { +596:@=> uint256 sendAmount = claimFixedVaultOngoingWithdrawal(msg.sender); +597: bool _isEnded = isEnded(); +598: uint256 arrayLength = fixedOngoingWithdrawalUsers.length; +599: for (uint i = 0; i < arrayLength; i++) { +600: if (fixedOngoingWithdrawalUsers[i] == msg.sender) { +601: delete fixedOngoingWithdrawalUsers[i]; +602: } +603: } +604:@=> transferWithdrawnFunds(msg.sender, sendAmount); +--- +606: emit FixedFundsWithdrawn(sendAmount, msg.sender, true, _isEnded); +607: } +``` +If a withdrawal fails due to technical issues, there is no retry mechanism or error handling to address the failure. + +3. `claimOngoingFixedWithdrawals` +Issue: Data loss if not called timely; no mechanism for handling failed withdrawals. +```solidity +690: function claimOngoingFixedWithdrawals() internal { +691:@=> uint256 arrayLength = fixedOngoingWithdrawalUsers.length; +692: for (uint i = 0; i < arrayLength; i++) { +693: address fixedUser = fixedOngoingWithdrawalUsers[i]; +694: fixedToPendingWithdrawalAmount[fixedUser] = claimFixedVaultOngoingWithdrawal(fixedUser); +695: delete fixedOngoingWithdrawalUsers[i]; +696: } +697: } +``` +This function must be called in a timely manner to ensure users can claim their withdrawals. Additionally, it lacks error handling for failed claims. + +## Impact +Users may lose access to their funds if functions are not called at the right time. + +## Code Snippet +- https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L577-L592 +- https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L595-L607 +- https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L690-L697 + +## Tool used + +Manual Review + +## Recommendation +- Introduce retry logic for handling failed withdrawals to ensure users can eventually access their funds. +- Use automated scripts or smart contract logic to ensure critical functions are called at the appropriate times. +- Implement error handling to manage and log failures, providing feedback to users and allowing for corrective actions. +- Develop a notification system to inform users of their withdrawal status, especially if there are delays or issues. +```diff +function finalizeVaultNotStartedFixedWithdrawals() external { + uint256[] memory requestIds = fixedToVaultNotStartedWithdrawalRequestIds[msg.sender]; + require(requestIds.length != 0, "WNR"); + + delete fixedToVaultNotStartedWithdrawalRequestIds[msg.sender]; + + uint256 sendAmount = claimWithdrawals(msg.sender, requestIds); ++ require(sendAmount > 0, "Withdrawal failed"); // Ensure withdrawal was successful + +- bool _isStarted = isStarted(); +- bool _isEnded = isEnded(); + transferWithdrawnFunds(msg.sender, sendAmount); + +- emit LidoWithdrawalFinalized(msg.sender, requestIds, FIXED, _isStarted, _isEnded); +- emit FixedFundsWithdrawn(sendAmount, msg.sender, _isStarted, _isEnded); +} + +function finalizeVaultOngoingFixedWithdrawals() external { + uint256 sendAmount = claimFixedVaultOngoingWithdrawal(msg.sender); +- bool _isEnded = isEnded(); +- uint256 arrayLength = fixedOngoingWithdrawalUsers.length; +- for (uint i = 0; i < arrayLength; i++) { +- if (fixedOngoingWithdrawalUsers[i] == msg.sender) { +- delete fixedOngoingWithdrawalUsers[i]; + } + } ++ require(sendAmount > 0, "Withdrawal failed"); // Ensure withdrawal was successful + + transferWithdrawnFunds(msg.sender, sendAmount); + +- emit FixedFundsWithdrawn(sendAmount, msg.sender, true, _isEnded); +} + +function claimOngoingFixedWithdrawals() internal { + uint256 arrayLength = fixedOngoingWithdrawalUsers.length; + for (uint i = 0; i < arrayLength; i++) { + address fixedUser = fixedOngoingWithdrawalUsers[i]; ++ uint256 amount = claimFixedVaultOngoingWithdrawal(fixedUser); ++ if (amount == 0) { + // Log failure and consider retry logic + // Example: emit WithdrawalFailed(fixedUser, "Claim failed, retry needed"); ++ continue; + } +- fixedToPendingWithdrawalAmount[fixedUser] = claimFixedVaultOngoingWithdrawal(fixedUser); ++ fixedToPendingWithdrawalAmount[fixedUser] = amount; + delete fixedOngoingWithdrawalUsers[i]; + } +} +``` \ No newline at end of file diff --git a/005/007.md b/005/007.md new file mode 100644 index 0000000..2140fa2 --- /dev/null +++ b/005/007.md @@ -0,0 +1,100 @@ +Oblong Chiffon Mole + +High + +# Inaccurate Withdrawal Calculations Due to Staking Balance Fluctuations + +## Summary +The `finalizeVaultOngoingVariableWithdrawals` function in the `LidoVault` contract is vulnerable to inaccurate withdrawal calculations due to its reliance on the current staking balance. Fluctuations in the staking balance can lead to incorrect distribution of funds, resulting in potential financial losses for users and the vault. + +## Vulnerability Detail +The vulnerability arises from the function's dependency on the current staking balance to calculate the amount to be withdrawn. If the staking balance changes between the time a withdrawal request is made and when it is finalized, the calculations not accurately reflect the user's entitled amount. +```solidity +610: function finalizeVaultOngoingVariableWithdrawals() external { +611:@=> uint256[] memory requestIds = variableToVaultOngoingWithdrawalRequestIds[msg.sender]; +612: if(variableToPendingWithdrawalAmount[msg.sender] != 0) { +613: withdrawAmountVariablePending(); +614: if(requestIds.length == 0) { +615: return; +616: } +617: } +618:@=> require(requestIds.length != 0, "WNR"); +--- +620: delete variableToVaultOngoingWithdrawalRequestIds[msg.sender]; +--- +622:@=> uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); +--- +624: uint256 protocolFee = applyProtocolFee(amountWithdrawn); +--- +626: uint256 sendAmount = amountWithdrawn + calculateVariableFeeEarningsShare() - protocolFee; +--- +628: bool _isEnded = isEnded(); +629: transferWithdrawnFunds(msg.sender, sendAmount); +--- +631: emit LidoWithdrawalFinalized(msg.sender, requestIds, VARIABLE, true, _isEnded); +632: emit VariableFundsWithdrawn(sendAmount, msg.sender, true, _isEnded); +633: } +``` +```solidity +uint256[] memory requestIds = variableToVaultOngoingWithdrawalRequestIds[msg.sender]; +require(requestIds.length != 0, "WNR"); +``` +The function retrieves and deletes the user's withdrawal request IDs, assuming the staking balance has not changed. +```solidity +uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); +``` +The `claimWithdrawals` function calculates the amount to be withdrawn based on the current staking balance, which may have changed since the withdrawal request was made. + +## Impact +- Users receive more or less than their fair share. +- The vault or users may incur financial losses due to incorrect calculations. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L610-L633 + +## Tool used + +Manual Review + +## Recommendation +- Implement a snapshot mechanism to record the staking balance at the time of the withdrawal request. Use this snapshot for calculations to ensure consistency. +- Introduce a buffer or margin in calculations to account for potential fluctuations in the staking balance. +```diff ++ mapping(address => uint256) public stakingBalanceSnapshots; + ++ function requestWithdrawal(uint256 amount) external { + // Take a snapshot of the staking balance when a withdrawal is requested ++ stakingBalanceSnapshots[msg.sender] = stakingBalance(); + // Proceed with withdrawal request logic +} + +function finalizeVaultOngoingVariableWithdrawals() external { + uint256[] memory requestIds = variableToVaultOngoingWithdrawalRequestIds[msg.sender]; +- if(variableToPendingWithdrawalAmount[msg.sender] != 0) { +- withdrawAmountVariablePending(); +- if(requestIds.length == 0) { +- return; + } + } + require(requestIds.length != 0, "WNR"); + + delete variableToVaultOngoingWithdrawalRequestIds[msg.sender]; + + // Use the snapshot for calculations ++ uint256 snapshotBalance = stakingBalanceSnapshots[msg.sender]; + uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds, snapshotBalance); + + uint256 protocolFee = applyProtocolFee(amountWithdrawn); + + uint256 sendAmount = amountWithdrawn + calculateVariableFeeEarningsShare() - protocolFee; + +- bool _isEnded = isEnded(); + transferWithdrawnFunds(msg.sender, sendAmount); + +- emit LidoWithdrawalFinalized(msg.sender, requestIds, VARIABLE, true, _isEnded); +- emit VariableFundsWithdrawn(sendAmount, msg.sender, true, _isEnded); + + // Clear the snapshot after the withdrawal is finalized ++ delete stakingBalanceSnapshots[msg.sender]; +} +``` \ No newline at end of file diff --git a/005/023.md b/005/023.md new file mode 100644 index 0000000..0b60334 --- /dev/null +++ b/005/023.md @@ -0,0 +1,51 @@ +Late Sable Swan + +Medium + +# Inefficient array item deletion + +## Summary +The LidoVault contract contains an issue where entries are deleted from the fixedOngoingWithdrawalUsers array using the delete keyword. This method sets the array element to 0x0 without reducing the array length, leaving "holes" in the array. Over time, this could lead to performance degradation, high gas costs, and inefficient iterations over arrays with empty slots. + +## Vulnerability Detail +In the finalizeVaultOngoingFixedWithdrawals() and claimOngoingFixedWithdrawals(), when a user completes a withdrawal, their address is removed from the fixedOngoingWithdrawalUsers array. +Users participate this vault, each being tracked in an array (fixedOngoingWithdrawalUsers). When a user finalizes a withdrawal, their entry in the fixedOngoingWithdrawalUsers array is set to address(0) using delete. However, the array length remains the same. As more users withdraw, the array accumulates address(0) entries. +Each time the contract processes reward distributions or updates users, it has to iterate over a growing array of useless address(0) values, consuming unnecessary gas. As the array grows, this inefficiency compounds, leading to exponentially increasing gas costs. Eventually, out-of-gas errors may occur during contract interactions, causing failed transactions and rendering the contract unusable. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L599-L603 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L692-L696 + +## Tool used +Manual Review + +## Recommendation +To fix the issue, replace the element to be deleted with the last element of the array and then reduce the array's length using pop(). +```solidity +function finalizeVaultOngoingFixedWithdrawals() external { + uint256 sendAmount = claimFixedVaultOngoingWithdrawal(msg.sender); + bool _isEnded = isEnded(); + uint256 arrayLength = fixedOngoingWithdrawalUsers.length; + for (uint i = 0; i < arrayLength; i++) { + if (fixedOngoingWithdrawalUsers[i] == msg.sender) { + fixedOngoingWithdrawalUsers[i] = fixedOngoingWithdrawalUsers[arrayLength - 1]; + fixedOngoingWithdrawalUsers.pop(); + break; + } + } + transferWithdrawnFunds(msg.sender, sendAmount); + + emit FixedFundsWithdrawn(sendAmount, msg.sender, true, _isEnded); +} +``` +```solidity +function claimOngoingFixedWithdrawals() internal { + uint256 arrayLength = fixedOngoingWithdrawalUsers.length; + while (arrayLength > 0) { + arrayLength--; + address fixedUser = fixedOngoingWithdrawalUsers[arrayLength]; + fixedToPendingWithdrawalAmount[fixedUser] = claimFixedVaultOngoingWithdrawal(fixedUser); + fixedOngoingWithdrawalUsers.pop(); + } +} +``` \ No newline at end of file diff --git a/005/063.md b/005/063.md new file mode 100644 index 0000000..a981992 --- /dev/null +++ b/005/063.md @@ -0,0 +1,55 @@ +Real Tangelo Lion + +Medium + +# Inefficient way to delete in the `fixedOngoingWithdrawalUsers` array with the `claimOngoingFixedWithdrawals` function + +### Summary + +The `claimOngoingFixedWithdrawals` function deletes elements from the `fixedOngoingWithdrawalUsers` array within the loop by setting them to the zero address. This does not actually remove the element, leading to inefficiencies and potential issues if the array is used elsewhere. +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L692-L695 + +### Root Cause + +The root cause of the inefficient deletion in the loop within the `claimOngoingFixedWithdrawals` function stems from how elements are "deleted" from the `fixedOngoingWithdrawalUsers` array. Here's a breakdown of the issue: + +#### Setting Elements to Zero: + +* Approach: The function sets elements of the array to the zero address (or equivalent) to indicate they are "deleted." +* Problem: This approach does not actually remove the element from the array; it only nullifies its value. The array's length remains unchanged, and the zeroed elements still occupy space. + +### Attack Path + +The inefficient deletion in the loop can be exploited to create a Denial of Service (DoS) situation by increasing the gas cost of operations that involve iterating over the array. Here's a potential attack path: + +#### Identify the Vulnerability: +* An attacker identifies that the `fixedOngoingWithdrawalUsers` array is not properly managed, with elements being set to zero rather than removed. This leads to inefficiencies in how the array is processed. + +#### Trigger Array Growth: +* The attacker repeatedly triggers conditions that add entries to the `fixedOngoingWithdrawalUsers` array. Even if these entries are later "deleted" by setting them to zero, they still occupy space in the array. + +#### Increase Gas Costs: +* By artificially inflating the size of the array with zeroed-out entries, the attacker increases the gas cost of any function that iterates over the array. This includes the `claimOngoingFixedWithdrawals` function and potentially other functions that interact with the array. + +#### Denial of Service (DoS): +* The increased gas cost makes it prohibitively expensive for legitimate users to process withdrawals or perform other operations involving the array. If the gas cost exceeds the block gas limit, transactions will fail, effectively creating a DoS situation. + +#### Sustain the Attack: +* The attacker can sustain the attack by continuously adding entries to the array, ensuring that the gas cost remains high and the DoS condition persists + +### Impact + +The inefficient deletion in the loop within the `claimOngoingFixedWithdrawals` function can lead to several issues. Here's a detailed explanation: + +#### State Bloat: +* Impact: The array retains its length even if elements are set to zero, leading to unnecessary state bloat. This can make the contract more expensive to interact with and maintain. + +#### Logical Errors: +* Impact: Future logic that relies on the array's length or contents might behave unexpectedly, as the array still contains zeroed-out elements. + +#### Gas Inefficiency: +* Impact: Continuously iterating over a growing array with "deleted" elements (set to zero) can lead to increased gas costs. This inefficiency can make transactions more expensive and potentially exceed gas limits, causing them to fail. + +### Mitigation + + Consider using a more efficient method to remove elements from an array, such as swapping the element with the last one and then popping the array. diff --git a/005/071.md b/005/071.md new file mode 100644 index 0000000..e80b025 --- /dev/null +++ b/005/071.md @@ -0,0 +1,75 @@ +Large Fleece Chimpanzee + +Medium + +# [M-1]Unbounded Array Growth Leading to High Gas Consumption and Potential DoS Attack Risk + +## Summary +An issue in the LidoVault contract allows the fixedOngoingWithdrawalUsers array to grow without limits. This causes higher gas costs during operations, especially when looping through the array. If abused, it can lead to a Denial of Service (DoS) attack. +## Vulnerability Detail +The array fixedOngoingWithdrawalUsers grows in size, but deleted elements are not removed efficiently. As a result, when looping through this array (e.g., in finalizeVaultOngoingFixedWithdrawals()), the array length increases over time. This can lead to high gas usage. Additionally, an attacker can inflate the array by making many small deposits, increasing gas consumption and causing potential transaction failures for other users. +## Impact +High Gas Costs: As the array grows, later users will pay more gas to complete their transactions. +DoS Attack Risk: A malicious user can fill the array with small deposits, making it harder for others to withdraw funds due to gas limits. +## Code Snippet +[(https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L601)] +```solidity +for (uint i = 0; i < arrayLength; i++) { + if (fixedOngoingWithdrawalUsers[i] == msg.sender) { + delete fixedOngoingWithdrawalUsers[i]; // Deletes but doesn't shrink array + } +} +``` +###The code segments that would be affected due to the aforementioned code are as follows: +[https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L601] +```solidity + function finalizeVaultOngoingFixedWithdrawals() external { + uint256 sendAmount = claimFixedVaultOngoingWithdrawal(msg.sender); + bool _isEnded = isEnded(); + uint256 arrayLength = fixedOngoingWithdrawalUsers.length; + for (uint i = 0; i < arrayLength; i++) { + if (fixedOngoingWithdrawalUsers[i] == msg.sender) { + delete fixedOngoingWithdrawalUsers[i]; + } + } + transferWithdrawnFunds(msg.sender, sendAmount); + + emit FixedFundsWithdrawn(sendAmount, msg.sender, true, _isEnded); + } +``` +[https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1168] +```solidity + function _claimWithdrawals(address user, uint256[] memory requestIds) internal returns (uint256) { + unlockReceive = true; + uint256 beforeBalance = address(this).balance; + + // Claim Ether for the burned stETH positions + // this will fail if the request is not finalized + for (uint i = 0; i < requestIds.length; i++) { + lidoWithdrawalQueue.claimWithdrawal(requestIds[i]); + } + + uint256 withdrawnAmount = address(this).balance - beforeBalance; + require(withdrawnAmount > 0, "IWA"); + + emit WithdrawalClaimed(withdrawnAmount, requestIds, user); + + unlockReceive = false; + return withdrawnAmount; + } +``` +## Tool used + +Manual Review + +## Recommendation +Array Compression: Replace the deleted element with the last one and reduce the array size: + +solidity +```solidity +fixedOngoingWithdrawalUsers[i] = fixedOngoingWithdrawalUsers[arrayLength - 1]; +fixedOngoingWithdrawalUsers.pop(); +``` +Batch Processing: Process the array in smaller batches to avoid gas limit issues. + +Array Limit: Set a maximum length for the array to prevent excessive growth. \ No newline at end of file diff --git a/005/080.md b/005/080.md new file mode 100644 index 0000000..0ec3ae1 --- /dev/null +++ b/005/080.md @@ -0,0 +1,62 @@ +Rich Orchid Meerkat + +Medium + +# Potential DoS due to non-shrinking array usage in an unbounded loop + +## Summary + +When a vault is started, users that have made a deposit in it have the ability to `withdraw()` which will start a withdrawal process and add their address to the `fixedOngoingWithdrawalUsers` array. + + + +```solidity +fixedBearerToken[msg.sender] = 0; +fixedBearerTokenTotalSupply -= bearerBalance; +fixedSidestETHOnStartCapacity -= withdrawAmount; +fixedToVaultOngoingWithdrawalRequestIds[msg.sender] = WithdrawalRequest({ + requestIds: requestWithdrawViaETH(msg.sender, withdrawAmount), + timestamp: block.timestamp +}); +@> fixedOngoingWithdrawalUsers.push(msg.sender); +``` + +## Vulnerability Detail + +The array `fixedOngoingWithdrawalUsers` in `LidoVault` is subjected to grow in size indefinitely in case multiple user attempt to `withdraw()` while the vault is ongoing. + +When the withdrawal can be finalized, users call `finalizeVaultOngoingFixedWithdrawals()` which will loop through every entry of the `fixedOngoingWithdrawalUsers` array before `delete` the entry. + + + +```solidity +function finalizeVaultOngoingFixedWithdrawals() external { + uint256 sendAmount = claimFixedVaultOngoingWithdrawal(msg.sender); + bool _isEnded = isEnded(); + uint256 arrayLength = fixedOngoingWithdrawalUsers.length; + for (uint i = 0; i < arrayLength; i++) { + if (fixedOngoingWithdrawalUsers[i] == msg.sender) { +@> delete fixedOngoingWithdrawalUsers[i]; + } + } + transferWithdrawnFunds(msg.sender, sendAmount); + + emit FixedFundsWithdrawn(sendAmount, msg.sender, true, _isEnded); +} +``` + +Since the entries are only `deleted`, the array size remains the same. This only resets the value of the entry to `address(0)`. + +## Impact + +The functions that loop through the `fixedOngoingWithdrawalUsers` array are subjected to Denial Of Service : +- `claimOngoingFixedWithdrawals()` +- `finalizeVaultOngoingFixedWithdrawals()` + +## Tool used + +Manual Review + +## Recommendation + +Replace the `fixedOngoingWithdrawalUsers` array by a mapping or add another variable responsible for tracking the entry of the `fixedOngoingWithdrawalUsers` array that belongs to a particular user. diff --git a/005/082.md b/005/082.md new file mode 100644 index 0000000..1849563 --- /dev/null +++ b/005/082.md @@ -0,0 +1,29 @@ +Feisty Lipstick Gibbon + +Medium + +# There is an issue with the management of the `fixedOngoingWithdrawalUsers` array. + +## Summary + +## Vulnerability Detail + +In the `finalizeVaultOngoingFixedWithdrawals` function, the current approach involves iterating over the `fixedOngoingWithdrawalUsers` array and using the `delete` operator to remove user addresses. However, this method only sets the respective array elements to their default value (`address(0)`) without shrinking the array length. + +## Impact + +Array bloating: Over time, the array will accumulate many `address(0)` elements, increasing the gas cost for subsequent operations. + +Logical confusion: Handling arrays with `address(0)` elements may cause unexpected behavior or errors during iteration or validation. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L601 + +## Tool used + +Manual Review + +## Recommendation +To manage the array more efficiently, use a technique that maintains array compactness. One effective method is to swap the element to be deleted with the last element of the array, and then use the `pop` function to remove the last element. This reduces gas consumption and keeps the array compact. +Use mappings instead of arrays.Since reading from a mapping has constant time complexity (O(1)), it can avoid traversing the entire array. \ No newline at end of file diff --git a/005/118.md b/005/118.md new file mode 100644 index 0000000..081f700 --- /dev/null +++ b/005/118.md @@ -0,0 +1,75 @@ +Keen Lead Squid + +High + +# FadoBagi - Incorrect Handling of fixedOngoingWithdrawalUsers Leads to Inconsistencies + +FadoBagi + +High + +# Incorrect Handling of `fixedOngoingWithdrawalUsers` Leads to Inconsistencies + +## Summary +The `LidoVault` contract manages a dynamic array `fixedOngoingWithdrawalUsers` to track users with ongoing fixed withdrawals. However, it uses the `delete` operation to remove users from this array, which sets the element to `address(0)` without removing it. This results in gaps within the array, leading to inconsistent state management and increased gas consumption during array iterations. + +## Vulnerability Detail +In the `finalizeVaultOngoingFixedWithdrawals`, `claimOngoingFixedWithdrawals`, `claimFixedVaultOngoingWithdrawal` functions, the contract removes users from the `fixedOngoingWithdrawalUsers` array by setting their address to `address(0)`. + +Example: + + function finalizeVaultOngoingFixedWithdrawals() external { + // ... + uint256 arrayLength = fixedOngoingWithdrawalUsers.length; + for (uint i = 0; i < arrayLength; i++) { + if (fixedOngoingWithdrawalUsers[i] == msg.sender) { + delete fixedOngoingWithdrawalUsers[i]; + } + } + // ... + } + +This is leaving gaps with `address(0)`. Using `delete fixedOngoingWithdrawalUsers[i];` sets the array element to `address(0)` without reducing the array's length. The array retains its original length, with `address(0)` occupying the spot of the deleted user. + +As the array grows with `address(0)` entries, loops become more gas-intensive. + +Extremely large arrays with numerous `address(0)` entries can cause critical functions to fail due to exceeding gas limits. + +Proof of Concept: + + function poc() pure external { + address[1] memory fixedOngoingWithdrawalUsers = [ + 0x1234567890123456789012345678901234567890 + ]; + + uint256 arrayLength = fixedOngoingWithdrawalUsers.length; + for (uint256 i = 0; i < arrayLength; i++) { + if (fixedOngoingWithdrawalUsers[i] == 0x1234567890123456789012345678901234567890) { + delete fixedOngoingWithdrawalUsers[i]; + } + } + + // Log the length and elements of the array + console.log("Array Length:", arrayLength); + console.log("Element 0:", fixedOngoingWithdrawalUsers[0]); + } + + +## Impact +The improper handling of the `fixedOngoingWithdrawalUsers` array leads to increased gas costs, out-of-gas errors, and inconsistent contract states. This can prevent users from successfully withdrawing their funds. + +## Code Snippet +**Function: `finalizeVaultOngoingFixedWithdrawals`** +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L595-L607 + +**Function: `claimOngoingFixedWithdrawals`** +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L690-L697 + +**Function: `claimFixedVaultOngoingWithdrawal`** +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L812-L843 + +## Tool used +Manual Review + +## Recommendation +Replace the `fixedOngoingWithdrawalUsers` array with a mapping to track ongoing withdrawals, eliminating gaps and reducing gas consumption. If maintaining an array is necessary for enumeration, implement an efficient removal method, such as swapping the user with the last element and popping the array, to prevent `address(0)` entries and ensure consistent state management. \ No newline at end of file diff --git a/005/150.md b/005/150.md new file mode 100644 index 0000000..1779786 --- /dev/null +++ b/005/150.md @@ -0,0 +1,70 @@ +Tiny Heather Viper + +High + +# Lido FIV Unfinalized Fixed Withdrawals Can Block Vault Finalization + + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L690 + +in fn `claimOngoingFixedWithdrawals` + + +The function doesn't handle the scenario where a fixed user has requested a withdrawal, but the withdrawal hasn't been finalized by the time the vault ends. This can lead to a situation where some fixed users might not receive their funds properly when the vault ends. + + +```solidity +function claimOngoingFixedWithdrawals() internal { + uint256 arrayLength = fixedOngoingWithdrawalUsers.length; + for (uint i = 0; i < arrayLength; i++) { + address fixedUser = fixedOngoingWithdrawalUsers[i]; + fixedToPendingWithdrawalAmount[fixedUser] = claimFixedVaultOngoingWithdrawal(fixedUser); + delete fixedOngoingWithdrawalUsers[i]; + } +} +``` + +The vulnerability arises because: + +1. The function assumes that all ongoing fixed withdrawals can be claimed successfully. +2. It doesn't check if the Lido withdrawal for each user has been finalized. +3. If a withdrawal hasn't been finalized, `claimFixedVaultOngoingWithdrawal` will revert, causing the entire function to revert. + +This can have several impacts on the project: + +1. It could prevent the vault from ending properly if any fixed user's withdrawal is not yet finalized. +2. It might lead to some fixed users not receiving their funds when the vault ends. +3. It could create an inconsistent state where some users' withdrawals are processed while others are not. + + #### To fix this, the function should: + +1. Check if each withdrawal request has been finalized before attempting to claim it. +2. Handle cases where a withdrawal is not yet finalized, perhaps by keeping track of these users separately. + + +- Why it can be triggered: +The function `claimOngoingFixedWithdrawals` is called within `finalizeVaultEndedWithdrawals` when the vault is ending. It attempts to claim all ongoing fixed withdrawals without checking if the Lido withdrawals have been finalized. + +- Potential Impact: +While the bug doesn't lead to loss of funds, it can cause issues with the proper distribution of funds and potentially lock the contract in an unfinished state. Here's a potential flow of events: + +#### PoC Example: + +a) The vault duration ends, and a user calls `finalizeVaultEndedWithdrawals`. +b) Inside this function, `claimOngoingFixedWithdrawals` is called. +c) Let's say there are three fixed users with ongoing withdrawals: + - User A: Withdrawal finalized + - User B: Withdrawal pending + - User C: Withdrawal finalized +d) The function will successfully process User A's withdrawal. +e) When it reaches User B, the `claimFixedVaultOngoingWithdrawal` will fail because the Lido withdrawal isn't finalized. +f) This causes the entire `claimOngoingFixedWithdrawals` function to revert. +g) As a result, the `vaultEndedWithdrawalsFinalized` flag is never set to true. +h) The vault is now stuck in a state where it can't be fully finalized, and some users can't withdraw their funds. + + +#### Impact: + +1. Vault Finalization Failure: The vault cannot be properly finalized if any fixed user's withdrawal is pending when `finalizeVaultEndedWithdrawals` is called. +2. Fund Distribution Issues: Some users might not be able to withdraw their funds as expected. +3. Locked State: The contract could become locked in an unfinished state, requiring manual intervention or a contract upgrade to resolve. diff --git a/006/037.md b/006/037.md new file mode 100644 index 0000000..eea64aa --- /dev/null +++ b/006/037.md @@ -0,0 +1,55 @@ +Rural Fuchsia Starfish + +Medium + +# `LidoVault` Computes Undue `earlyExitFees` + +### Summary + +The `earlyExitFees` are miscalculated when handling an early fixed exit. + +### Root Cause + +In [`calculateFixedEarlyExitFees`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L982C12-L982C39), when trying to compute a share of the `earlyExitFees` for the fixed withdrawal's premium, the `earlyExitFee` is increased by a single basis point: + +```solidity +/// @audit 1e18 down to 0 as duration increases +uint256 remainingProportion = (endTime > timestampRequested ? endTime - timestampRequested : 0).mulDiv( + 1e18, + duration +); + +// Calculate the scaling fee based on the linear factor and earlyExitFeeBps +/// @audit Reduces the early exit fees as duration increases. +/// @audit Increases the worst-case fee by one basis point for every calculation. +uint256 earlyExitFees = upfrontPremium.mulDiv((1 + earlyExitFeeBps).mulDiv(remainingProportion, 1e18), 10000); +``` + +As we can see, instead of scaling the upfrontPremium by a diminishing `earlyExitFeeBps`, the `msg.sender` is charged an additional undue basis point. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. Fixed withdrawal user submits a withdrawal claim whilst `isStarted() && !isEnded()`. + +### Impact + +1. Each early fixed withdrawal is charged at a unanimous increased rate of 0.01% more than intended. + +### PoC + +_No response_ + +### Mitigation + +```diff +- uint256 earlyExitFees = upfrontPremium.mulDiv((1 + earlyExitFeeBps).mulDiv(remainingProportion, 1e18), 10000); ++ uint256 earlyExitFees = upfrontPremium.mulDiv(earlyExitFeeBps.mulDiv(remainingProportion, 1e18), 10000); +``` \ No newline at end of file diff --git a/006/040.md b/006/040.md new file mode 100644 index 0000000..43fb988 --- /dev/null +++ b/006/040.md @@ -0,0 +1,82 @@ +Brief Latte Buffalo + +High + +# Unsafe Handling of Early Exit Fees + +## Summary + +The early exit fee calculation in calculateFixedEarlyExitFees may cause incorrect behavior if the vault hasn't started or if incorrect parameters are passed. There could be cases where users are charged over 100% of their deposit due to faulty fee logic. + +## Vulnerability Detail + +In certain edge cases, such as when timestampRequested is near or equal to startTime, or if there's rounding during the fee calculation, the exit fee could exceed reasonable limits. Users may loss their funds due to unreasonable thing and be responsible for total fees in their whole stake amount. + +```solidity +function calculateFixedEarlyExitFees( + uint256 upfrontPremium, + uint256 timestampRequested + ) internal view returns (uint256) { + uint256 remainingProportion = (endTime > timestampRequested ? endTime - timestampRequested : 0).mulDiv( + 1e18, + duration + ); + + // Calculate the scaling fee based on the linear factor and earlyExitFeeBps + uint256 earlyExitFees = upfrontPremium.mulDiv( (1 + earlyExitFeeBps).mulDiv(remainingProportion, 1e18), 10000); + + // Calculate the amount to be paid back of their original upfront claimed premium, not influenced by quadratic scaling + earlyExitFees += upfrontPremium - upfrontPremium.mulDiv(timestampRequested - startTime, duration); + + return earlyExitFees; + } +``` + +```solidity +function claimFixedVaultOngoingWithdrawal(address user) internal returns (uint256) { + if (user == address(0)) return 0; + + WithdrawalRequest memory request = fixedToVaultOngoingWithdrawalRequestIds[user]; + uint256[] memory requestIds = request.requestIds; + require(requestIds.length != 0, "WNR"); + + uint256 upfrontPremium = userToFixedUpfrontPremium[user]; + + delete userToFixedUpfrontPremium[user]; + delete fixedToVaultOngoingWithdrawalRequestIds[user]; + + // uint256 arrayLength = fixedOngoingWithdrawalUsers.length; + // for (uint i = 0; i < arrayLength; i++) { + // if (fixedOngoingWithdrawalUsers[i] == user) { + // delete fixedOngoingWithdrawalUsers[i]; + // } + // } + + uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); + + uint256 earlyExitFees = calculateFixedEarlyExitFees(upfrontPremium, request.timestamp); + // make sure, that earlyExitFee cant be higher than initial deposit + earlyExitFees = Math.min(earlyExitFees, amountWithdrawn); + + // add earlyExitFees to earnings for variable side + feeEarnings += earlyExitFees; + + emit LidoWithdrawalFinalized(user, requestIds, FIXED, true, isEnded()); + + return amountWithdrawn - earlyExitFees; + } + ``` + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L812 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L982 + +## Tool used + +Manual Review + +## Recommendation +Check again the calculating logic of earlyexitfee and change into flexible and invaluable thing or limit the time for earlyexit. +So we can prevent the unexpected loss of funds. \ No newline at end of file diff --git a/006/044.md b/006/044.md new file mode 100644 index 0000000..f35aba5 --- /dev/null +++ b/006/044.md @@ -0,0 +1,83 @@ +Pet Frost Yak + +Medium + +# `calculateFixedEarlyExitFees` will overcharge fixed-side users + +### Summary + +The incorrect addition of `1` to `earlyExitFeeBps` in the `calculateFixedEarlyExitFees` function will cause an overcharging fee for fixed-side users as the contract misapplies the basis points during early exit fee calculations. + + +### Root Cause + +In [`LidoVault.sol`](https://github.com/saffron-finance/lido-fiv/blob/7246b6651c8affffe17faa4d2984975102a65d81/contracts/LidoVault.sol), the [`calculateFixedEarlyExitFees` function](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L982-L998) adds `1` to `earlyExitFeeBps` when calculating the `earlyExitFees`, leading to an incorrect fee multiplier. + + +### Internal pre-conditions + +1. **Fixed-side user** needs to **initiate an early withdrawal request** by calling the `withdraw` function with `side == FIXED`. +2. The `earlyExitFeeBps` must be **greater than 0**. +3. The `duration` must be **properly set** and greater than **0**. +4. The `timestampRequested` must be **before** the `endTime` to trigger early exit fees. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. **Fixed-side user** calls the `withdraw` function with `side == FIXED` before the vault ends. +2. The `withdraw` function invokes `calculateFixedEarlyExitFees` with the user's `upfrontPremium` and `timestampRequested`. +3. Inside `calculateFixedEarlyExitFees`, the calculation incorrectly adds `1` to `earlyExitFeeBps`: + ```solidity + uint256 earlyExitFees = upfrontPremium.mulDiv( + (1 + earlyExitFeeBps).mulDiv(remainingProportion, 1e18), + 10000 + ); + ``` +4. This results in `earlyExitFees` being **significantly higher** than intended due to the improper multiplier. +5. The user is charged an **excessive fee**, receiving less ETH upon withdrawal. + + +### Impact + +Fixed-side users suffer an **overcharging of early exit fees**, resulting in financial losses. This undermines user trust and the economic integrity of the protocol, potentially leading to a **loss of user funds** and **reputation damage** for the protocol. + +### PoC + +1. **User A** deposits **100 ETH** into the fixed side. +2. **User A** decides to withdraw **50 ETH** early by calling: + ```solidity + vault.withdraw(FIXED); + ``` +3. The `withdraw` function calls `calculateFixedEarlyExitFees` with: + - `upfrontPremium = 100 ETH` + - `timestampRequested = current timestamp` +4. Inside `calculateFixedEarlyExitFees`, the fee is calculated as: + ```solidity + uint256 earlyExitFees = 100 ether.mulDiv( + (1 + 100).mulDiv(remainingProportion, 1e18), // Suppose earlyExitFeeBps = 100 (1%) + 10000 + ); + ``` + - `(1 + 100) = 101` + - `earlyExitFees = 100 ether * (101 * remainingProportion / 1e18) / 10000` + - This results in `earlyExitFees` being **over 1 ETH** instead of the intended **1 ETH** (for `earlyExitFeeBps = 100`). +5. **User A** receives **less ETH** than expected due to the inflated fee. + + +### Mitigation + +Correct the fee calculation by removing the unnecessary addition of `1` to `earlyExitFeeBps`. The `earlyExitFees` should be calculated directly based on `earlyExitFeeBps` without the extra increment. + +```diff +- uint256 earlyExitFees = upfrontPremium.mulDiv( +- (1 + earlyExitFeeBps).mulDiv(remainingProportion, 1e18), +- 10000 +- ); ++ uint256 earlyExitFees = upfrontPremium.mulDiv( ++ earlyExitFeeBps.mulDiv(remainingProportion, 1e18), ++ 10000 ++ ); +``` \ No newline at end of file diff --git a/006/062.md b/006/062.md new file mode 100644 index 0000000..f33170f --- /dev/null +++ b/006/062.md @@ -0,0 +1,64 @@ +Energetic Seafoam Jaguar + +Medium + +# Users will pay 0.01% more early exit fees. + +## Summary +`LidoVault.calculateFixedEarlyExitFees()` function uses `1 + earlyExitFeeBps` instead of `earlyExitFeeBps` in calculation of early exit fees. + +## Vulnerability Detail +`LidoVault.withdraw()` function is following. +```solidity + function calculateFixedEarlyExitFees( + uint256 upfrontPremium, + uint256 timestampRequested + ) internal view returns (uint256) { + uint256 remainingProportion = (endTime > timestampRequested ? endTime - timestampRequested : 0).mulDiv( + 1e18, + duration + ); + + // Calculate the scaling fee based on the linear factor and earlyExitFeeBps +992:uint256 earlyExitFees = upfrontPremium.mulDiv( (1 + earlyExitFeeBps).mulDiv(remainingProportion, 1e18), 10000); + + // Calculate the amount to be paid back of their original upfront claimed premium, not influenced by quadratic scaling + earlyExitFees += upfrontPremium - upfrontPremium.mulDiv(timestampRequested - startTime, duration); + + return earlyExitFees; + } +``` +As can be seen, it uses mistakenly `1 + earlyExitFeeBps` instead of `earlyExitFeeBps` in calculation of early exit fees. + +## Impact +0.01% more early exit fee rate will be applied to users who don't know about it. Users will pay 0.01% more early exit fees. Loss of users funds. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L992 + +## Tool used + +Manual Review + +## Recommendation +Modify `LidoVault.calculateFixedEarlyExitFees()` function as follows. +```solidity + function calculateFixedEarlyExitFees( + uint256 upfrontPremium, + uint256 timestampRequested + ) internal view returns (uint256) { + uint256 remainingProportion = (endTime > timestampRequested ? endTime - timestampRequested : 0).mulDiv( + 1e18, + duration + ); + + // Calculate the scaling fee based on the linear factor and earlyExitFeeBps +-- uint256 earlyExitFees = upfrontPremium.mulDiv( (1 + earlyExitFeeBps).mulDiv(remainingProportion, 1e18), 10000); +++ uint256 earlyExitFees = upfrontPremium.mulDiv( earlyExitFeeBps.mulDiv(remainingProportion, 1e18), 10000); + + // Calculate the amount to be paid back of their original upfront claimed premium, not influenced by quadratic scaling + earlyExitFees += upfrontPremium - upfrontPremium.mulDiv(timestampRequested - startTime, duration); + + return earlyExitFees; + } +``` \ No newline at end of file diff --git a/006/072.md b/006/072.md new file mode 100644 index 0000000..bcdc052 --- /dev/null +++ b/006/072.md @@ -0,0 +1,66 @@ +Mammoth Pink Peacock + +Medium + +# calculateFixedEarlyExitFees will be underflow + +## Summary +If timestampRequested is more than endtime then timestampRequested - startTime will be greater than duration and then calculateFixedEarlyExitFees will be underflow .This will cause other function claimFixedVaultOngoingWithdrawal,claimOngoingFixedWithdrawals, to be reverted. +## Vulnerability Detail + function calculateFixedEarlyExitFees( + uint256 upfrontPremium, + uint256 timestampRequested + ) internal view returns (uint256) { + uint256 remainingProportion = (endTime > timestampRequested ? endTime - timestampRequested : 0).mulDiv( + 1e18, + duration + ); + + // Calculate the scaling fee based on the linear factor and earlyExitFeeBps + uint256 earlyExitFees = upfrontPremium.mulDiv( (1 + earlyExitFeeBps).mulDiv(remainingProportion, 1e18), 10000); + + // Calculate the amount to be paid back of their original upfront claimed premium, not influenced by quadratic scaling + @>> earlyExitFees += upfrontPremium - upfrontPremium.mulDiv(timestampRequested - startTime, duration); + + return earlyExitFees; + } + + + + function claimFixedVaultOngoingWithdrawal(address user) internal returns (uint256) { + if (user == address(0)) return 0; + + WithdrawalRequest memory request = fixedToVaultOngoingWithdrawalRequestIds[user]; + uint256[] memory requestIds = request.requestIds; + require(requestIds.length != 0, "WNR"); + + uint256 upfrontPremium = userToFixedUpfrontPremium[user]; + + delete userToFixedUpfrontPremium[user]; + delete fixedToVaultOngoingWithdrawalRequestIds[user]; + + + + uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); + +@>> uint256 earlyExitFees = calculateFixedEarlyExitFees(upfrontPremium, request.timestamp); + // make sure, that earlyExitFee cant be higher than initial deposit + earlyExitFees = Math.min(earlyExitFees, amountWithdrawn); + + // add earlyExitFees to earnings for variable side + feeEarnings += earlyExitFees; + + emit LidoWithdrawalFinalized(user, requestIds, FIXED, true, isEnded()); + + return amountWithdrawn - earlyExitFees; + } +## Impact + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L995 +## Tool used + +Manual Review + +## Recommendation +check timestampRequested should be less than endtime. \ No newline at end of file diff --git a/006/088.md b/006/088.md new file mode 100644 index 0000000..13ba41e --- /dev/null +++ b/006/088.md @@ -0,0 +1,42 @@ +Pet Porcelain Sheep + +Medium + +# Incorrect `FixedEarlyExitFees` calculation leads to significantly reduced fees + +## Summary +The `calculateFixedEarlyExitFees` function has an error in the percentage calculation for early exit fees, potentially resulting in significantly lower fees than intended. + +## Vulnerability Detail + +In the `calculateFixedEarlyExitFees` function, the early exit fee is calculated using the following formula: + +```js +uint256 earlyExitFees = upfrontPremium.mulDiv((1 + earlyExitFeeBps).mulDiv(remainingProportion, 1e18), 10000); +``` + +The intention is to calculate a yield using the formula `(1 + r)`, where `r` is the rate expressed in basis points. However, since the calculation works in basis points, the "1" in this formula should actually be represented as 10,000 basis points. + + +## Impact + +- Early exit fees will be much lower than intended. For example, if `earlyExitFeeBps` is 500 (5%), the current calculation will use 0,05 instead of 1.05 as the yield factor. +- Users exiting early will pay much lower fees than intended, potentially as low as 1/21th of the intended amount. +- The protocol will lose a significant amount of revenue from early exit fees. +- The reduced exit fees may incentivize more early exits and reduce incentives for depositors that will earn the fees. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L992 + +## Tool used + +Manual Review + +## Recommendation + +Implement the following : + +```js +uint256 earlyExitFees = upfrontPremium.mulDiv((10_000 + earlyExitFeeBps).mulDiv(remainingProportion, 1e18), 10000); +``` \ No newline at end of file diff --git a/006/126.md b/006/126.md new file mode 100644 index 0000000..38fc045 --- /dev/null +++ b/006/126.md @@ -0,0 +1,64 @@ +Crazy Ocean Nightingale + +High + +# Exit fees are not quadratically scalled as they should be which will allow fixed users to withdraw for less fees early on + +### Summary + +The codebase states in several locations that the `earlyExitFeeBps` is quadratically scaled, but it is not. +[VaultFactory](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L29) +> Fixed income early exit fee in basis points (one basis point = 1/100 of 1%) that is quadratically scaled based off of early exit time + +[LidoVault](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L55) +> /// @notice Penalty fee in basis points for fixed side early withdrawals that is quadratically scaled based off of the amount of time that has elapsed since the vault started + +However, if we look at [LidoVault::calculateFixedEarlyExitFees()](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L982), it is the sum of 2 linear functions, one going from `upfrontPremium * earlyExitFeeBps` to 0 and another one from `upfrontPremium` to 0. This results in a linear function going from `upfrontPremium * (1 + earlyExitFeeBps)` to 0, which is not quadratic. + +Thus, users can withdraw earlier with less fees as the quadratic would penalize much more users withdrawing early in the duration of the vault. + +### Root Cause + +In `LidoVault:982`, it does not calculate an early exit fee quadratically. + +### Internal pre-conditions + +None. + +### External pre-conditions + +None. + +### Attack Path + +1. Fixed user does an early withdraw but pays less fees than supposed or more fees if near the end. + +### Impact + +Variable users take losses if the fixed user is withdrawing early in the vault's duration or the fixed user takes a loss if withdrawing late. + +### PoC + +The `LidoVault::calculateFixedEarlyExitFees()` function calculates fees based on a linear relationship with the duration left. +```solidity +function calculateFixedEarlyExitFees(uint256 upfrontPremium, uint256 timestampRequested) + internal + view + returns (uint256) +{ + uint256 remainingProportion = + (endTime > timestampRequested ? endTime - timestampRequested : 0).mulDiv(1e18, duration); + + // Calculate the scaling fee based on the linear factor and earlyExitFeeBps + uint256 earlyExitFees = upfrontPremium.mulDiv((1 + earlyExitFeeBps).mulDiv(remainingProportion, 1e18), 10000); + + // Calculate the amount to be paid back of their original upfront claimed premium, not influenced by quadratic scaling + earlyExitFees += upfrontPremium - upfrontPremium.mulDiv(timestampRequested - startTime, duration); + + return earlyExitFees; +} +``` + +### Mitigation + +Implement the quadratic function as intended. \ No newline at end of file diff --git a/006/170.md b/006/170.md new file mode 100644 index 0000000..13dc742 --- /dev/null +++ b/006/170.md @@ -0,0 +1,67 @@ +Feisty Lipstick Gibbon + +Medium + +# There be a logical error in the early exit fee calculation formula. + +## Summary + +The `calculateFixedEarlyExitFees` function has an issue, primarily due to the incorrect fee calculation formula, which causes the calculated early exit fees to deviate from the expected result. + +## Vulnerability Detail + +1. Basis Points Calculation Error + +The earlyExitFeeBps represents the fee rate in basis points (bps), typically ranging from 0 to 10,000, corresponding to 0% to 100%. In the calculation, the formula uses (1 + earlyExitFeeBps), which can lead to a calculation error. + +Incorrectly adding 1 to basis points: In the formula, (1 + earlyExitFeeBps) adds one unit (1) to a value in basis points, which is mathematically incorrect since the units are inconsistent. +Correct usage of basis points: The basis points value should be divided by 10,000 to obtain the corresponding percentage, which should then be used in the calculation. + +2. The Calculation Logic for Early Exit Fees is Unclear + +The formula for calculating early exit fees in the function is rather complex, which could lead to difficulties in understanding and maintaining the code. Specifically, the fee calculation is divided into two parts, which may result in double counting or omissions. + + In the first part of the fee calculation, the impact of the remaining time proportion on the fees has already been considered. The second part again calculates the unearned premium based on the remaining time, resulting in the same portion of the fees being counted twice. + +When a user exits early, they are required to pay both the unearned upfront premium and the early exit fee based on that unearned premium. However, these two fees are redundantly included in the calculation. + +## Impact + +This results in the user having to pay fees higher than expected, which does not align with a reasonable fee calculation logic. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L992 + +## Tool used + +Manual Review + +## Recommendation +``` solidity +function calculateFixedEarlyExitFees( + uint256 upfrontPremium, + uint256 timestampRequested +) internal view returns (uint256) { + if (timestampRequested >= endTime) { + + return 0; + } + + + uint256 remainingDuration = endTime - timestampRequested; + uint256 remainingProportion = remainingDuration.mulDiv(1e18, duration); + + + uint256 earlyExitFeePercent = earlyExitFeeBps.mulDiv(1e18, 10000); + + + uint256 unearnedPremium = upfrontPremium.mulDiv(remainingDuration, duration); + uint256 earlyExitFee = unearnedPremium.mulDiv(earlyExitFeePercent, 1e18).div(1e18); + + + uint256 totalEarlyExitFees = unearnedPremium + earlyExitFee; + + return totalEarlyExitFees; +} +``` \ No newline at end of file diff --git a/007/108.md b/007/108.md new file mode 100644 index 0000000..6d2cfda --- /dev/null +++ b/007/108.md @@ -0,0 +1,153 @@ +Swift Pine Shrimp + +High + +# LidoVault allows unauthorised actors to access sensitive data via getFixedOngoingWithdrawalRequestTimestamp function + +# Sherlock.xyz Security Audit Findings Report + +### Project: LidoVault Contract +### Date: September 2024 +### Audited by: fat32 + +--- + +### Summary + +I identified access control vulnerabilities that allow unauthorized actors to access sensitive data. Specifically, the lack of access restrictions on the `getFixedOngoingWithdrawalRequestTimestamp()` function could allow an attacker to retrieve information about other users' withdrawal requests. Without proper access control mechanisms, malicious users can exploit this flaw to access confidential information or potentially disrupt vault operations. + +The audit findings detail the severity, impact, proof of concept (PoC), mitigation suggestions, and relevant code changes. + +--- + +## Lack of Access Control on the `getFixedOngoingWithdrawalRequestTimestamp` Function + +### **Severity**: High + +### **Impact** + +The `getFixedOngoingWithdrawalRequestTimestamp()` function is publicly accessible, allowing any external address to query the ongoing withdrawal request timestamp of any user. This is a significant privacy concern, as unauthorized actors could obtain sensitive information about users' interactions with the vault. + +If the system relies on such timestamps to track user interactions or withdrawals, exposing this information could lead to more sophisticated attacks, especially if combined with other vulnerabilities or user behavior patterns. + +### **Vulnerable Code Location** +- Link: [Vulnerable Code on GitHub](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L142-L144) +```solidity + function getFixedOngoingWithdrawalRequestTimestamp(address user) public view returns (uint256) { + return fixedToVaultOngoingWithdrawalRequestIds[user].timestamp; + } +``` + +#### **Proof of Concept (PoC)** + +This PoC demonstrates how an unauthorized attacker can call the function to access a sensitive timestamp associated with another user. +```txt +lido-fiv/test/LidoVaultAccessControl.t.sol +``` +```solidity +// SPDX-License-Identifier: MIT +pragma solidity 0.8.18; + +import {Test, console} from "forge-std/Test.sol"; +import {LidoVault} from "../contracts/LidoVault.sol"; + +import "../contracts/interfaces/ILidoVault.sol"; +import "../contracts/interfaces/ILido.sol"; +import "../contracts/interfaces/ILidoWithdrawalQueueERC721.sol"; +import "../contracts/interfaces/ILidoVaultInitializer.sol"; + + + +contract LidoVaultAccessControlTest is Test { + LidoVault public lidovault; + address public account; + ILido public ilido; + ILidoVault public ilidovault; + ILidoWithdrawalQueueERC721 public ilidowithdrawal; + ILidoVaultInitializer public ilidovaultinitializer; + ILidoVaultInitializer.InitializationParams public initializationparams; + + // Mock Lido contract + function setUp() public { + account = address(this); + uint256 newBalance = 100 ether; + lidovault = new LidoVault(true); // Initializing LidoVault with a true parameter + ilido = ILido(address(this)); // Deploying a mock Lido contract + vm.deal(account, newBalance); // Deal ether to the test account + } + + function testAccessController() external { + uint256 amount = 1000; + address attacker = address(0xbEEF); + address user = address(1157920892373111111111111111111111111111111111111); + vm.startPrank(attacker); + //lidovault.initialize(ILidoVaultInitializer.InitializationParams({ vaultId: type(uint256).max, duration: type(uint256).max, fixedSideCapacity: type(uint256).max, variableSideCapacity: type(uint256).max, earlyExitFeeBps: type(uint256).max, protocolFeeBps: type(uint256).max, protocolFeeReceiver: address(0xbEEF) })); + lidovault.getFixedOngoingWithdrawalRequestTimestamp(account); + vm.stopPrank(); + } +} +``` + +#### **Foundry Test Log Results** +```text +forge test -vvvv --match-contract LidoVaultAccessControl +[⠊] Compiling... +[⠊] Compiling 1 files with Solc 0.8.18 +[⠒] Solc 0.8.18 finished in 977.25ms +... +Ran 1 test for test/LidoVaultAccessControl.t.sol:LidoVaultAccessControlTest +[PASS] testAccessController() (gas: 13096) +Traces: + [13096] LidoVaultAccessControlTest::testAccessController() + ├─ [0] VM::startPrank(0x000000000000000000000000000000000000bEEF) + │ └─ ← [Return] + ├─ [2598] LidoVault::getFixedOngoingWithdrawalRequestTimestamp(LidoVaultAccessControlTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall] + │ └─ ← [Return] 0 + ├─ [0] VM::stopPrank() + │ └─ ← [Return] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.57ms (303.33µs CPU time) + +Ran 1 test suite in 122.50ms (1.57ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) +``` + +The test simulates an unauthorized user (address `0xBEEF`) invoking the `getFixedOngoingWithdrawalRequestTimestamp()` function to query sensitive information from the vault. The test passes, demonstrating that the function can be called without any restrictions. + +--- + +### **Mitigation** + +To prevent unauthorized access, restrict access to this function by implementing access control mechanisms such as `onlyDepositor` or `onlyOwner`. These restrictions will ensure that only users who have made deposits or other legitimate stakeholders can access this information. + +Here’s a Solidity-based solution using the `onlyDepositor` modifier to restrict access: + +```solidity +// Access Control Modifier +modifier onlyDepositor() { + require(fixedETHDepositToken[msg.sender] > 0 || variableBearerToken[msg.sender] > 0, "Not a depositor"); + _; +} + +function getFixedOngoingWithdrawalRequestTimestamp(address user) public view onlyDepositor returns (uint256) { + return fixedToVaultOngoingWithdrawalRequestIds[user].timestamp; +} +``` + +In this mitigation: + +- **`onlyDepositor` modifier**: Restricts function access to users who have made deposits. A depositor is someone who holds either `fixedETHDepositToken` or `variableBearerToken`. +- **Updated function**: The `getFixedOngoingWithdrawalRequestTimestamp()` function can now only be accessed by depositors, ensuring that attackers cannot query another user’s sensitive data. + +--- + +### Conclusion + +#### Summary of Findings: +1. **Lack of Access Control on Sensitive Functions**: Unauthorized users can access sensitive data via the `getFixedOngoingWithdrawalRequestTimestamp()` function. + - **Severity**: High + - **Type**: Access Control + - **Mitigation**: Introduce an `onlyDepositor` modifier to restrict access to depositors only. + +By implementing the suggested access control mechanism, the `LidoVault` contract will be protected against unauthorized access to sensitive information, improving the overall security of the vault system. + diff --git a/007/115.md b/007/115.md new file mode 100644 index 0000000..7603652 --- /dev/null +++ b/007/115.md @@ -0,0 +1,147 @@ +Swift Pine Shrimp + +High + +# LidoVault contract has Lack of Access Control on the getFixedOngoingWithdrawalRequestIds Function + +# Sherlock.xyz Security Audit Findings Report + +### Project: LidoVault Contract +### Date: September 2024 +### Audited by: fat32 + +--- + +### Summary + +During the audit of the `LidoVault` smart contract, a critical access control vulnerability was identified. The `getFixedOngoingWithdrawalRequestIds()` function is publicly accessible and lacks access control, allowing any external user to retrieve the withdrawal request IDs of any user. This exposes sensitive data and opens the possibility for malicious actors to obtain private information about other users' vault interactions, which could lead to more targeted or sophisticated attacks. + +This audit report presents the vulnerability, a proof of concept (PoC) to demonstrate the issue, suggested mitigations, and improvements. + +--- + +## Lack of Access Control on the `getFixedOngoingWithdrawalRequestIds` Function + +### **Severity**: High + +### **Impact** + +The `getFixedOngoingWithdrawalRequestIds()` function is vulnerable due to the absence of access control, enabling any external user to query the ongoing withdrawal request IDs for any user in the vault. This can lead to sensitive data exposure, including revealing other users' withdrawal patterns. An attacker can gather private information about vault users, which may expose them to more sophisticated attacks such as phishing or front-running based on known withdrawal patterns. + +### **Vulnerable Code Location** +- **URL**: [Vulnerable Code on GitHub](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L146-L148) +```solidity + function getFixedOngoingWithdrawalRequestIds(address user) public view returns (uint256[] memory) { + return fixedToVaultOngoingWithdrawalRequestIds[user].requestIds; + } +``` + +### **Proof of Concept (PoC)** + +This PoC demonstrates how an unauthorized user can call the `getFixedOngoingWithdrawalRequestIds()` function to retrieve the withdrawal request IDs of another user without permission. + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity 0.8.18; + +import {Test, console} from "forge-std/Test.sol"; +import {LidoVault} from "../contracts/LidoVault.sol"; + +import "../contracts/interfaces/ILidoVault.sol"; +import "../contracts/interfaces/ILido.sol"; +import "../contracts/interfaces/ILidoWithdrawalQueueERC721.sol"; +import "../contracts/interfaces/ILidoVaultInitializer.sol"; + + + +contract LidoVaultAccessControlTest is Test { + LidoVault public lidovault; + address public account; + ILido public ilido; + ILidoVault public ilidovault; + ILidoWithdrawalQueueERC721 public ilidowithdrawal; + ILidoVaultInitializer public ilidovaultinitializer; + ILidoVaultInitializer.InitializationParams public initializationparams; + + // Mock Lido contract + function setUp() public { + account = address(this); + uint256 newBalance = 100 ether; + lidovault = new LidoVault(true); // Initializing LidoVault with a true parameter + ilido = ILido(address(this)); // Deploying a mock Lido contract + vm.deal(account, newBalance); // Deal ether to the test account + } + + function testAccessController() external { + uint256 amount = 1000; + address attacker = address(0xbEEF); + address user = address(1157920892373111111111111111111111111111111111111); + vm.startPrank(attacker); + //lidovault.initialize(ILidoVaultInitializer.InitializationParams({ vaultId: type(uint256).max, duration: type(uint256).max, fixedSideCapacity: type(uint256).max, variableSideCapacity: type(uint256).max, earlyExitFeeBps: type(uint256).max, protocolFeeBps: type(uint256).max, protocolFeeReceiver: address(0xbEEF) })); + lidovault.getFixedOngoingWithdrawalRequestIds(account); + vm.stopPrank(); + } +} +``` + +### **Foundry Log Results** +```txt +lido-fiv % forge test -vvvv --match-contract LidoVaultAccessControl +[⠊] Compiling... +... +Ran 1 test for test/LidoVaultAccessControl.t.sol:LidoVaultAccessControlTest +[PASS] testAccessController() (gas: 13817) +Traces: + [13817] LidoVaultAccessControlTest::testAccessController() + ├─ [0] VM::startPrank(0x000000000000000000000000000000000000bEEF) + │ └─ ← [Return] + ├─ [2987] LidoVault::getFixedOngoingWithdrawalRequestIds(LidoVaultAccessControlTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall] + │ └─ ← [Return] [] + ├─ [0] VM::stopPrank() + │ └─ ← [Return] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.46ms (301.46µs CPU time) + +Ran 1 test suite in 121.25ms (1.46ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) +``` + +This Foundry test logs confirm that the function can be called successfully by an unauthorized address (`0xBEEF`), indicating a lack of access control on the `getFixedOngoingWithdrawalRequestIds()` function. The test passes, meaning that unauthorized users can retrieve sensitive information from the vault, potentially leading to exploitation. + +--- + +### **Mitigation** + +To prevent unauthorized access, restrict access to the `getFixedOngoingWithdrawalRequestIds()` function by implementing an access control mechanism such as `onlyDepositor` or `onlyOwner`. This will ensure that only users with an active deposit or the contract owner can call the function. + +Here’s a mitigation using a `modifier` to restrict access to legitimate depositors: + +```solidity +// Access Control Modifier +modifier onlyDepositor() { + require(fixedETHDepositToken[msg.sender] > 0 || variableBearerToken[msg.sender] > 0, "Not a depositor"); + _; +} + +function getFixedOngoingWithdrawalRequestIds(address user) public view onlyDepositor returns (uint256[] memory) { + return fixedToVaultOngoingWithdrawalRequestIds[user].requestIds; +} +``` + +### **Explanation:** +- **`onlyDepositor` modifier**: Ensures that only users who have made a deposit (either to the fixed or variable side) can access sensitive information like ongoing withdrawal request IDs. +- **Updated function**: The `getFixedOngoingWithdrawalRequestIds()` function now includes the `onlyDepositor` modifier to restrict access to depositors only, preventing unauthorized users from querying sensitive data. + +This solution ensures that only legitimate users with deposits can access their withdrawal request IDs, preventing malicious actors from exploiting the function. + +--- + +### Conclusion + +#### Summary of Findings: +1. **Lack of Access Control on Sensitive Functions**: Unauthorized users can access sensitive information, such as ongoing withdrawal request IDs, through the `getFixedOngoingWithdrawalRequestIds()` function. + - **Severity**: High + - **Type**: Access Control Vulnerability + - **Mitigation**: Implement access control using the `onlyDepositor` modifier to restrict access to depositors. + +By implementing the recommended access control mechanisms, the `LidoVault` contract will be more secure against unauthorized access to sensitive user information and will safeguard user data integrity. \ No newline at end of file diff --git a/007/154.md b/007/154.md new file mode 100644 index 0000000..19350d4 --- /dev/null +++ b/007/154.md @@ -0,0 +1,130 @@ +Swift Pine Shrimp + +Medium + +# LidoVault contract allows any user to access the withdrawal request IDs of any other user on getFixedToVaultNotStartedWithdrawalRequestIds + +### Security Audit Report for Saffron Finance Lido Vault Contract + +### Auditor: fat32 + +#### Vulnerability Summary: +The function `getFixedToVaultNotStartedWithdrawalRequestIds(address user)` allows any user to access the withdrawal request IDs of any other user. This introduces a potential privacy breach since an attacker can access sensitive withdrawal-related data for other users without permission. This issue is particularly concerning in decentralized finance (DeFi) environments, where such information should remain confidential and only accessible to authorized parties. + +#### Vulnerability Details: +- **Impact**: Medium + - **Privacy Breach**: Any user can call this function to view another user's withdrawal request IDs, which could leak sensitive transaction information or facilitate front-running strategies. + - **Exploit Scenario**: An attacker can invoke this function and retrieve details of any user's pending withdrawal requests, gaining unauthorized visibility into user behavior. + +#### Severity: **Medium** + +#### Issue Type: **Access Control / Privacy Issue** + +#### Vulnerable Code: +- **File**: [LidoVault.sol](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L150-L152) +- **Location**: Line 150-152 + +```solidity +function getFixedToVaultNotStartedWithdrawalRequestIds(address user) public view returns (uint256[] memory) { + return fixedToVaultNotStartedWithdrawalRequestIds[user]; +} +``` + +#### Proof of Concept (PoC) Test: +The following Foundry PoC test demonstrates how an unauthorized user can access withdrawal request IDs of another user: + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity 0.8.18; + +import {Test, console} from "forge-std/Test.sol"; +import {LidoVault} from "../contracts/LidoVault.sol"; + +import "../contracts/interfaces/ILidoVault.sol"; +import "../contracts/interfaces/ILido.sol"; +import "../contracts/interfaces/ILidoWithdrawalQueueERC721.sol"; +import "../contracts/interfaces/ILidoVaultInitializer.sol"; + + + +contract LidoVaultAccessControlTest is Test { + LidoVault public lidovault; + address public account; + ILido public ilido; + ILidoVault public ilidovault; + ILidoWithdrawalQueueERC721 public ilidowithdrawal; + ILidoVaultInitializer public ilidovaultinitializer; + ILidoVaultInitializer.InitializationParams public initializationparams; + + // Mock Lido contract + function setUp() public { + account = address(this); + uint256 newBalance = 100 ether; + lidovault = new LidoVault(true); // Initializing LidoVault with a true parameter + ilido = ILido(address(this)); // Deploying a mock Lido contract + vm.deal(account, newBalance); // Deal ether to the test account + } + + function testAccessController() external { + uint256 amount = 1000; + address attacker = address(0xbEEF); + address user = address(1157920892373111111111111111111111111111111111111); + vm.startPrank(attacker); + //lidovault.initialize(ILidoVaultInitializer.InitializationParams({ vaultId: type(uint256).max, duration: type(uint256).max, fixedSideCapacity: type(uint256).max, variableSideCapacity: type(uint256).max, earlyExitFeeBps: type(uint256).max, protocolFeeBps: type(uint256).max, protocolFeeReceiver: address(0xbEEF) })); + lidovault.getFixedToVaultNotStartedWithdrawalRequestIds(account); + vm.stopPrank(); + } +} +``` + +#### Foundry Log Results: +The test shows that an unauthorized address (attacker) can access the withdrawal request IDs of another user without any restriction. The test passed, indicating that no access control checks are implemented. + +```txt +lido-fiv % forge test -vvvvv --match-contract LidoVaultAccessControl +... +Ran 1 test for test/LidoVaultAccessControl.t.sol:LidoVaultAccessControlTest +[PASS] testAccessController() (gas: 13768) +Traces: + [3450702] LidoVaultAccessControlTest::setUp() + ├─ [3362361] → new LidoVault@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 16670 bytes of code + ├─ [0] VM::deal(LidoVaultAccessControlTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 100000000000000000000 [1e20]) + │ └─ ← [Return] + └─ ← [Stop] + + [13768] LidoVaultAccessControlTest::testAccessController() + ├─ [0] VM::startPrank(0x000000000000000000000000000000000000bEEF) + │ └─ ← [Return] + ├─ [2938] LidoVault::getFixedToVaultNotStartedWithdrawalRequestIds(LidoVaultAccessControlTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall] + │ └─ ← [Return] [] + ├─ [0] VM::stopPrank() + │ └─ ← [Return] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 639.50µs (81.13µs CPU time) + +Ran 1 test suite in 606.73ms (639.50µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) +``` + +#### Recommended Mitigation: +Restrict access to the `getFixedToVaultNotStartedWithdrawalRequestIds` function so that only the user who owns the withdrawal requests can view them. Implement an access control check to ensure that only authorized addresses (e.g., the user themselves) can access their own data. + +**Solidity Mitigation Code**: +```solidity +function getFixedToVaultNotStartedWithdrawalRequestIds(address user) public view returns (uint256[] memory) { + require(msg.sender == user, "Access denied: Unauthorized access to withdrawal request IDs"); + return fixedToVaultNotStartedWithdrawalRequestIds[user]; +} +``` + +#### Steps to Mitigate: +1. **Add access control**: Introduce a `require` check to ensure only the owner of the withdrawal requests can access their own data. +2. **Re-deploy the contract**: Once the access control is added, the contract needs to be redeployed to the network. +3. **Test thoroughly**: Ensure that the fix is effective by rerunning the Foundry tests with both valid and invalid users attempting to access the function. + +#### Severity Justification: +The severity is rated as **Medium** because the vulnerability allows an attacker to access sensitive user information, but it doesn't directly result in loss of funds. However, the leaked information can still be used to gain competitive advantage or track user behavior, which is significant in financial applications. + +#### Conclusion: +The current implementation of `getFixedToVaultNotStartedWithdrawalRequestIds` lacks access control, resulting in a privacy breach. Applying the mitigation to restrict access to this function only to authorized users will resolve the issue and protect user data from unauthorized access. \ No newline at end of file diff --git a/007/163.md b/007/163.md new file mode 100644 index 0000000..3259650 --- /dev/null +++ b/007/163.md @@ -0,0 +1,139 @@ +Swift Pine Shrimp + +Medium + +# LidoVault contract function named getVariableToVaultOngoingWithdrawalRequestIds exposes sensitive information + +### Security Audit Findings Report + +**Auditor**: Fat32 +**Severity**: Medium +**Issue Type**: Access Control Violation +**Location**: [LidoVault.sol#L154-L156](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L154-L156) + +--- + +### Vulnerability Summary: +The function `getVariableToVaultOngoingWithdrawalRequestIds()` exposes sensitive information by allowing any external caller to access ongoing withdrawal request IDs of any user. This allows a malicious actor to enumerate all users’ ongoing withdrawal request information, which may expose sensitive transaction data and open attack vectors. + +--- + +### Impact: +This vulnerability poses an **information disclosure** risk, as any external account can view internal details of another user's vault activity. Specifically, withdrawal request IDs may be exploited for front-running attacks or to gather insights into vault activity that should remain private. + +--- + +### Exploit Scenario: +1. **Attacker's Actions**: + - An attacker could call the `getVariableToVaultOngoingWithdrawalRequestIds()` function and supply another user's address. + - The attacker retrieves that user's ongoing withdrawal request IDs. + +2. **Potential Attack Vector**: + - The attacker can use this information to gather insights into when large withdrawals are happening and potentially initiate front-running transactions or other malicious actions based on the observed withdrawal patterns. + +--- + +### Proof of Concept (PoC): + +**Foundry Test**: + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity 0.8.18; + +import {Test, console} from "forge-std/Test.sol"; +import {LidoVault} from "../contracts/LidoVault.sol"; + +import "../contracts/interfaces/ILidoVault.sol"; +import "../contracts/interfaces/ILido.sol"; +import "../contracts/interfaces/ILidoWithdrawalQueueERC721.sol"; +import "../contracts/interfaces/ILidoVaultInitializer.sol"; + + + +contract LidoVaultAccessControlTest is Test { + LidoVault public lidovault; + address public account; + ILido public ilido; + ILidoVault public ilidovault; + ILidoWithdrawalQueueERC721 public ilidowithdrawal; + ILidoVaultInitializer public ilidovaultinitializer; + ILidoVaultInitializer.InitializationParams public initializationparams; + + // Mock Lido contract + function setUp() public { + account = address(this); + uint256 newBalance = 100 ether; + lidovault = new LidoVault(true); // Initializing LidoVault with a true parameter + ilido = ILido(address(this)); // Deploying a mock Lido contract + vm.deal(account, newBalance); // Deal ether to the test account + } + + function testAccessController() external { + uint256 amount = 1000; + address attacker = address(0xbEEF); + address user = address(1157920892373111111111111111111111111111111111111); + vm.startPrank(attacker); + //lidovault.initialize(ILidoVaultInitializer.InitializationParams({ vaultId: type(uint256).max, duration: type(uint256).max, fixedSideCapacity: type(uint256).max, variableSideCapacity: type(uint256).max, earlyExitFeeBps: type(uint256).max, protocolFeeBps: type(uint256).max, protocolFeeReceiver: address(0xbEEF) })); + lidovault.getVariableToVaultOngoingWithdrawalRequestIds(account); + vm.stopPrank(); + } +} +``` + +**Foundry Log Results**: + +```txt +lido-fiv % forge test -vvvvv --match-contract LidoVaultAccessControl +... +Ran 1 test for test/LidoVaultAccessControl.t.sol:LidoVaultAccessControlTest +[PASS] testAccessController() (gas: 13789) +Traces: + [3450702] LidoVaultAccessControlTest::setUp() + ├─ [3362361] → new LidoVault@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 16670 bytes of code + ├─ [0] VM::deal(LidoVaultAccessControlTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 100000000000000000000 [1e20]) + │ └─ ← [Return] + └─ ← [Stop] + + [13789] LidoVaultAccessControlTest::testAccessController() + ├─ [0] VM::startPrank(0x000000000000000000000000000000000000bEEF) + │ └─ ← [Return] + ├─ [2959] LidoVault::getVariableToVaultOngoingWithdrawalRequestIds(LidoVaultAccessControlTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall] + │ └─ ← [Return] [] + ├─ [0] VM::stopPrank() + │ └─ ← [Return] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 11.87ms (1.56ms CPU time) + +Ran 1 test suite in 616.23ms (11.87ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) +``` + +The log confirms that an external attacker (`0xbEEF`) was able to call the `getVariableToVaultOngoingWithdrawalRequestIds` function successfully, revealing private withdrawal request information. + +--- + +### Recommended Mitigation: + +To mitigate this issue, access control should be implemented to restrict this function, allowing only the user (or other authorized entities, such as the protocol owner) to retrieve their own ongoing withdrawal request information. + +**Solidity Mitigation**: + +```solidity +function getVariableToVaultOngoingWithdrawalRequestIds(address user) public view returns (uint256[] memory) { + require(msg.sender == user, "Unauthorized: Cannot access another user's withdrawal requests"); + return variableToVaultOngoingWithdrawalRequestIds[user]; +} +``` + +This change ensures that only the user who owns the withdrawal request IDs can access this sensitive information. + +--- + +### Conclusion: + +- **Severity**: Medium +- **Issue Type**: Access Control Violation +- **Impact**: Unrestricted access to sensitive user withdrawal information can lead to information leakage and potential attacks. +- **Mitigation**: Restrict access by ensuring only the user can access their own withdrawal request IDs. diff --git a/008.md b/008.md new file mode 100644 index 0000000..729fffb --- /dev/null +++ b/008.md @@ -0,0 +1,74 @@ +Oblong Chiffon Mole + +Medium + +# Inadequate Validation in Lido Withdrawal Processing + +## Summary +The `_requestWithdraw` and `_claimWithdrawals` functions in the `LidoVault` contract lack sufficient validation checks, which can lead to failed withdrawal requests and unclaimed funds. + +## Vulnerability Detail +1. `_requestWithdraw` +Issue: Insufficient validation of withdrawal amounts. +```solidity +1137: function _requestWithdraw(address user, uint256 stETHAmount) internal returns (uint256[] memory) { +1138: unlockReceive = true; +1139: require(stETHAmount >= MIN_STETH_WITHDRAWAL_AMOUNT, "WM"); +--- +1142: bool approved = lido.approve(address(lidoWithdrawalQueue), stETHAmount); +1143: require(approved, "AF"); +--- +1145: uint256[] memory amounts = calculateWithdrawals(stETHAmount); +--- +1148: uint256[] memory requestIds = lidoWithdrawalQueue.requestWithdrawals(amounts, address(this)); +1149: require(requestIds.length > 0, "IWR"); +--- +1151: emit WithdrawalRequested(stETHAmount, requestIds, user); +--- +1153: unlockReceive = false; +1154: return requestIds; +1155: } +``` +The function does not validate if the withdrawal amount exceeds the maximum allowed per request or if the user has sufficient stETH balance. This can lead to failed requests. + +2. `_claimWithdrawals` +Issue: Lack of validation to ensure all withdrawal requests are processed correctly. +```solidity +1168: function _claimWithdrawals(address user, uint256[] memory requestIds) internal returns (uint256) { +1169: unlockReceive = true; +1170: uint256 beforeBalance = address(this).balance; +--- +1174: for (uint i = 0; i < requestIds.length; i++) { +1175: lidoWithdrawalQueue.claimWithdrawal(requestIds[i]); +1176: } +--- +1178: uint256 withdrawnAmount = address(this).balance - beforeBalance; +1179: require(withdrawnAmount > 0, "IWA"); +--- +1181: emit WithdrawalClaimed(withdrawnAmount, requestIds, user); +--- +1183: unlockReceive = false; +1184: return withdrawnAmount; +1185: } +``` +The function assumes all requests are successfully processed without validating each claim's success. If a claim fails, it may not be detected, leading to unclaimed funds. + +## Impact +Without proper validation, funds may remain unclaimed, causing discrepancies in expected vs. actual balances. + +## Code Snippet +- https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1137-L1155 +- https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1168-L1185 + +## Tool used + +Manual Review + +## Recommendation +`_requestWithdraw` +- Validate both minimum and maximum withdrawal amounts. +- Check the user's stETH balance before processing the request to ensure they have sufficient funds. + +`_claimWithdrawals` +- Add checks to ensure each withdrawal claim is successful. +- Implement retry mechanisms or logging for failed claims to track and address issues. \ No newline at end of file diff --git a/008/005.md b/008/005.md new file mode 100644 index 0000000..43576a4 --- /dev/null +++ b/008/005.md @@ -0,0 +1,91 @@ +Oblong Chiffon Mole + +High + +# Repeated Premium Claim Exploit in LidoVault + +## Summary +The `LidoVault` contract's `claimFixedPremium` function lacks proper validation to ensure that a user can only claim their fixed side premium once. This allows malicious users to repeatedly claim the premium, leading to unfair distribution and potential financial losses for the vault. + +## Vulnerability Detail +The vulnerability arises from the absence of checks to prevent multiple claims of the fixed side premium by the same user. +```solidity +395: function claimFixedPremium() external { +396:@=> require(isStarted(), "CBS"); +--- +399:@=> uint256 claimBal = fixedClaimToken[msg.sender]; +400:@=> require(claimBal > 0, "NCT"); +--- +403: uint256 sendAmount = claimBal.mulDiv(variableSideCapacity, fixedIntialTokenTotalSupply); +--- +406: userToFixedUpfrontPremium[msg.sender] = sendAmount; +--- +409: fixedBearerToken[msg.sender] += claimBal; +410: fixedBearerTokenTotalSupply += claimBal; +--- +413:@=> fixedClaimToken[msg.sender] = 0; +414:@=> fixedClaimTokenTotalSupply -= claimBal; +--- +416: (bool sent, ) = msg.sender.call{value: sendAmount}(""); +417: require(sent, "ETF"); +418: emit FixedPremiumClaimed(sendAmount, claimBal, msg.sender); +419: } +``` +```solidity +require(isStarted(), "CBS"); + +uint256 claimBal = fixedClaimToken[msg.sender]; +require(claimBal > 0, "NCT"); +``` +The function checks if the vault has started and if the user has a claim balance, but it does not check if the user has already claimed their premium. +```solidity +fixedClaimToken[msg.sender] = 0; +fixedClaimTokenTotalSupply -= claimBal; +``` +The claim tokens are burned after claiming, but this does not prevent the function from being called again before the tokens are burned. + +Exploit by Malicious Users: +A malicious user could repeatedly call `claimFixedPremium` before the claim tokens are effectively burned or the state is updated, allowing them to claim the premium multiple times. + +## Impact +- The vault could suffer significant financial losses as malicious users drain the premium pool. +- Honest users may receive less than their fair share of the premium. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L395-L419 + +## Tool used + +Manual Review + +## Recommendation +Implement a mechanism to track whether a user has already claimed their premium and prevent multiple claims. +```diff + // Add a mapping to track if a user has claimed their premium ++ mapping(address => bool) public hasClaimedPremium; + +function claimFixedPremium() external { + require(isStarted(), "CBS"); ++ require(!hasClaimedPremium[msg.sender], "Already claimed"); + + uint256 claimBal = fixedClaimToken[msg.sender]; + require(claimBal > 0, "NCT"); + + uint256 sendAmount = claimBal.mulDiv(variableSideCapacity, fixedIntialTokenTotalSupply); + + userToFixedUpfrontPremium[msg.sender] = sendAmount; + + fixedBearerToken[msg.sender] += claimBal; + fixedBearerTokenTotalSupply += claimBal; + + fixedClaimToken[msg.sender] = 0; + fixedClaimTokenTotalSupply -= claimBal; + + // Mark the premium as claimed ++ hasClaimedPremium[msg.sender] = true; + + (bool sent, ) = msg.sender.call{value: sendAmount}(""); + require(sent, "ETF"); + emit FixedPremiumClaimed(sendAmount, claimBal, msg.sender); +} +``` \ No newline at end of file diff --git a/008/039.md b/008/039.md new file mode 100644 index 0000000..47499dd --- /dev/null +++ b/008/039.md @@ -0,0 +1,43 @@ +Early Wool Pike + +High + +# incorrect handling of claimFixedPremium + +High + +Summary +The claimFixedPremium() function in the LidoVault contract calculates a premium based on variable side deposits but doesn't actually transfer funds from this specific source, potentially leading to mismanagement of funds and contract insolvency. +[github.com//<2024-08-saffron-finance-YavorJJ>/blob//LidoVault.sol?plain=1#L403](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L403) + +Vulnerability Detail +The function calculates sendAmount based on variable side capacity, implying it should be paid from variable side deposits. However, the actual transfer is made from the contract's general balance without explicitly sourcing from variable side funds. + +Impact +The contract may pay out premiums using funds not intended for this purpose. +This could lead to a mismatch between expected and actual balances, potentially causing different balances which breaks the functionality of the protocol. + +Code Snippet +```solidity +function claimFixedPremium() external { + // ... [previous code omitted for brevity] + + uint256 sendAmount = claimBal.mulDiv(variableSideCapacity, fixedIntialTokenTotalSupply); + + // ... [middle code omitted for brevity] + + (bool sent, ) = msg.sender.call{value: sendAmount}(""); + require(sent, "ETF"); + emit FixedPremiumClaimed(sendAmount, claimBal, msg.sender); +} +``` + +Tool used +Manual Review + +Recommendation +There could be many fixes, one is to implement a new dedicated variable to track variable side deposits: +```solidity +uint256 public availableVariableDeposits; +``` +Another is to be sure the sendAmount is coming always from total variable side deposits. \ No newline at end of file diff --git a/008/086.md b/008/086.md new file mode 100644 index 0000000..78ff8d5 --- /dev/null +++ b/008/086.md @@ -0,0 +1,40 @@ +Radiant Arctic Lemur + +High + +# Fixed participants can claim more premium than expected + +## Vulnerability Detail + +`claimFixedPremium()` is called to allow fixed participants to claim their fixed premium when vault is started. And `sendAmount`, the premium amount to be claimed, is determined by the participant's share of `variableSideCapacity`, using their balance of `fixedClaimTokens` over`fixedIntialTokenTotalSupply`. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L395 +```javascript + // Check and cache balance for gas savings + uint256 claimBal = fixedClaimToken[msg.sender]; + require(claimBal > 0, "NCT"); + + // Send a proportional share of the total variable side deposits (premium) to the fixed side depositor + uint256 sendAmount = claimBal.mulDiv(variableSideCapacity, fixedIntialTokenTotalSupply); +``` +However `fixedClaimTokenTotalSupply` is the fixed bearer tokens and the variable side premium payment both.https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L77 + +it will be reduced when fixed participants performs on-going vault withdrawals due to the fact that the participant's balance of fixedBearerToken will be burned. + +This will cause an issue with the `sendAmount` calculation within `claimFixedPremium()`, giving a higher than expected share of the `variableSideCapacity` after vault withdrawal by fixed participants. This allows fixed participants to claim more premium than allowed by doing it after certain amount of withdrawals. + +## Impact +Fixed participants will claim more premium than expected. + +## Tool used +Manual Review + +## Recommendation + +In claimFixedPremium(), use `fixedSideCapacity` instead of `fixedIntialTokenTotalSupply` as follows: + +```diff + +- uint256 sendAmount = claimBal.mulDiv(variableSideCapacity, fixedIntialTokenTotalSupply); ++ uint256 sendAmount = claimBal.mulDiv(variableSideCapacity, fixedSideCapacity); +``` \ No newline at end of file diff --git a/009.md b/009.md new file mode 100644 index 0000000..804d34f --- /dev/null +++ b/009.md @@ -0,0 +1,47 @@ +Dapper Ginger Cyborg + +Medium + +# Unfair share distribution + +### Summary + +In the `deposit` function ([`LidoVault:328`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L328-L391)), the share distribution is unfair. The share is equivalent to the stEth deposit, but it does not count the yield generated by the user that deposit before. + +### Root Cause + +In [`LidoVault:359`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L359), shares are allocated to the user, with the amount being directly proportional to the stETH deposited. + +Consider two users, A and B. User A deposits an amount `A` into the LidoVault several weeks before user B. + +User A’s deposit has already generated a yield, `yieldA`. When user B deposits the same amount `A` into the vault, they receive an equal number of shares as user A. However, user B should receive fewer shares because their deposit represents a smaller portion of the vault's total assets, given the yield already accrued by user A’s earlier deposit. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. A user call the deposit function +2. A second user call the deposit function a bit after. The longer it is after, the less fair it is. + +### Impact + +The stETH depositor will be rewarded less than they should be. Moreover, users are then encouraged to wait the last moment to deposit. + +### PoC + +_No response_ + +### Mitigation + +I recommend to compute shares by this way: + +```solidity +newShares = shares/stakingBalance() +``` +Note that we can add decimals to this to increase precision. \ No newline at end of file diff --git a/009/001.md b/009/001.md new file mode 100644 index 0000000..29e8b33 --- /dev/null +++ b/009/001.md @@ -0,0 +1,50 @@ +Brisk Dijon Moth + +High + +# Ownable is not initialized in `VaultFactory.sol` + +## Summary +The "Ownable" that is inherited by openzeppelin is not initialized. +## Vulnerability Detail +When a contract inherits the "Ownable" by openzeppelin the owner should be passed as an argument in the constructor as we see here: +```solidity + + /** + * @dev Initializes the contract setting the address provided by the deployer as the initial owner. + */ + constructor(address initialOwner) { + if (initialOwner == address(0)) { + revert OwnableInvalidOwner(address(0)); + } + _transferOwnership(initialOwner); + } +``` +https://github.com/OpenZeppelin/openzeppelin-contracts/blob/6e224307b44bc4bd0cb60d408844e028cfa3e485/contracts/access/Ownable.sol#L38 + +However in the "VaultFactory.sol" the `Ownable.sol` is not initialized as we can see in the constructor: +```solidity +constructor(uint256 _protocolFeeBps, uint256 _earlyExitFeeBps) { + require(_protocolFeeBps < 10_000, "IPB"); + protocolFeeReceiver = msg.sender; + protocolFeeBps = _protocolFeeBps; + earlyExitFeeBps = _earlyExitFeeBps; + vaultContract = address(new LidoVault(true)); + emit VaultCodeSet(msg.sender); + } +``` +## Impact +The owner will not be set leading to 'onlyOwner` modifier not working +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/f09cf4ce42044a0f22d49e763f76fabe47bd8fe8/lido-fiv/contracts/VaultFactory.sol#L81 +## Tool used + +Manual Review + +## Recommendation +Refactor the constructor +```solidity +constructor(uint256 _protocolFeeBps, uint256 _earlyExitFeeBps) Ownable(msg.sender) { + // Rest of your initialization +} +``` \ No newline at end of file diff --git a/009/032.md b/009/032.md new file mode 100644 index 0000000..51e90a6 --- /dev/null +++ b/009/032.md @@ -0,0 +1,30 @@ +Big Viridian Lynx + +High + +# VaultFactory owner uninitialized leaving all `onlyOwner` setters inaccessible + +## Summary +`Ownable` constructor is never called during VaultFactory constructor, leaving `onlyOwner` setter functions inaccessible. + +## Vulnerability Detail +When an owner is not set during construction from a contract that inherits Ownable, the owner address remains 0x0, meaning any function that uses `onlyOwner` is permanently inaccessible after VaultFactory.sol deployment. + +The owner wants to be able to call the onlyOwner functions to change the associated state values for future vaults created through the factory, but they cannot. + +## Impact +Owner cannot call `setProtocolFeeBps`, `setProtocolFeeReceiver`, and `setEarlyExitFeeBps` to change state for future vaults deployed through the factory. + +## Code Snippet +* https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L81 +* https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L90-#L104 + +## Tool used +Manual Review + +## Recommendation + +```diff +-  constructor(uint256 _protocolFeeBps, uint256 _earlyExitFeeBps) { ++  constructor(uint256 _protocolFeeBps, uint256 _earlyExitFeeBps) Ownable(msg.sender) { +``` \ No newline at end of file diff --git a/010.md b/010.md new file mode 100644 index 0000000..309f650 --- /dev/null +++ b/010.md @@ -0,0 +1,33 @@ +Keen Fossilized Viper + +High + +# The vault does not implement any ERC721TokenReceiver method making it imposible to unstake from the lidoWithdrawalQueue address. + +### Details +Based on Lido architecture, upon withdrawal requests, an ERC721 is usually sent to the `msg.sender` which is in this case the vault. [Proof of this](https://github.com/lidofinance/docs/blob/main/docs/contracts/withdrawal-queue-erc721.md). + +User request withdrawal by calling `LidoVault::withdraw`. + +The problem is that `lidoWithdrawalQueue.requestWithdrawals` will safeTransfer an NFT to the `msg.sender` which in this case will be the vault as proof of request withdrawal. But the vault is not compatible with safeTransfer because the implementation LidoVault.sol does not have any ERC721TokenReceiver method required by safeTransfer. + +It is important to mention that the reason the test passed is because of the mock that was used. + +``` +/// @title Saffron Fixed Income Lido Vault +contract LidoVault is ILidoVaultInitializer, ILidoVault { +``` + +https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol +``` +function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual { + transferFrom(from, to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), from, to, tokenId, data); + } +``` + +### Tool used +Manual Review + +### Recommendations +Implement a ERC721TokenReceiver in the vault. \ No newline at end of file diff --git a/010/003.md b/010/003.md new file mode 100644 index 0000000..7a9efa8 --- /dev/null +++ b/010/003.md @@ -0,0 +1,49 @@ +Rural Fuchsia Starfish + +Medium + +# `LIDO_ERROR_TOLERANCE_ETH` Results In Excessive DoS + +## Summary + +Using a constant [`LIDO_ERROR_TOLERANCE_ETH`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L1018C27-L1018C51) value increases the protocol's susceptibility to DoS. + +## Vulnerability Detail + +The `LidoVault` imposes the constraint that the shortfall experienced when minting shares must not exceed an empircally evaluated [`LIDO_ERROR_TOLERANCE_ETH`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L1018C27-L1018C51): + +```solidity +/// @notice ETH diff tolerance either for the expected deposit or withdraw from Lido - some rounding errors of a few wei seem to occur +uint256 public constant LIDO_ERROR_TOLERANCE_ETH = 10 wei; +``` + +```solidity +// Stake on Lido +/// returns stETH, and returns amount of Lido shares issued for the staked ETH +uint256 stETHBalanceBefore = stakingBalance(); +uint256 shares = lido.submit{value: amount}(address(0)); // _referral address argument is optional use zero address +require(shares > 0, "ISS"); +// stETH transfered from Lido != ETH deposited to Lido - some rounding error +uint256 stETHReceived = (stakingBalance() - stETHBalanceBefore); + +/// @audit Do not allow more than `10 wei` shortfall: +@> require((stETHReceived >= amount) || (amount - stETHReceived <= LIDO_ERROR_TOLERANCE_ETH), "ULD"); +``` + +However, the same threshold tolerance is imposed regardless of the size of the `amount` staked. + +Assuming maximum allowable slippage of `10 wei` for a nominal token, then minting `10` nominal tokens clearly amplifies perceived slippage past the threshold, even though the rate per token would remain within the intended operational limits of the protocol. + +## Impact + +Reduced availability, especially for larger deposits. + +## Code Snippet + +## Tool used + +Manual Review + +## Recommendation + +The maximum slippage permitted during deposits should be a function of the nominal `amount`. diff --git a/010/020.md b/010/020.md new file mode 100644 index 0000000..4fb9c99 --- /dev/null +++ b/010/020.md @@ -0,0 +1,50 @@ +Careful Mocha Vulture + +High + +# `deposit()` Will Always Revert Because Slippage Tolerance `LIDO_ERROR_TOLERANCE_ETH` Is Too Low + +## Summary + +`LIDO_ERROR_TOLERANCE_ETH` is set to `10`, which is too low and will cause the `deposit()` function to always revert. + +## Vulnerability Detail + +The `deposit` function includes a slippage tolerance check to ensure that the amount of stETH received from Lido is within the expected range. However, the slippage tolerance is set to `10`, which is too low and will always revert the `deposit()` function: + +[LidoVault.sol#L1017-L1018](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/f09cf4ce42044a0f22d49e763f76fabe47bd8fe8/lido-fiv/contracts/LidoVault.sol#L1017-L1018) +```solidity + /// @notice ETH diff tolerance either for the expected deposit or withdraw from Lido - some rounding errors of a few wei seem to occur +-> uint256 public constant LIDO_ERROR_TOLERANCE_ETH = 10 wei; +``` + +[LidoVault.sol#L348-L355](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/f09cf4ce42044a0f22d49e763f76fabe47bd8fe8/lido-fiv/contracts/LidoVault.sol#L348-L355) +```solidity + // Stake on Lido + /// returns stETH, and returns amount of Lido shares issued for the staked ETH + uint256 stETHBalanceBefore = stakingBalance(); + uint256 shares = lido.submit{value: amount}(address(0)); // _referral address argument is optional use zero address + require(shares > 0, "ISS"); + // stETH transfered from Lido != ETH deposited to Lido - some rounding error + uint256 stETHReceived = (stakingBalance() - stETHBalanceBefore); +-> require((stETHReceived >= amount) || (amount - stETHReceived <= LIDO_ERROR_TOLERANCE_ETH), "ULD"); +``` + +As shown in the deployed Lido contract, the exchange rate is not 1:1. +At the time of writing, 1 ETH is equivalent to 0.848033148273146669 stETH: [Lido Contract on Etherscan](https://etherscan.io/address/0xae7ab96520de3a18e5e111b5eaab095312d7fe84#readProxyContract#F5) + +## Impact + +DoS on the `deposit` function. + +## Code Snippet + +[LidoVault.sol#L348-L355](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/f09cf4ce42044a0f22d49e763f76fabe47bd8fe8/lido-fiv/contracts/LidoVault.sol#L348-L355) + +## Tool used + +Manual Review + +## Recommendation + +Set `LIDO_ERROR_TOLERANCE_ETH` to a higher value. \ No newline at end of file diff --git a/011/013.md b/011/013.md new file mode 100644 index 0000000..b989b21 --- /dev/null +++ b/011/013.md @@ -0,0 +1,54 @@ +Striped Punch Wallaby + +Medium + +# {Repeated function calls within withdraw function} will {increase gas costs} {for users} + +### Summary + +Repeated function calls inside contract `withdraw()` loops will cause increased gas costs for users as the contract repeatedly accesses values without caching, leading to inefficient execution. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L423C3-L556C10 + +![image](https://github.com/user-attachments/assets/27d87c6f-17d8-42bf-ae5e-fcab105f95b3) + + +### Root Cause + +The choice to perform repeated function calls within loops and conditional blocks without caching their states is a mistake as it significantly increases gas costs. Instead of retrieving the same value multiple times, caching results in local / state variables would reduce the gas usage, making the contract more efficient. + +The function calls such as `isStarted()` and `isEnded()` are already being cached. However `stakingBalance()` , `stakingShares()` , and `minStETHWithdrawalAmount()` are not cached. They are making repeated function calls accross many functions in the contract to access this information. + +### Internal pre-conditions + +1. **The contract design uses function calls within loops and conditional blocks**: The functions like stakingBalance(), stakingShares(), and minStETHWithdrawalAmount() are called multiple times within a single execution of the withdraw() function, especially inside loops and conditionals. + +2. **The function results are not cached locally within the execution flow**: Despite being called multiple times, the values are not stored in local variables outside of the loop or conditional structures, leading to repeated gas costs for retrieving the same data. + +3.**No existing mechanism to update cached values efficiently**: The contract does not employ an efficient method to update or manage cached values, resulting in unnecessary function calls during each execution. + +### External pre-conditions + +1. **External protocols (e.g., staking platforms) frequently update relevant values** like staking balances or share amounts, necessitating function calls that should ideally be cached to minimize gas costs. + +2. **The contract relies on up-to-date values from external sources**, which might not be efficiently handled if these values are fetched repeatedly rather than cached. + +### Attack Path + +1. **User calls** `withdraw()`: The function is executed, triggering internal logic with loops and conditionals. + +2. **Contract calls `stakingBalance()`, `stakingShares()`, and `minStETHWithdrawalAmount()` multiple times within the execution**: These calls are not cached, leading to redundant calculations and increased gas costs. + +### Impact + +The users suffer increased transaction costs due to inefficient gas usage. Each call to `withdraw()` incurs higher gas fees because of repeated function calls within loops and conditionals without caching, resulting in an unnecessary burden on the user. + +### PoC + +_No response_ + +### Mitigation + +**Consider using state variables for values that need to be accessed across multiple functions:** This would involve updating these variables only when necessary, reducing repeated calls and improving overall gas efficiency. + +**Implement an efficient update mechanism**: For cached values that need periodic updates, ensure that they are updated at the appropriate times without causing additional gas costs during every function call. \ No newline at end of file diff --git a/011/014.md b/011/014.md new file mode 100644 index 0000000..82a5357 --- /dev/null +++ b/011/014.md @@ -0,0 +1,59 @@ +Scrawny Coal Snail + +Medium + +# {function is called multiple times within withdraw function} which will {increase gas costs} {for users} + +### Summary + +Repeated function calls inside `LidoVault.withdraw()`contract, loops will cause increased gas costs for users as the contract repeatedly accesses values without caching, leading to inefficient execution. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L423C3-L556C10 + +![report](https://github.com/user-attachments/assets/4f8622ac-f0cc-48f3-abc9-7dcb1ee51e9a) + + +### Root Cause + +in LidoVault.sol line 423-556 + +The choice to perform repeated function calls within loops and conditional blocks without caching their states is a mistake as it significantly increases gas costs. Instead of retrieving the same value multiple times, caching results in local variables would reduce the gas usage, making the contract more efficient. + +The function calls such as `isStarted()` and `isEnded()` are already being cached. However `stakingBalance()` , `stakingShares()` , and `minStETHWithdrawalAmount()` are not cached. They are making repeated function calls accross many functions in the contract to access this information. + +### Internal pre-conditions + +1. **The contract design uses function calls within loops and conditional blocks**: The functions like stakingBalance(), stakingShares(), and minStETHWithdrawalAmount() are called multiple times within a single execution of the withdraw() function, especially inside loops and conditionals. + +2. **The function results are not cached locally within the execution flow**: Despite being called multiple times, the values are not stored in local variables outside of the loop or conditional structures, leading to repeated gas costs for retrieving the same data. + +3.**No existing mechanism to update cached values efficiently**: The contract does not employ an efficient method to update or manage cached values, resulting in unnecessary function calls during each execution. + + +### External pre-conditions + +1. **External protocols (e.g., staking platforms) frequently update relevant values** like staking balances or share amounts, necessitating function calls that should ideally be cached to minimize gas costs. + +2. **The contract relies on up-to-date values from external sources**, which might not be efficiently handled if these values are fetched repeatedly rather than cached. + + +### Attack Path + +1. **User calls** `withdraw()`: The function is executed, triggering internal logic with loops and conditionals. + +2. **Contract calls `stakingBalance()`, `stakingShares()`, and `minStETHWithdrawalAmount()` multiple times within the execution**: These calls are not cached, leading to redundant calculations and increased gas costs. + +### Impact + +The users suffer increased transaction costs due to inefficient gas usage. Each call to `withdraw()` incurs higher gas fees because of repeated function calls within loops and conditionals without caching, resulting in an unnecessary burden on the user. + + +### PoC + +_No response_ + +### Mitigation + +**Consider using state variables for values that need to be accessed across multiple functions:** This would involve updating these variables only when necessary, reducing repeated calls and improving overall gas efficiency. + +**Implement an efficient update mechanism**: For cached values that need periodic updates, ensure that they are updated at the appropriate times without causing additional gas costs during every function call. diff --git a/012/022.md b/012/022.md new file mode 100644 index 0000000..55dadf2 --- /dev/null +++ b/012/022.md @@ -0,0 +1,52 @@ +Scrawny Iron Tiger + +High + +# Protocol Fee Receiver and Withdrawal Logic Vulnerabilities in LidoVault Contract + +## Summary +The LidoVault contract manages deposits, staking via Lido, and the distribution of rewards and early exit fees among fixed and variable users. The contract also collects protocol fees for the protocol fee receiver from variable-side earnings. However, there are issues in the withdrawal logic that create unnecessary restrictions on the protocol fee receiver's ability to withdraw their variable-side deposits and collect accrued fees. These issues may result in blocked withdrawals and inefficiencies in the protocol fee collection process. +(vaultEndedWithdraw function also has get similar problem); + +## Vulnerability Detail +The vulnerability lies in the withdrawal logic concerning the protocol fee receiver. Specifically, the following issues arise: + +**1. Unnecessary Blockage of Protocol Fee Receiver Withdrawals:** +When the protocol fee receiver tries to withdraw their variable-side deposits while the appliedProtocolFee is greater than zero, they are blocked from doing so by this condition in the withdraw function: + +if (msg.sender == protocolFeeReceiver && appliedProtocolFee > 0) { + require(variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0, "WAR"); + return protocolFeeReceiverWithdraw(); +} +This check prevents the protocol fee receiver from withdrawing their variable-side deposits even if they have unrelated variable-side withdrawal requests in progress, effectively locking their funds. + +**2.Misuse of variableToVaultOngoingWithdrawalRequestIds for Fee Collection:** +The variableToVaultOngoingWithdrawalRequestIds mapping is used to track ongoing withdrawal requests for variable-side users. The contract blocks the protocol fee receiver from collecting protocol fees if they have ongoing withdrawal requests, even though these two processes should be independent. + +## Impact +1.Blocked Withdrawals: +The protocol fee receiver might be unable to withdraw their variable-side deposits while there are protocol fees accrued (appliedProtocolFee > 0), locking their funds and causing a disruption in the normal flow of operations. + +2.Inefficient Protocol Fee Collection: +By tying protocol fee collection to variable-side withdrawal logic, the contract introduces unnecessary complexity and delays in collecting protocol fees, reducing efficiency. + +3.Operational Bottleneck: +The dependency between the protocol fee receiver's own variable-side withdrawal requests and the fee collection process introduces a bottleneck, where one process can block the other, complicating operations. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L514-L518 + +## Tool used + +Manual Review + +## Recommendation +**1. Decouple Protocol Fee Collection from Variable Withdrawals:** +The protocol fee receiver’s ability to collect protocol fees should not be tied to their variable-side withdrawal requests. The condition requiring the absence of entries in variableToVaultOngoingWithdrawalRequestIds should be removed to allow the protocol fee receiver to collect protocol fees without interference: + +**2. Allow Withdrawal of Variable Deposits by Protocol Fee Receiver:** +The protocol fee receiver should be able to withdraw their variable-side deposits regardless of whether protocol fees have accrued. Remove the restriction that blocks withdrawals when appliedProtocolFee > 0, allowing the protocol fee receiver to operate as a normal variable-side user when withdrawing their own deposits. + +**3. Consider Separate Roles for Fee Collection and User Withdrawals:** +To avoid potential conflicts, it may be beneficial to create separate roles or processes for handling protocol fee collection and user withdrawals, ensuring smoother and more efficient operations. \ No newline at end of file diff --git a/012/141.md b/012/141.md new file mode 100644 index 0000000..816609f --- /dev/null +++ b/012/141.md @@ -0,0 +1,93 @@ +Tiny Heather Viper + +High + +# Lido FIV Protocol Fee Receiver Can Bypass Variable Side Withdrawal Checks! + +### Summary + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L514 + + +```solidity +else { + if (msg.sender == protocolFeeReceiver && appliedProtocolFee > 0) { + require(variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0, "WAR"); + return protocolFeeReceiverWithdraw(); + } +``` + +The vulnerability lies in the fact that the `protocolFeeReceiver` address can also be a variable side depositor. This creates a potential conflict of interest and a way to bypass certain checks. Here's the problematic scenario: + +1. The `protocolFeeReceiver` address makes a variable side deposit. +2. This gives them the ability to call the `withdraw` function for the variable side. +3. When they call `withdraw`, the code first checks if they are the `protocolFeeReceiver` and if there's an `appliedProtocolFee`. +4. If these conditions are met, it only checks if `variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0`. +5. It then immediately calls `protocolFeeReceiverWithdraw()` without any further checks. + +The vulnerability is that this bypasses all the normal checks and balances for variable side withdrawals. It doesn't check the user's balance, doesn't update any state related to variable side withdrawals, and doesn't handle the withdrawal process in the same way as for other variable side depositors. + +This could potentially allow the `protocolFeeReceiver` to: +1. Withdraw more than their fair share of earnings. +2. Withdraw at times when other users can't. +3. Bypass the normal withdrawal process and associated state updates. + +To fix this, the contract should either: +1. Prevent the `protocolFeeReceiver` from being a variable side depositor. +2. Or, handle the `protocolFeeReceiver`'s variable side withdrawals separately from their fee withdrawals, ensuring all normal checks and state updates occur for their variable side activity. + +This vulnerability highlights the importance of separating roles and ensuring that privileged addresses (like the `protocolFeeReceiver`) don't have unintended additional capabilities that could be exploited. + + + +#### Impact: + +The vulnerability allows the protocolFeeReceiver to bypass normal withdrawal checks and potentially drain the contract of more funds than they should be entitled to. This can lead to a loss of funds for other users and compromise the integrity of the entire system. + +#### PoC Flow: + +1. The protocolFeeReceiver makes a variable side deposit, becoming a variable side user. + +2. The vault operates normally, accumulating fees and earnings. + +3. At any point (even before the vault ends), the protocolFeeReceiver can call the withdraw function: + + ```solidity + function withdraw(uint256 side) external { + // ... + else if (!isEnded()) { + if (side == VARIABLE) { + if (msg.sender == protocolFeeReceiver && appliedProtocolFee > 0) { + require(variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0, "WAR"); + return protocolFeeReceiverWithdraw(); + } + // ... (normal variable side withdrawal logic) + } + } + // ... + } + ``` + +4. This call bypasses all normal variable side withdrawal checks and immediately calls protocolFeeReceiverWithdraw(): + + ```solidity + function protocolFeeReceiverWithdraw() internal { + uint256 protocolFee = appliedProtocolFee; + appliedProtocolFee = 0; + transferWithdrawnFunds(msg.sender, protocolFee); + + emit VariableFundsWithdrawn(protocolFee, msg.sender, isStarted(), isEnded()); + } + ``` + +5. The protocolFeeReceiver can repeat this process multiple times, each time withdrawing the entire appliedProtocolFee, potentially draining the contract. + +6. This bypass allows the protocolFeeReceiver to: + - Withdraw funds before other variable side users + - Withdraw more than their fair share + - Avoid updating their withdrawal state (variableToVaultOngoingWithdrawalRequestIds, variableToWithdrawnStakingEarnings, etc.) + - Potentially withdraw both their variable side earnings and protocol fees multiple times + +7. The normal accounting and withdrawal request process for variable side users is completely circumvented, leading to inconsistent state and potential fund loss for other users. + + diff --git a/013/029.md b/013/029.md new file mode 100644 index 0000000..7472f15 --- /dev/null +++ b/013/029.md @@ -0,0 +1,45 @@ +Careful Mocha Vulture + +Medium + +# Reorg Attack Vulnerability in `createVault` Function + +## Summary + +The `createVault` function within the `VaultFactory.sol` contract is susceptible to blockchain reorganization (reorg) attacks. This vulnerability arises from the usage of OpenZeppelin's `Clones.clone` method, which leverages the `create` opcode for deploying minimal proxy contracts. The non-deterministic nature of the `create` opcode exposes the contract to potential manipulation during blockchain reorgs. + +## Vulnerability Detail + +The `createVault` function deploys new vault contracts by cloning a master `LidoVault` implementation using OpenZeppelin's `Clones.clone` method. This clone deployment utilizes the `create` opcode, which deterministically generates contract addresses based on the deployer's address and its nonce. Each invocation of `create` increases the nonce, resulting in unique, non-deterministic clone addresses. + +In case of a reorg, an attacker can steal user funds by deploying a new vault on the same address as the initial vault. + +**An example attack scenario in the above case:** +- **Alice** creates a vault via the factory contract (transaction A) +- **Alice** then deposits tokens into the vault (transaction B) +- A block reorg happens and transaction A is discarded (transaction B still exists) +- Normally transaction B now would revert if executed +- **Bob** deploys himself the vault that initially **Alice** did (via frontrunning) and because of the underlying issue, it is created on the same address as the initial vault. But **Bob** changed the parameters of the vault and set `_fixedSideCapacity` to the exact same value as **Alice**'s deposit transaction, and `_variableSideCapacity` to `0.01 ether` (the minimum allowed) and `duration` to `1` (the minimum allowed). +- **Alice**'s deposit reaches now **Bob**'s vault. +- **Bob** deposits `0.01 ether` on the variable side to start the vault. +- `1` second later the vault ends and **Bob** has successfully extracted the maximum value out of **Alice**'s deposit. + +Alternatively, **Bob** could set `duration` to `type(uint256).max` and "trap" **Alice**'s deposit because the early exit fee will be very high for a very long time. + +Source: [Blockchain reorgs for Managers and Auditors](https://abarbatei.xyz/blockchain-reorgs-for-managers-and-auditors#heading-protocols-that-require-users-to-create-something-and-then-interact-with-it-in-a-two-step-manner) + +## Impact + +Theft of user funds in the case of a reorg. + +## Code Snippet + +[VaultFactory.sol#L113](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/VaultFactory.sol#L113) + +## Tool used + +Manual Review + +## Recommendation + +Use a deterministic method for vault address generation, such as `create2`, to ensure consistent and predictable contract addresses. \ No newline at end of file diff --git a/013/057.md b/013/057.md new file mode 100644 index 0000000..eedba84 --- /dev/null +++ b/013/057.md @@ -0,0 +1,70 @@ +Rich Orchid Meerkat + +Medium + +# The `VaultFactory` is suspicious to blockchain reorg + +## Summary + +A block reorg is a situation where multiple miners find valid blocks at the same time, and the network has to decide which block to include in the blockchain. + +The `VaultFactory` contract deploys instances of the `LidoVault` contract using the `Clones.clone()` function from Openzeppelin which uses the `CREATE` opcode internally. + + + +```solidity +function createVault( + uint256 _fixedSideCapacity, + uint256 _duration, + uint256 _variableSideCapacity +) public virtual { + // Deploy vault (Note: this does not run constructor) +@> address vaultAddress = Clones.clone(vaultContract); +``` + +The address derivation (for the new `LidoVault`) depends on the sender's address (e.g. `VaultFactory`) and its nonce. + +The system is intended to be deployed on Ethereum meaning these situations do not occur often but can still happen. + +Here is an instance of chain reorg that took place on Ethereum : [click here](https://decrypt.co/101390/ethereum-beacon-chain-blockchain-reorg). + +## Vulnerability Detail + +In case a chain reorg occurs, a user may find himself depositing ETH on a `LidoVault` that he did not intend to : a vault with different parameters, duration... + +Assume the following scenario : + +1. Let user A create a vault. The `CREATE` opcode will use the `VaultFactory` nonce (say 5) to deploy the vault at `address(12345)`. + +2. Let user B create a vault. The `CREATE` opcode will use the `VaultFactory` nonce (now 6) to deploy the vault at `address(abcde)`. + +3. Let user C `deposit()` ETH on vault created by user A, at `address(12345)`. The transaction is validated successfully. + +4. Unfortunately, a block reorg occurs which reorganizes the blocks in a way where : + - user B transaction occurs first : deploying his vault at `address(12345)` + - user A transaction occurs second : deploying his vault at `address(abcde)` + - user C still `deposit()` on vault deployed at `address(12345)` which was deployed by user B with a set of parameters different from what user C expected + +## Impact + +A user may commit his ETH to a system he did not intend to, potentially reducing his return on investment. + +## Tool used + +Manual Review + +## Recommendation + +Use the `Clones.cloneDeterministic()` function from Openzeppelin that uses the `CREATE2` opcode in order to deploy a vault at a deterministic and fixed address instead of the `Clones.clone()` function. + +```diff +function createVault( + uint256 _fixedSideCapacity, + uint256 _duration, + uint256 _variableSideCapacity, ++ bytes32 salt +) public virtual { + // Deploy vault (Note: this does not run constructor) +- address vaultAddress = Clones.clone(vaultContract); ++ address vaultAddress = Clones.cloneDeterministic(vaultContract, salt); +``` \ No newline at end of file diff --git a/014/045.md b/014/045.md new file mode 100644 index 0000000..0d9486d --- /dev/null +++ b/014/045.md @@ -0,0 +1,125 @@ +Pet Frost Yak + +High + +# `finalizeVaultEndedWithdrawals` allows anyone to steal fixed-side user funds + +### Summary + +The incorrect use of `msg.sender` in the [`claimFixedVaultOngoingWithdrawal` function](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L812-L844) will cause any user to receive the withdrawal funds of fixed-side users as the contract misroutes the ETH during the withdrawal finalization process. + + +### Root Cause + +In [`LidoVault.sol`](https://github.com/saffron-finance/lido-fiv/blob/7246b6651c8affffe17faa4d2984975102a65d81/contracts/LidoVault.sol), the [`claimFixedVaultOngoingWithdrawal` function](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L812-L844) incorrectly [calls `claimWithdrawals` with `msg.sender`](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L831) instead of the intended `user`. This misroutes the ETH withdrawal to the caller of the function rather than the rightful fixed-side user. + +**Affected Functions:** +- `finalizeVaultOngoingFixedWithdrawals` +- `claimFixedVaultOngoingWithdrawal` + + +### Internal pre-conditions + +1. **Any user** needs to **call** `finalizeVaultEndedWithdrawals` with `side == FIXED` after the vault has ended. +2. The `vaultEndedWithdrawalsFinalized` flag must be **false**. +3. There must be **pending fixed-side withdrawal requests** in `fixedOngoingWithdrawalUsers`. +4. The `fixedToVaultOngoingWithdrawalRequestIds` mapping must contain **valid withdrawal requests** for the fixed-side users. + +### External pre-conditions + +_No response_ + +### Attack Path + +1. **Attacker** calls the [`finalizeVaultEndedWithdrawals` function](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L661-L688) with `side == FIXED` after the vault has ended. +2. Inside `finalizeVaultEndedWithdrawals`, the function checks and sets `vaultEndedWithdrawalsFinalized` to `true`. +3. The function then calls `claimOngoingFixedWithdrawals`, which iterates through `fixedOngoingWithdrawalUsers`. +4. For each `fixedUser`, `claimFixedVaultOngoingWithdrawal(fixedUser)` is invoked. +5. Inside `claimFixedVaultOngoingWithdrawal`, the function erroneously calls: + ```solidity + uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); + ``` + Instead of: + ```solidity + uint256 amountWithdrawn = claimWithdrawals(user, requestIds); + ``` +6. As a result, the ETH withdrawn from Lido is transferred to `msg.sender` (the attacker) instead of the intended `fixedUser`. +7. The attacker receives the ETH that should have been sent to the fixed-side users. +8. This process repeats for all `fixedOngoingWithdrawalUsers`, allowing the attacker to drain their funds. + + +### Impact + +Fixed-side users suffer a **complete loss of their withdrawal funds**, resulting in significant financial losses. The attacker gains unauthorized access to the ETH intended for fixed-side users, undermining the protocol's integrity and leading to a **loss of user trust** and potential **reputation damage** for Saffron Finance. + +### PoC + +1. **User A** deposits **100 ETH** into the fixed side and plans to withdraw early. +2. **User A** initiates a withdrawal by calling: + ```solidity + vault.withdraw(FIXED); + ``` +3. The vault records the withdrawal request in `fixedOngoingWithdrawalUsers` and `fixedToVaultOngoingWithdrawalRequestIds[UserA]`. +4. After the vault has ended, **Attacker** calls: + ```solidity + vault.finalizeVaultEndedWithdrawals(FIXED); + ``` +5. Inside `finalizeVaultEndedWithdrawals`, the function sets `vaultEndedWithdrawalsFinalized` to `true` and calls `claimOngoingFixedWithdrawals`. +6. `claimOngoingFixedWithdrawals` iterates over `fixedOngoingWithdrawalUsers` and calls: + ```solidity + claimFixedVaultOngoingWithdrawal(UserA); + ``` +7. Inside `claimFixedVaultOngoingWithdrawal`, the function incorrectly transfers the withdrawn ETH to `msg.sender` (the attacker) instead of `UserA`: + ```solidity + uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); + ``` +8. The attacker receives **100 ETH** intended for **User A**. + +**Relevant Code Snippets:** + +```solidity +function finalizeVaultOngoingFixedWithdrawals() external { + uint256 sendAmount = claimFixedVaultOngoingWithdrawal(msg.sender); + // ... +} + +function claimFixedVaultOngoingWithdrawal(address user) internal returns (uint256) { + // ... + uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); + // ... +} +``` + +In the above code, `msg.sender` refers to the attacker calling `finalizeVaultEndedWithdrawals`, resulting in ETH being sent to the attacker instead of the intended `user`. + + +### Mitigation + +Ensure that the `claimWithdrawals` function is called with the correct `user` parameter instead of `msg.sender`. Update the `claimFixedVaultOngoingWithdrawal` function to pass the `user` address. + +```diff +function claimFixedVaultOngoingWithdrawal(address user) internal returns (uint256) { + if (user == address(0)) return 0; + + WithdrawalRequest memory request = fixedToVaultOngoingWithdrawalRequestIds[user]; + uint256[] memory requestIds = request.requestIds; + require(requestIds.length != 0, "WNR"); + + uint256 upfrontPremium = userToFixedUpfrontPremium[user]; + + delete userToFixedUpfrontPremium[user]; + delete fixedToVaultOngoingWithdrawalRequestIds[user]; + +- uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds); ++ uint256 amountWithdrawn = claimWithdrawals(user, requestIds); + + uint256 earlyExitFees = calculateFixedEarlyExitFees(upfrontPremium, request.timestamp); + earlyExitFees = Math.min(earlyExitFees, amountWithdrawn); + + feeEarnings += earlyExitFees; + + emit LidoWithdrawalFinalized(user, requestIds, FIXED, true, isEnded()); + + return amountWithdrawn - earlyExitFees; +} +``` diff --git a/014/075.md b/014/075.md new file mode 100644 index 0000000..8d60ffd --- /dev/null +++ b/014/075.md @@ -0,0 +1,30 @@ +Feisty Lipstick Gibbon + +Medium + +# The internal function `claimFixedVaultOngoingWithdrawal` uses `msg.sender` instead of the passed `user` parameter. + +## Summary + +The internal function `claimFixedVaultOngoingWithdrawal` uses `msg.sender` instead of the passed `user` parameter. + +## Vulnerability Detail + +In the internal function `claimFixedVaultOngoingWithdrawal`, although the function accepts a `user` parameter, it uses `msg.sender` when calling `claimWithdrawals`. + +## Impact + +This will result in incorrect event logging. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L831 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1181 + +## Tool used + +Manual Review + +## Recommendation +Replace `msg.sender` with the passed `user` parameter to ensure consistency and correctness in the function logic. \ No newline at end of file diff --git a/015/102.md b/015/102.md new file mode 100644 index 0000000..a8445df --- /dev/null +++ b/015/102.md @@ -0,0 +1,42 @@ +Keen Lead Squid + +Invalid + +# FadoBagi - Funds Can Become Stuck Due to Lido Withdrawal Minimums + +FadoBagi + +High + +# Funds Can Become Stuck Due to Lido Withdrawal Minimums + +## Summary +The `LidoVault` contract enforces a minimum withdrawal amount of `100 stETH` through the `MIN_STETH_WITHDRAWAL_AMOUNT` constant in the `_requestWithdraw` function. If the contract's total `stETH` balance falls below `100 stETH`, users are unable to initiate withdrawal requests. Additionally, the contract lacks alternative withdrawal mechanisms, which may result in users' funds becoming permanently locked when the `stETH` balance is insufficient. + +## Vulnerability Detail +In the `_requestWithdraw` function, the contract checks if the `stETHAmount` is greater than or equal to `MIN_STETH_WITHDRAWAL_AMOUNT`: + + function _requestWithdraw(address user, uint256 stETHAmount) internal + returns (uint256[] memory) { + // ... + require(stETHAmount >= MIN_STETH_WITHDRAWAL_AMOUNT, "WM"); + // ... + } + +This means if the contract's total `stETH` balance drops below `100 stETH`, the condition will fail, and users will be unable to request withdrawals. + +## Impact +If the contract's `stETH` balance falls below `100 stETH`, users will be unable to withdraw their funds, potentially leaving them permanently locked without any alternative withdrawal options. + +## Code Snippet +- **Function: _requestWithdraw** + https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1137 + +- **Variable: MIN_STETH_WITHDRAWAL_AMOUNT** + https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1011 + +## Tools Used +Manual Review + +## Recommendation +Implement a fallback mechanism allowing users to withdraw their proportional share of `stETH` directly when the contract's balance falls below `100 stETH`. Alternatively, accumulate smaller withdrawal requests until they meet the minimum threshold for a Lido withdrawal. \ No newline at end of file diff --git a/015/104.md b/015/104.md new file mode 100644 index 0000000..92834a5 --- /dev/null +++ b/015/104.md @@ -0,0 +1,42 @@ +Keen Lead Squid + +High + +# FadoBagi - Funds Can Become Stuck Due to Lido Withdrawal Minimums + +FadoBagi + +High + +# Funds Can Become Stuck Due to Lido Withdrawal Minimums + +## Summary +The `LidoVault` contract enforces a minimum withdrawal amount of `100 stETH` through the `MIN_STETH_WITHDRAWAL_AMOUNT` constant in the `_requestWithdraw` function. If the contract's total `stETH` balance falls below `100 stETH`, users are unable to initiate withdrawal requests. Additionally, the contract lacks alternative withdrawal mechanisms, which may result in users' funds becoming permanently locked when the `stETH` balance is insufficient. + +## Vulnerability Detail +In the `_requestWithdraw` function, the contract checks if the `stETHAmount` is greater than or equal to `MIN_STETH_WITHDRAWAL_AMOUNT`: + + function _requestWithdraw(address user, uint256 stETHAmount) internal + returns (uint256[] memory) { + // ... + require(stETHAmount >= MIN_STETH_WITHDRAWAL_AMOUNT, "WM"); + // ... + } + +This means if the contract's total `stETH` balance drops below `100 stETH`, the condition will fail, and users will be unable to request withdrawals. + +## Impact +If the contract's `stETH` balance falls below `100 stETH`, users will be unable to withdraw their funds, potentially leaving them permanently locked without any alternative withdrawal options. + +## Code Snippet +- **Function: _requestWithdraw** + https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1137 + +- **Variable: MIN_STETH_WITHDRAWAL_AMOUNT** + https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1011 + +## Tools Used +Manual Review + +## Recommendation +Implement a fallback mechanism allowing users to withdraw their proportional share of `stETH` directly when the contract's balance falls below `100 stETH`. Alternatively, accumulate smaller withdrawal requests until they meet the minimum threshold for a Lido withdrawal. \ No newline at end of file diff --git a/016.md b/016.md new file mode 100644 index 0000000..9b39d85 --- /dev/null +++ b/016.md @@ -0,0 +1,67 @@ +Scrawny Iron Tiger + +High + +# Redundant Withdrawal Condition and Ambiguous Error Messages in LidoVault.sol + +## Summary +In the LidoVault.sol contract, a redundant conditional check in the withdrawal logic and the use of identical error messages for different conditions can lead to blocked withdrawals and user confusion. Specifically, the unnecessary check on unfinalized withdrawal requests affects withdrawals in progress, and the reuse of the same error message obscures the specific action users need to take to resolve different issues. + +## Vulnerability Detail +The core issue arises in the following require statement: +`require( + fixedToVaultOngoingWithdrawalRequestIds[msg.sender].requestIds.length == 0 && + fixedToVaultNotStartedWithdrawalRequestIds[msg.sender].length == 0, + "WAR" +);` +This condition attempts to ensure that users have no active or unfinalized withdrawal requests before initiating a new one. However, the check on fixedToVaultNotStartedWithdrawalRequestIds[msg.sender].length == 0 is unnecessary when the withdrawal is related to the ongoing vault process. This check relates to unfinalized withdrawals that were initiated but not completed, which does not need to be considered for withdrawals in progress. + +## Senario +1. A user deposits into the fixed vault. +2. The user requests a withdrawal but does not finalize it using finalizeVaultNotStartedFixedWithdrawals(), leaving an entry in fixedToVaultNotStartedWithdrawalRequestIds. +3. The user deposits again and tries to initiate a new withdrawal in progress. +4. The contract blocks the withdrawal because the unfinalized withdrawal exists, even though it is unrelated to the current in-progress request. +This is compounded by the fact that the same error message "WAR" is reused in different require checks throughout the contract: +`require(fixedToVaultNotStartedWithdrawalRequestIds[msg.sender].length == 0, "WAR"); +require(fixedToVaultOngoingWithdrawalRequestIds[msg.sender].requestIds.length == 0 && + fixedToVaultNotStartedWithdrawalRequestIds[msg.sender].length == 0, "WAR"); +require(variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0, "WAR"); +` +Each of these checks involves different logic and requires different corrective actions (e.g., finalizing unstarted withdrawals vs. claiming ongoing withdrawals), but the identical error message offers no insight into what the user needs to do. This creates confusion and makes it difficult for users to determine which function to call to resolve the issue. + +## Impact +**1. Denial of Service (DoS):** +Users are prevented from withdrawing from the vault in progress if they have unfinalized withdrawal requests. This unnecessarily restricts access to their funds, potentially leading to a prolonged lockout. +**2. User Confusion:** +The use of the same error message for different conditions makes it difficult for users to determine what action they need to take. This could lead to users attempting incorrect actions, further delaying resolution and increasing frustration. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L474-L478 + +## Tool used + +Manual Review + +## Recommendation +**1.Remove the Redundant Condition:** + +The check for fixedToVaultNotStartedWithdrawalRequestIds[msg.sender].length == 0 should be removed in the context of ongoing withdrawals, as it is irrelevant to the process of withdrawing from the vault in progress. The corrected require statement should be: +`require( + fixedToVaultOngoingWithdrawalRequestIds[msg.sender].requestIds.length == 0, + "WAR" +); +` +**2.Use Specific Error Messages:** + +The error messages should be revised to reflect the specific nature of the issue. This will give users clear instructions on what they need to do, avoiding confusion and delays. For example: + +require( + fixedToVaultNotStartedWithdrawalRequestIds[msg.sender].length == 0, + "Unfinalized withdrawal exists. Call finalizeVaultNotStartedFixedWithdrawals()."); + +require( + fixedToVaultOngoingWithdrawalRequestIds[msg.sender].requestIds.length == 0, + "Ongoing withdrawal exists. Call claimFixedVaultOngoingWithdrawal()." +); + +By making these adjustments, the contract will provide more precise error handling, preventing unnecessary withdrawal blocks and ensuring users understand how to resolve issues. diff --git a/017.md b/017.md new file mode 100644 index 0000000..da6c097 --- /dev/null +++ b/017.md @@ -0,0 +1,35 @@ +Virtual Ash Oyster + +Medium + +# Handling ETH Transfers to Smart Contracts Without receive() or fallback() Functions + +## Summary +In the current implementation, there are no safeguards to handle scenarios where msg.sender is a smart contract without the capability to receive ETH. If such a smart contract interacts with the contract and the contract tries to send ETH to it, the transaction will fail, resulting in a revert. This failure can interrupt the intended functionality and cause operational issues, particularly in scenarios involving withdrawals or payments. + +## Vulnerability Detail +The smart contract does not include explicit checks to determine whether msg.sender is an EOA or a smart contract. Consequently, when the contract attempts to send ETH to an address that is a smart contract without a receive() or fallback() function, the ETH transfer will fail. This is due to the fact that such smart contracts are unable to accept ETH, causing any transaction that involves sending ETH to them to revert. + +## Impact +* Transaction Reverts: ETH transfers to smart contracts without receive() or fallback() functions will fail, causing reverts and potentially leaving the contract's state inconsistent. +* Operational Issues: Users who deploy smart contracts without proper ETH handling will experience failed transactions, disrupting the contract's functionality and potentially causing financial losses or inconvenience. +* User Experience: Users interacting with such contracts may face issues without clear explanations, affecting their experience and trust. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1-L1231 + +## Tool used +Manual Review + +## Recommendation +Implement a function to check if an address is a smart contract before attempting to transfer ETH. This can help avoid sending ETH to addresses that cannot handle it. + +```solidity +function isContract(address account) internal view returns (bool) { + uint256 size; + assembly { + size := extcodesize(account) + } + return size > 0; +} +``` \ No newline at end of file diff --git a/019.md b/019.md new file mode 100644 index 0000000..ab3a1c7 --- /dev/null +++ b/019.md @@ -0,0 +1,90 @@ +Keen Fossilized Viper + +High + +# When duration has ended, an attempt by a variable type depositor to request Withdrawals for staked earnings from the vault attempts to call `Lido::requestWithdrawals` and this fails + +### Details +``` + function withdraw(uint256 side) external { + require(side == FIXED || side == VARIABLE, "IS"); + if (!isStarted()) {} + else if (!isEnded()) {} + else { + return vaultEndedWithdraw(side); + } + } +``` +In a situation where a vault duration has ended, and a variable type depositor tries to withdraw his staked earnings through `LidoVault::withdraw`, this calls fails because it attempts to call the internal function `LidoVault::vaultEndedWithdraw` which is structured to request withdrawals from Lido. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L709C2-L728C6 (line 722) is what specifically tries to requestwithdrawal. Expectations are that variable depositors would be able to withdraw their staked interest based on the vault lifespan and even after the vault lifespan. `LidoVault::vaultEndedWithdraw` is also expected to first check the type of fill before attempting to make a request and a variable fill is not expected to send any withdrawal reques to Lido. + +### Impact +Loss of Staked earnings by Variable depositor after deposit has ended. + +### Tool used +Manual Review + +### POC +``` +// SPDX-License-Identifier: MIT +pragma solidity 0.8.18; + +import {Test, console} from "forge-std/Test.sol"; +import {LidoVault} from "../../src/LidoVault.sol"; +import "../../src/interfaces/ILidoVaultInitializer.sol"; + +contract POC is Test { + LidoVault vault; + + address admin = makeAddr("admin"); + address user1 = makeAddr("user1"); + address user2 = makeAddr("user2"); + function setUp() external { + ILidoVaultInitializer.InitializationParams memory params = ILidoVaultInitializer.InitializationParams({ + vaultId: 1, + duration: 3 weeks, + fixedSideCapacity: 1000 ether, + variableSideCapacity : 30 ether, + earlyExitFeeBps: 1000, + protocolFeeBps: 100, + protocolFeeReceiver: admin + }); + vm.startPrank(admin); + vault = new LidoVault(false); + vault.initialize(params); + vm.stopPrank(); + + vm.deal(user1, 1030 ether); + vm.deal(user2, 75 ether); + } + + + function test_POC() external { + vm.warp(1000); + vm.startPrank(user1); + vault.deposit{value: 940 ether}(0); + vault.deposit{value: 15 ether}(1); + vm.stopPrank(); + + vm.startPrank(user2); + vault.deposit{value: 60 ether}(0); + vault.deposit{value: 15 ether}(1); + vm.stopPrank(); + + vm.warp(1000 + 3 weeks + 1); + console.log(vault.isEnded()); + + vm.startPrank(user2); + vm.expectRevert(); + vault.withdraw(1); + vm.stopPrank(); + } +} +``` + +Comment out `vm.expectRevert();` and run `forge test -vvvv --fork-url ...(add your api)` to see the log mssg + +### Recommendation +`LidoVault::vaultEndedWithdraw` sould protize the fille type first before making any request. + diff --git a/025.md b/025.md new file mode 100644 index 0000000..e56669a --- /dev/null +++ b/025.md @@ -0,0 +1,70 @@ +Restless Foggy Raven + +Medium + +# Updating `protocolFeeBps/protocolFeeReceiverearlyExitFeeBps` in `VaultFactory` is not synchronized with updating them in `LidoVault` + +### Summary + +When initializing [LidoVault:133](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L133) in contract `VaultFactory`, the values `earlyExitFeeBps/protocolFeeBps/protocolFeeReceiver` in the current contract are used. However, when updating these variables, they are not updated to the already initialized `LidoVault` contracts. + +### Root Cause + +These parameters(`earlyExitFeeBps/protocolFeeBps/protocolFeeReceiver`) are only can be update in contract [VaultFactory](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L90-L104). + +### Internal pre-conditions + +None. + +### External pre-conditions + +None. + +### Attack Path + +None. + +### Impact + +These parameters can only be assigned at initialization time and will not change as `VaultFactory ` changes. This may cause the fee + and fee receiver to be out of sync with the factory. + +### PoC + +None. + +### Mitigation + +1. `ILidoVaultInitializer .sol` +```solidity +interface ILidoVaultInitializer { + /// @notice All the required parameters to initialize a vault + struct InitializationParams { + uint256 vaultId; + uint256 duration; + uint256 fixedSideCapacity; + uint256 variableSideCapacity; + address vaultFactory; + } +} +``` +2. `VaultFactory.sol` +```solidity +#Line: 122 + + InitializationParams memory params = InitializationParams({ + vaultId: vaultId, + duration: _duration, + fixedSideCapacity: _fixedSideCapacity, + variableSideCapacity: _variableSideCapacity, + vaultFactory: address(this) + }); +``` +3. `LidoVault.sol` : +- import `IVaultFactory` to get these parameters (earlyExitFeeBps/protocolFeeBps/protocolFeeReceiver) +- make changes: +```solidity +earlyExitFeeBps --> IVaultFactory(vaultFactory).earlyExitFeeBps() +protocolFeeBps --> IVaultFactory(vaultFactory).protocolFeeBps() +protocolFeeReceiver --> IVaultFactory(vaultFactory).protocolFeeReceiver() +``` \ No newline at end of file diff --git a/026.md b/026.md new file mode 100644 index 0000000..d4b487b --- /dev/null +++ b/026.md @@ -0,0 +1,31 @@ +Scrawny Iron Tiger + +High + +# Locked Ether Due to Precision Loss in claimFixedPremium Function + +## Summary +The claimFixedPremium function of the contract contains an issue where Ether can become locked due to precision loss in a mulDiv calculation. The Ether left from the operation (resulting from remainder values) is not claimable by users, causing the contract to accumulate locked funds over time. As a potential solution, the unclaimed Ether could be stored or staked via Lido to prevent permanent loss. + +## Vulnerability Detail +Within the claimFixedPremium function, the calculation to distribute Ether is: + +`uint256 sendAmount = claimBal.mulDiv(variableSideCapacity, fixedIntialTokenTotalSupply);` + +The mulDiv function calculates claimBal * variableSideCapacity / fixedIntialTokenTotalSupply. However, if there is a remainder from this operation (claimBal * variableSideCapacity % fixedIntialTokenTotalSupply), that portion of Ether remains undistributed. Over time, this accumulated Ether remains locked in the contract without a way for users to claim it. + +No mechanism currently exists to handle the remainder, leading to a scenario where small amounts of Ether accumulate and are permanently lost. + +## Impact +This issue causes a growing amount of Ether to be locked within the contract, as small portions of each transaction remain undistributed. As this problem persists over time, the total amount of locked Ether could become significant, effectively reducing user rewards and causing financial loss. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L403 +The vulnerability arises from the remainder in the division, which is not handled, leaving some Ether locked in the contract. + +## Tool used + +Manual Review + +## Recommendation +Consider storing unclaimable Ether resulting from the precision loss in Lido or a similar staking protocol. This would allow the contract to stake the unclaimed Ether and earn rewards, rather than leaving it locked and inaccessible. Alternatively, find a way to fairly redistribute the remaining Ether to users or implement a rounding mechanism that prevents significant amounts from being left unclaimed. \ No newline at end of file diff --git a/027.md b/027.md new file mode 100644 index 0000000..9294c1e --- /dev/null +++ b/027.md @@ -0,0 +1,43 @@ +Late Sable Swan + +Medium + +# Zero-Amount Withdrawal Attack in finalizeVaultEndedWithdrawals() + +## Summary +A malicious user could exploit the function by initiating zero-amount withdrawals, causing unnecessary gas consumption and execution overhead. + +## Vulnerability Detail +The issue arises in the vaultEndedWithdraw() function when called by finalizeVaultEndedWithdrawals(). Specifically, the function allows a withdrawal request to proceed even if the sendAmount is 0, triggering the transferWithdrawnFunds() function with zero ETH. This leads to an inefficient execution and an opportunity for attackers to perform zero-amount transactions that waste gas. +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L739-L758 +If fixedBearerToken[msg.sender] is 0, the sendAmount becomes 0. However, the function continues execution and transfers 0 ETH, which consumes gas unnecessarily. + +## Impact +This vulnerability allows attackers to perform gas-griefing attacks by repeatedly triggering this function with zero-amount transactions, potentially making the contract costly or slow to use for legitimate users, especially in high-traffic scenarios where many users are trying to withdraw their funds. + +## Tool used +Manual Review + +## Recommendation +Introduce a check to ensure the sendAmount is greater than zero before calling transferWithdrawnFunds() to avoid processing unnecessary zero-amount transactions. +Modify the vaultEndedWithdraw() function to include the following condition: +```solidity +... +if (sendAmount > 0) { + transferWithdrawnFunds(msg.sender, sendAmount); +} +... +``` + +Instead of modifying all instances where withdrawals or transfers happen, a better solution is to add a validation in the transferWithdrawnFunds() function itself. This will ensure that no zero-amount transfers occur regardless of where the function is called from. +```solidity +function transferWithdrawnFunds(address recipient, uint256 amount) internal { + require(recipient != address(0), "NZA"); + require(amount > 0, "NZA"); //Amount is zero + + (bool sent, ) = recipient.call{value: amount}(""); + require(sent, "ETF"); + + emit FundsTransferred(amount, recipient); +} +``` diff --git a/028.md b/028.md new file mode 100644 index 0000000..0099165 --- /dev/null +++ b/028.md @@ -0,0 +1,41 @@ +Fierce Neon Guppy + +High + +# Initialization Access will lead to user funds being comprised + +### Summary + +The initialize function lacks of access control. It only checks isFactoryCreated value, but doesn't restrict who can call it(msg.sender). This could allow unauthorized entities to reinitialize the contract or alter critical parameters + +### Root Cause + +In LidoVault.sol:276 lack of checking vault ownership on initialize function +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L276 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +1. users have deposited funds +2. attacker calls the initialize function to reinitialize the vault with malicious parameters. He could set earlyExitFeeBps and protocolFeeBps to large values, which effectively draining user funds as they attempt to withdraw +3. attacker can change protocolFeeReceiver to his own address so that protocol fee will be sent to him + +### Impact + +User Financial Losses: +Users may receive less than what they are entitled to due to manipulated fees. Protocol fees could be sent to an attacker's address. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/031.md b/031.md new file mode 100644 index 0000000..f5b54eb --- /dev/null +++ b/031.md @@ -0,0 +1,71 @@ +Itchy Pebble Piranha + +Medium + +# Users are sometimes blocked from withdrawing their FIXED ETH when the vault is ongoing, because of an invalid request IDs check + +### Summary + +When FIXED users withdraw their deposits, a request is sent to Lido and gets fulfilled later. Lido returns the request IDs for the withdrawals, these IDs are saved in mappings according to the time they're being requested, before the vault starts, before the vault ends, ... + +When withdrawing before the vault starts, the protocol checks if there are previous "before start" pending withdrawal, and if so, it reverts, which makes sense, as it is saved per address: +```solidity +require(fixedToVaultNotStartedWithdrawalRequestIds[msg.sender].length == 0, "WAR"); +``` +However, when withdrawing after the vault starts and before it ends, the protocol also checks if there are "before start" pending withdrawals, which doesn't make sense, as it blocks users from requesting ongoing withdrawals whenever they want, they'll have to wait until the "before start" request is fulfilled on Lido. +```solidity +require( + fixedToVaultOngoingWithdrawalRequestIds[msg.sender].requestIds.length == 0 && + fixedToVaultNotStartedWithdrawalRequestIds[msg.sender].length == 0, + "WAR" +); +``` + +### Root Cause + +`LidoVault::withdraw` checks if the user has a "before vault start" pending withdrawal before allowing him to withdraw his ongoing balance, [here](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L476). + +### Impact + +Users are temporarily blocked from withdrawing their deposited amount while having a pending "before vault start" withdrawal. + +### PoC + +Add the following test in `lido-fiv/test/1.LidoVault.test.ts`: + +```typescript +it("BUG - Can't withdraw ongoing fixed withdrawals", async () => { + const { lidoVault, addr1, addr2, addr3 } = await loadFixture(deployLidoVaultFixture) + + // User 1 deposits 100 FIXED + await lidoVault.connect(addr1).deposit(SIDE.FIXED, { value: parseEther('100') }) + + // User 1 withdraws 100 FIXED + await lidoVault.connect(addr1).withdraw(SIDE.FIXED) + + // Withdrawal is sent to Lido + expect( + (await lidoVault.getFixedToVaultNotStartedWithdrawalRequestIds(addr1.address)).length + ).to.equal(1) + + // User 1 deposits 100 FIXED + await lidoVault.connect(addr1).deposit(SIDE.FIXED, { value: parseEther('700') }) + // User 3 deposits 200 FIXED + await lidoVault.connect(addr3).deposit(SIDE.FIXED, { value: parseEther('300') }) + // user 2 deposits 30 VARIABLE + await lidoVault.connect(addr2).deposit(SIDE.VARIABLE, { value: parseEther('30') }) + + // Vault has started + expect(await lidoVault.isStarted()).to.equal(true) + + // User 1 claims FIXED premium + await lidoVault.connect(addr1).claimFixedPremium() + + // User 1 can't withdraw his ongoing FIXED + await expect(lidoVault.connect(addr1).withdraw(SIDE.FIXED)).to.be.revertedWith('WAR') +}) +``` + +### Mitigation + +Remove `fixedToVaultNotStartedWithdrawalRequestIds[msg.sender].length == 0` from the ongoing FIXED withdrawal check, https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L474-L478. \ No newline at end of file diff --git a/036.md b/036.md new file mode 100644 index 0000000..798f386 --- /dev/null +++ b/036.md @@ -0,0 +1,31 @@ +Big Viridian Lynx + +High + +# Sandwich attack leading to vault start DoS + +## Summary +Two buckets (`fixedETHDepositTokenTotalSupply` and `variableBearerTokenTotalSupply`) are both required to be 100% full for a vault to start. We can manipulate them through a MEV type sandwich attack so that a vault (basically) never starts. + +## Vulnerability Detail +- As soon as a vault is created, we fill the variable side to capacity through `deposit()`. The `variableBearerTokenTotalSupply` bucket is full entirely with our 30 ETH. +- Monitor and wait for fixed side to almost get full. When a `deposit()` transaction from fixed side will fill the fixed bucket 100%, and would jumpstart the vault, we frontrun withdraw our entire 30 ETH through `withdraw()`. Their deposit then executes so the fixed bucket is 100% full, but the variable bucket is empty. We then backrun their deposit and `deposit()` 29.99 ETH back into variable bucket. +- We now monitor the variable bucket which has 0.01 ETH left unsatisfied for a vault start, which is exactly one deposit left. When someone deposits that would fill this bucket (and thus both buckets would be full and a vault would start), we frontrun withdraw our 29.99 ETH and backrun a deposit of 29.98 ETH. +- The variable bucket would now be 29.98 our ETH and 0.01 someone else ETH. This way, the variable bucket will always have 0.01 total remaining and never be full for 3,000+ deposits. +- Anytime someone from variable side wants to withdraw, this just gives us more volume to reclaim. We can deposit whatever amount they withdrew and prolong the attack. People are likely to start withdrawing after they understand the vault is DoS'ed. +- The variable bucket will never be filled (at least for 3,000 deposit attempts from other users), and the vault will not be able to start. + +## Impact +Created vaults can get their starting DoS'ed. Note: Does not have to use full 30/30 ETH, this is just for maximum duration of DoS, could be done with 1 ETH 100 times for example (0.01 min withdraw/depo). + +## Code Snippet +* Vault start conditions in `deposit()`: https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L380-#L384 +* `withdraw()` logic before a vault starts: https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L423-#L469 + +## Tool used +Manual Review + +## Recommendation +Possible remedies +- Blacklist addresses from withdrawing (pre vault start) after they have already withdrawn X amount of times before a vault starts. Requires keeping a counter and associated check during pre-start withdraws. +- Allow `protocolFeeReceiver` or admin to manually jumpstart a vault if it's close to 100% on both sides. diff --git a/041.md b/041.md new file mode 100644 index 0000000..93fd9d6 --- /dev/null +++ b/041.md @@ -0,0 +1,53 @@ +Mean Strawberry Boar + +Medium + +# Some functions can revert if the LidoWithDrawalQueueERC721 is updated + +### Summary + +As the variable MAX_STETH_WITHDRAWAL_AMOUNT cannot be updated and the Lido MAX_STETH_WITHDRAWAL_AMOUNT in the LidoWithdrawalQueueERC721 can be updated (since LidoWithdrawalQueueERC721 is a proxy), the withdraw function will revert if the staked amount exceeds the new Lido MAX_STETH_WITHDRAWAL_AMOUNT value. + +### Root Cause + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L1015 + +The LidoWithdrawalQueue contract reverts if the withdrawal amount is greater than the MAX_STETH_WITHDRAWAL_AMOUNT defined in the same contract. + +function _checkWithdrawalRequestAmount(uint256 _amountOfStETH) internal pure { + if (_amountOfStETH < MIN_STETH_WITHDRAWAL_AMOUNT) { + revert RequestAmountTooSmall(_amountOfStETH); + } + if (_amountOfStETH > MAX_STETH_WITHDRAWAL_AMOUNT) { + revert RequestAmountTooLarge(_amountOfStETH); + } +} + +As the LidoWithdrawalQueue is a proxy, we can assume the likelihood of constants like MAX_STETH_WITHDRAWAL_AMOUNT being updated, even if it's very low. + +Since the variable MAX_STETH_WITHDRAWAL_AMOUNT in the LidoVault cannot be modified, if the value of MAX_STETH_WITHDRAWAL_AMOUNT from LidoVault is greater than the new MAX_STETH_WITHDRAWAL_AMOUNT from LidoWithdrawalQueue, each call to the function calculateWithdrawals will revert. + +The external function withdraw indirectly calls this function under certain conditions. + +### Internal pre-conditions + +1. Staked amount superior to the new MAX_STETH_WITHDRAWAL_AMOUNT + +### External pre-conditions + +1. Update of the Lido WithDrawableQueue contract to modify the value of the constant MAX_STETH_WITHDRAWAL_AMOUNT + +### Attack Path +This is a bug that will occur when Lido updates its protocol, and Lido is not trusted in this context. Moreover, they have no interest in verifying if a change on their end could break the protocols that use Lido in this manner. + +### Impact + +Medium. Low Likehood, High Impact . Loss of funds. Users will not be capable of withdrawing their funds. + +### PoC + +None. + +### Mitigation + +Add a setter to the variable MAX_STETH_WITHDRAWAL_AMOUNT \ No newline at end of file diff --git a/048.md b/048.md new file mode 100644 index 0000000..cb77a18 --- /dev/null +++ b/048.md @@ -0,0 +1,53 @@ +Original Onyx Ram + +Invalid + +# 0xesbee - Incorrect Return Value in getFixedToVaultNotStartedWithdrawalRequestIds and getVariableToVaultOngoingWithdrawalRequestIds Functions + +## Summary +Severity: Medium + +The `getFixedToVaultNotStartedWithdrawalRequestIds` and `getVariableToVaultOngoingWithdrawalRequestIds` functions is intended to return an array of uint256 withdrawal request IDs for a specific user. However, due to the underlying structure of the contract, the functions currently return the entire `WithdrawalRequest` struct instead of the requestIds array. + +The struct `WithdrawalRequest` contains both `timestamp` and `requestIds`, but only the `requestIds` field is relevant for this function’s intended purpose. + +## Vulnerability Detail +The functions currently return the entire struct: +``` +function getFixedToVaultNotStartedWithdrawalRequestIds(address user) public view returns (uint256[] memory) { + return fixedToVaultNotStartedWithdrawalRequestIds[user]; +} +``` +``` + function getVariableToVaultOngoingWithdrawalRequestIds(address user) public view returns (uint256[] memory) { + return variableToVaultOngoingWithdrawalRequestIds[user]; + } +``` +This is incorrect because `fixedToVaultNotStartedWithdrawalRequestIds[user]` and `variableToVaultOngoingWithdrawalRequestIds[user]` is a `WithdrawalRequest` struct, and the function should return only the `requestIds` field from the struct. + +## Impact +### **Incorrect Functionality:** +If deployed, the functions would provide unexpected output to users or other contracts interacting with it. Instead of returning the withdrawal request IDs, it would return the entire `WithdrawalRequest` object, which could cause failure in dApps or external contracts that expect an array of IDs. +### **Type Mismatch / Compiler Error:** +The declared return type of the function is uint256[], but the functions is returning a `WithdrawalRequest` struct, which includes both a `timestamp` and `requestIds`. This would likely result in a Solidity compiler error or runtime issues if the code were deployed without this fix. +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L151 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L154 +## Tool used + +Manual Review + +## Recommendation +To resolve this issue, modify the functions to return only the requestIds field of the `WithdrawalRequest` struct. This ensures that the functions return an array of uint256 values as expected. + +The corrected functions should be as follow: +``` +function getFixedToVaultNotStartedWithdrawalRequestIds(address user) public view returns (uint256[] memory) { + return fixedToVaultNotStartedWithdrawalRequestIds[user].requestIds; +} +``` +``` + function getVariableToVaultOngoingWithdrawalRequestIds(address user) public view returns (uint256[] memory) { + return variableToVaultOngoingWithdrawalRequestIds[user]; + } +``` \ No newline at end of file diff --git a/050.md b/050.md new file mode 100644 index 0000000..0376940 --- /dev/null +++ b/050.md @@ -0,0 +1,29 @@ +Brief Latte Buffalo + +Medium + +# No Check for Valid Protocol Fee Receiver + +## Summary +The factory allows the protocol fee receiver to be changed via `setProtocolFeeReceiver()` without any validation. Setting the protocol fee receiver to an invalid or zero address could lead to funds being locked in the contract. +```Solidity + function setProtocolFeeReceiver(address _protocolFeeReceiver) external onlyOwner { + protocolFeeReceiver = _protocolFeeReceiver; + emit SetProtocolFeeReceiver(protocolFeeReceiver); + } +``` +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L96 +## Tool used + +Manual Review + +## Recommendation +Add a validation check to ensure that the protocol fee receiver is not the zero address: +```solidity +function setProtocolFeeReceiver(address _protocolFeeReceiver) external onlyOwner { + require(_protocolFeeReceiver != address(0), "Invalid address"); + protocolFeeReceiver = _protocolFeeReceiver; + emit SetProtocolFeeReceiver(protocolFeeReceiver); +} +``` \ No newline at end of file diff --git a/052.md b/052.md new file mode 100644 index 0000000..d0b5093 --- /dev/null +++ b/052.md @@ -0,0 +1,43 @@ +Zany Khaki Salmon + +Medium + +# claimFixedPremium Function Allows Claims After Earning Period Has Ended + +### Summary + +The claimFixedPremium function does not check if the current time is within the valid earning period, allowing users to claim rewards even after the vault's earning period (endTime) has passed. This can lead to potential misuse and incorrect token distribution. + +### Root Cause + +The issue stems from the lack of a time-bound check within the claimFixedPremium function. Specifically, the function does not verify whether the current block's timestamp is before the endTime, which marks the conclusion of the vault's earning period. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L395 + +### Internal pre-conditions + +The contract defines a fixed duration for which users can claim rewards (from startTime to endTime). +startTime and endTime are correctly set when the vault begins. + +### External pre-conditions + +The user has a positive balance in fixedClaimToken + +### Attack Path + +A user waits until the earning period has ended (block.timestamp > endTime +The user then calls claimFixedPremium, which does not restrict the call based on the current time. +The user successfully claims a share of the variable side deposits even though the earning period has expired. + +### Impact + +Users can claim rewards after the intended claim period, potentially leading to undesired token distribution or draining of contract funds. + +### PoC + +_No response_ + +### Mitigation + +add this line of code on claimFixedPremium function +require(block.timestamp <= endTime); \ No newline at end of file diff --git a/054.md b/054.md new file mode 100644 index 0000000..94c12a9 --- /dev/null +++ b/054.md @@ -0,0 +1,37 @@ +Modern Gingham Pheasant + +Medium + +# Usage of native `transfer()` will lock variable user funds + +### Summary + +The use of `.transfer()` in [L656](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L656) will result in a revert if the recipient is a smart contract. Since this is the only instance where `transfer()` is used, variable side users will not expect this function and will instead anticipate the use of `call()`. This assumption will lead to issues with withdrawing after the vault has started for variable side users. Since `transfer()` only provides 2300 gas for its execution, any smart contract's callback that consumes more than 2300 gas (which is only enough to emit an event) will cause the transfer to revert. + +### Root Cause + +_No response_ + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Users on the variable side will not be able to withdraw their funds at will for withdrawals initiated while the vault was active. As a result, their funds will remain locked in the vault. + +### PoC + +_No response_ + +### Mitigation + +It is recommended to use `.call()` instead of `.transfer().` \ No newline at end of file diff --git a/058.md b/058.md new file mode 100644 index 0000000..44f8d51 --- /dev/null +++ b/058.md @@ -0,0 +1,37 @@ +Eager Pine Puppy + +Invalid + +# {actor} will {impact} {affected party} + +### Summary + +_No response_ + +### Root Cause + +_No response_ + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/059.md b/059.md new file mode 100644 index 0000000..4989846 --- /dev/null +++ b/059.md @@ -0,0 +1,281 @@ +Amusing Chili Reindeer + +Medium + +# Vault::deposit is vulnerable to DoS any user call via frontrunning txs + +## Vulnerability Detail +Vault::deposit is vulnerable to DoS via frontrunning because anyone is able to block another's user Vault::deposit call. +This is posible because an user can deposit and withdraw at any time, so, frontrunning any deposit calls to make them to revert. +To make this attack the following steps could be used: +1. User U wants to deposit k amount in to vault calling vault deposit. +2. Attacker A is monitoring mempool and sees U deposit call +3. A calculates k2 = fixedSideCapacity - (fixedETHDepositTokenTotalSupply + minimumFixedDeposit) and calls deposit with a higher gas price + K2 amount is deposited but vault is not started because vault is not full it needs minimumFixedDeposit amount +4. U txs is rejected due to this check +```solidity +require(amount <= fixedSideCapacity - fixedETHDepositTokenTotalSupply, "OED"); +``` +5. Attacker now withdraws his position. +6. Attacker can repeat the previous step anytime when a deposit call is attempted to vault + +The following PoC demostrate the issue: +Address1 deposits 80% of vault capacity +Address2 tries to deposit 20% to fill the vault and start it +Address3 front runs address2 call and deposits 15% +Now address2 deposit call is reverted due to max vault capacity is exceeded and DoS his deposit, making unable to start vault + +```js +import { time, loadFixture } from '@nomicfoundation/hardhat-toolbox/network-helpers' +import { anyValue, anyUint } from '@nomicfoundation/hardhat-chai-matchers/withArgs' +import { assert, expect } from 'chai' +import { AddressLike, BigNumberish, ContractTransactionReceipt, parseEther, formatEther } from 'ethers' +import { ethers } from 'hardhat' + +import { LidoVault, VaultFactory } from '../typechain-types' +import { ContractMethodArgs } from '../typechain-types/common' +import { ILidoVaultInitializer } from '../typechain-types/contracts/LidoVault' +import { + compareWithBpsTolerance, + decodeLidoErrorData, + finalizeLidoWithdrawalRequests, + getLidoContract, + getWithdrawalQueueERC721Contract, + submitOracleReport, + BIG_INT_ZERO, + BIG_INT_ONE, + BIG_INT_10K, + DEFAULTS, + SIDE, + ONE_ADDRESS, + ZERO_ADDRESS, + setupMockLidoContracts, +} from './helpers' + +describe('AAALidoVault', function () { + let nextVaultId = 0 + + async function deployVaultFactoryFixture() { + let VaultFactoryFactory = await ethers.getContractFactory('VaultFactory') + + let deployer + let addr1 + let addr2 + let addr3 + let addr4 + let addrs + ;[deployer, addr1, addr2, addr3, addr4, ...addrs] = await ethers.getSigners() + + const protocolFeeBps = DEFAULTS.protocolFeeBps; + const earlyExitFeeBps = DEFAULTS.earlyExitFeeBps; + + let vaultFactory: VaultFactory = (await VaultFactoryFactory.deploy( + protocolFeeBps, + earlyExitFeeBps + )) as any + + let vaultAddress = await vaultFactory.vaultContract(); + + console.log('vaultInfo', vaultAddress) + const vaultContract: LidoVault = await ethers.getContractAt('LidoVault', vaultAddress) + + return { + vaultFactory, + deployer, + vaultContract, + } + } + + async function deployVault({ + durationSeconds = DEFAULTS.durationSeconds, + fixedSideCapacity = DEFAULTS.fixedSideCapacity, + variableSideCapacity = fixedSideCapacity * BigInt(DEFAULTS.fixedPremiumBps) / BigInt(10000), + earlyExitFeeBps = DEFAULTS.earlyExitFeeBps, + protocolFeeBps = DEFAULTS.protocolFeeBps, + protocolFeeReceiver, + admin, + }: { + durationSeconds?: number + fixedSideCapacity?: BigInt + variableSideCapacity?: BigInt + earlyExitFeeBps?: number + protocolFeeBps?: number + protocolFeeReceiver?: string + admin?: string + }) { + let LidoVaultFactory = await ethers.getContractFactory('LidoVault') + + let deployer + let addr1 + let addr2 + let addr3 + let addr4 + let addrs + ;[deployer, addr1, addr2, addr3, addr4, ...addrs] = await ethers.getSigners() + + const feeReceiver = protocolFeeReceiver ?? deployer + + const vaultId = ++nextVaultId + const lidoVault: LidoVault = (await LidoVaultFactory.deploy(false)) as any + + const lidoVaultAddress = await lidoVault.getAddress() + + await lidoVault.initialize({ + vaultId, + duration: durationSeconds, + fixedSideCapacity, + variableSideCapacity, + earlyExitFeeBps, + protocolFeeBps, + protocolFeeReceiver: feeReceiver, + admin: admin ?? deployer + } as ILidoVaultInitializer.InitializationParamsStruct) + + const lidoContract = await getLidoContract(deployer) + const lidoWithdrawalQueueContract = getWithdrawalQueueERC721Contract(deployer) + + return { + lidoVault, + deployer, + addr1, + addr2, + addr3, + addr4, + addrs, + vaultId, + protocolFeeReceiver: feeReceiver, + lidoVaultAddress, + lidoContract, + lidoWithdrawalQueueContract, + } + } + + const deployLidoVaultFixture = () => deployVault({}) + describe('AAAVault not Started', function () { + describe('Deposit', function () { + + describe('Fixed Side', function () { + it('AAATest', async function () { + this.timeout(30000); + + const { lidoVault, addr1, addr2, addr3, lidoWithdrawalQueueContract } = await loadFixture(deployLidoVaultFixture) + + /* + addr1: first depositor: deposits 80% ie fxDMin*16 + addr2: user + addr3: atkr + + addr2 wants to deposit 20% ie fixedDepositMin*4 to start vault + addr3 frontruns and deposits 15% ie fixedDepositMin*3 + addr2 deposit fails + addr3 withdraws deposit + addr3 repeats when some user wants to deposit + */ + console.log("addr1 ",addr1.address); + console.log("addr2 ",addr2.address); + console.log("addr3 ",addr3.address); + + // 5% deposit amount + const fixedDepositMin = + (DEFAULTS.fixedSideCapacity * BigInt(DEFAULTS.minimumFixedDepositBps)) / BIG_INT_10K + + console.log("\naddr1 deposits 80% of vault capacity") + await lidoVault.connect(addr1).deposit( + SIDE.FIXED, + { value: fixedDepositMin * BigInt(16), } + ) + + // Disable automining to simulate front run + await ethers.provider.send("evm_setAutomine", [false]); + await ethers.provider.send("evm_setIntervalMining", [2000]); + + console.log("addr2 tries to deposit 20% to start vault") + await lidoVault.connect(addr2).deposit( + SIDE.FIXED, + { + value: fixedDepositMin * BigInt(4), + gasPrice: 50_000, + gasLimit: 20_000_000 + } + ) + + console.log("addr3 frontruns addr2 deposit and deposit 15%") + await lidoVault.connect(addr3).deposit( + SIDE.FIXED, + { + value: fixedDepositMin * BigInt(3), + gasPrice: 160_000, + gasLimit: 20_000_000 + } + ) + console.log("Now addr2 deposit is rejected cause will overflow vault capacity") + + // Reenable automining + await ethers.provider.send("hardhat_mine", ["1"]); + await ethers.provider.send("evm_setAutomine", [true]); + + console.log("After addr2 failed deposit addr3 withdraws") + const lidoLastRequestId = await lidoWithdrawalQueueContract.getLastRequestId() + const requestIds = [lidoLastRequestId + BIG_INT_ONE]; + + await lidoVault.connect(addr3).withdraw(SIDE.FIXED); + await finalizeLidoWithdrawalRequests(requestIds, DEFAULTS.fixedSideCapacity) + + const finalizedTrx = await lidoVault + .connect(addr3) + .finalizeVaultNotStartedFixedWithdrawals() + + + console.log("\nIf deposit frontrun worked vault hasnt started yet") + console.log( + "lidoVault.isStarted()", + await lidoVault.isStarted() + ); + console.log("\nAddresses vault balances: "); + console.log( + "lidoVault.fixedClaimTokenTotalSupply(addr1)", + await lidoVault.fixedClaimToken(addr1.address) + ); + console.log( + "lidoVault.fixedClaimTokenTotalSupply(addr2)", + await lidoVault.fixedClaimToken(addr2.address) + ); + console.log( + "lidoVault.fixedClaimTokenTotalSupply(addr3)", + await lidoVault.fixedClaimToken(addr3.address) + ); + + }) + }) + + }) + }) +}) +``` + +Save this code as LidoVaultFrontrunDeposit.test.ts in test dir +To execute this test first start a forked node (in this case anvil was used ) +```bash +reset;anvil --fork-url https://rpc.ankr.com/eth --fork-block-number 18562954 --fork-chain-id 31337 --block-base-fee-per-gas 10000 +``` + +Next execute this test with: +```bash +npx hardhat test test/LidoVaultFrontrunDeposit.test.ts --network localhost +``` +Observe deposit call is front runned + +## Impact +Anyone can block another users deposit call frontrunning his deposit method call +Block a vault to start + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L328-L343 + +## Tool used + +Manual Review + +## Recommendation +Implement a wait time between deposit and withdraw calls to prevent this type of DoS + diff --git a/065.md b/065.md new file mode 100644 index 0000000..fd00a6e --- /dev/null +++ b/065.md @@ -0,0 +1,143 @@ +Real Tangelo Lion + +High + +# Potential DoS from not having enough stETH to withdraw funds potentially locking funds + +### Summary + +The `LidoVault.sol` contract sets a minimum deposit amount of 0.01 ETH, allowing users to deposit small amounts into the vault. +The contract interacts with Lido for withdrawals, which has a minimum stETH withdrawal amount of 100 stETH. This is a significant amount compared to the minimum deposit which demonstrates a mismatch between the two requirements and leads to a potential Denial of Service(DoS). + +### Root Cause + +#### Mismatch in Minimum Values: +* Deposit vs. Withdrawal: If users can deposit as little as 0.01 ETH but need to withdraw at least 100 stETH, it creates a situation where users cannot easily access their funds once deposited. +* Inability to Withdraw: Users who deposit small amounts may find themselves unable to meet the withdrawal threshold, effectively locking their funds in the contract. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L62 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1011 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +Here's a potential DoS attack path: +#### User Deposits: +* A user deposits the minimum amount of 0.01 ETH into the vault, expecting to be able to withdraw it later. + +#### Accumulation of Small Balances: +* Multiple users deposit small amounts, leading to a large number of users with balances significantly below the 100 stETH withdrawal threshold. + +#### Inability to Withdraw: +*These users are unable to withdraw their funds because their balances do not meet the minimum withdrawal requirement set by Lido. + +#### Locked Funds: +* Users' funds are effectively locked in the contract, as they cannot withdraw their ETH or stETH due to the high minimum withdrawal threshold. + +#### Denial of Service: +* Users are denied access to their funds, leading to frustration and potential financial loss. This situation can be considered a DoS attack, as it prevents users from accessing the service they expected. + +### Impact + +#### Locked Funds: +* Issue: Users who deposit amounts greater than the minimum deposit (e.g., 0.01 ETH) but less than the minimum stETH withdrawal amount (e.g., 100 stETH) may find themselves unable to withdraw their funds. +* Impact: Users' funds are effectively locked in the contract, as they cannot meet the withdrawal threshold to convert stETH back to ETH. + +#### Financial Loss: +* Issue: Users may incur financial losses if they cannot access their funds, especially if they need liquidity for other investments or expenses. +* Impact: Inability to withdraw funds can lead to missed opportunities or financial strain for users. + +#### Operational Disruption: +* Issue: The contract may face operational challenges if a significant number of users are unable to withdraw their funds. +* Impact: The platform may face increased support requests and operational challenges as users seek to resolve their withdrawal issues. This can strain resources and affect the platform's ability to operate smoothly. + + +The inability for users to withdraw their funds due to the mismatch between deposit and withdrawal minimums poses a significant risk to user satisfaction, trust, and the platform's overall reputation + +### PoC + +```solidity + +// SPDX-License-Identifier: MIT +pragma solidity 0.8.18; + +import "forge-std/Test.sol"; +import "../LidoVault.sol"; +import "../interfaces/ILido.sol"; +import "../interfaces/ILidoWithdrawalQueueERC721.sol"; + +contract LidoVaultTest is Test { + LidoVault vault; + ILido lido; + ILidoWithdrawalQueueERC721 lidoWithdrawalQueue; + + address user1 = address(0x1); + address user2 = address(0x2); + + function setUp() public { + vault = new LidoVault(false); + lido = ILido(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84); + lidoWithdrawalQueue = ILidoWithdrawalQueueERC721(0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1); + + LidoVault.InitializationParams memory params = LidoVault.InitializationParams({ + vaultId: 1, + duration: 30 days, + fixedSideCapacity: 100 ether, + variableSideCapacity: 100 ether, + earlyExitFeeBps: 100, + protocolFeeBps: 50, + protocolFeeReceiver: address(this) + }); + + vault.initialize(params); + } + + function testDoSMinimumDeposit() public { + // User1 deposits just below the minimum deposit amount + vm.startPrank(user1); + vm.deal(user1, 0.009 ether); + vm.expectRevert("MDA"); + vault.deposit{value: 0.009 ether}(0); + vm.stopPrank(); + + // User2 deposits the minimum deposit amount + vm.startPrank(user2); + vm.deal(user2, 0.01 ether); + vault.deposit{value: 0.01 ether}(0); + vm.stopPrank(); + } + + function testDoSMinimumStETHWithdrawal() public { + // Simulate a scenario where the vault has started and user1 has deposited + vm.startPrank(user1); + vm.deal(user1, 1 ether); + vault.deposit{value: 1 ether}(0); + vm.stopPrank(); + + // Fast forward time to simulate vault start + vm.warp(block.timestamp + 1 days); + + // User1 tries to withdraw but the stETH amount is below the minimum withdrawal amount + vm.startPrank(user1); + vm.expectRevert("WM"); + vault.withdraw(0); + vm.stopPrank(); + } +} + +``` + +### Mitigation + +#### Align Minimums +* Ensure that the minimum withdrawal amount is reasonable and aligned with the minimum deposit amount to prevent locking users' funds. +#### Flexible Withdrawals: +* Consider implementing a more flexible withdrawal mechanism that allows users to withdraw smaller amounts, even if it incurs a fee or penalty. \ No newline at end of file diff --git a/066.md b/066.md new file mode 100644 index 0000000..6800b6f --- /dev/null +++ b/066.md @@ -0,0 +1,21 @@ +Droll Lavender Puma + +Medium + +# lack of updates to fix ETHDepositTokenTotalSupply and ETHDepositToken in the withdraw function + +### Summary + +`fixedETHDepositToken` and `fixedETHDepositTokenTotalSupply` are used to track fixed user ETH deposits. These values need to be updated when a fixed user withdraws funds. + +### Root Cause + +In [LidoVault.sol:496](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L496) there is a missing update of `fixedETHDepositToken` and `fixedETHDepositTokenTotalSupply` when Vault started and in progress. + +### Impact + +- Incorrect values for `fixedETHDepositToken` and `fixedETHDepositTokenTotalSupply` during calculation. + +### Mitigation + +These values should be updated during withdrawals. \ No newline at end of file diff --git a/083.md b/083.md new file mode 100644 index 0000000..ccdca52 --- /dev/null +++ b/083.md @@ -0,0 +1,59 @@ +Swift Rouge Grasshopper + +High + +# Vulnerability in `LidoVault::_claimWithdrawals` function: `unlockReceive` and `address(this).balance` leads to Potential Overpayments + + ## Summary + The `LidoVault::_claimWithdrawals` function allows users to claim withdrawals, but it does in a manner that exposes users to potential `overpayments` due to `unlockReceive` flag and `address(this).balance. + ## Vulnerability Detail + The `LidoVault::_claimWithdrawals` function sets `unlockReceive` to `true` at the beginning of the function and resets it to `false` at the end. However, this flag is not properly protected, allowing arbitrary users to send ETH to the contract during the withdrawal process. As a result, the `withdrawnAmount` calculation becomes inflated, which leads to unintended overpayments. + ## Impact + Financial Losses: Users may receive more ETH during withdrawal process, which leads to financial losses to contract owners or other users. + + ## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1168-L1185 + ```solidity + function _claimWithdrawals(address user, uint256[] memory requestIds) internal returns (uint256) { +// @audit-issue: contract receives ETH from any user when true, which makes address(this).balance gets inflated +@> unlockReceive = true; +@> uint256 beforeBalance = address(this).balance; + + // Claim Ether for the burned stETH positions + // this will fail if the request is not finalized + for (uint i = 0; i < requestIds.length; i++) { + lidoWithdrawalQueue.claimWithdrawal(requestIds[i]); // amount gets transferred from `lido` to this contract. + } +// @audit-issue: contract balance inflated(claimed balance + arbitrary user's ETh deposit) +@> uint256 withdrawnAmount = address(this).balance - beforeBalance; // user receives more amount than intended. + require(withdrawnAmount > 0, "IWA"); + + emit WithdrawalClaimed(withdrawnAmount, requestIds, user); + + unlockReceive = false; + return withdrawnAmount; + } + ``` + ## Proof of Concept-1 + 1. An user requested for withdrawal which triggered `LidoVault::_claimWithdrawals` function. + 2. Now `unlockReceive` sets to `true` which allows `LidoVault::receive` function to receive ETH. + 3. Assume the ` uint256 beforeBalance = address(this).balance;` is `1000 Ether`. + 4. Arbitrary user sends `10 ether` to this contract, because `unlockReceive = true`. + 5. Now this line `lidoWithdrawalQueue.claimWithdrawal(requestIds[i]);` triggered, where `lidoWithdrawalQueue` transfers the amount to this contract. + 6. Assume `lidoWithdrawalQueue` sends `100 ether` to this contract. + 7. So, `100 ether` is intended withdrawal that should be received by the user. + 8. But this line `uint256 withdrawnAmount = address(this).balance - beforeBalance;` makes `withdrawn Amount = (1000 + 10 +100) - 1000 ether = 110 ether` . + 9. So, user receives more ether than intended. + + ## Proof of Concept-2 +1. `user1` requested for withdrawal which triggered `LidoVault::_claimWithdrawals` function, which sets `unlockReceive = true`. +2. Now while `_claimWithdrawals::for` loop executing, `user2` initiated his withdrawal request and his transaction is executed first which sets `unlockReceive = false`. +3. since `unlockReceive = false`, the contract won't receive any incoming ether. +4. So, `user1's` transaction gets reverted. + ## Tool used + + Manual Review + + ## Recommendation + Keep track of withdrawn amounts instead of relying on `address(this).balance`. + \ No newline at end of file diff --git a/084.md b/084.md new file mode 100644 index 0000000..942a15f --- /dev/null +++ b/084.md @@ -0,0 +1,36 @@ +Passive Denim Yeti + +Medium + +# Due to using strict > comparison operator, accumulated Lido earnings are distributed with a delay + +## Summary +There's an assertion here in `withdraw`'s **vault-is-in-progress STATE** flow to see when the earnings on Lido are high enough for their distribution to not be blocked by the `MIN_STETH_WITHDRAWAL_AMOUNT` native Lido limit: +```solidity + // staking earnings have accumulated on Lido + if (lidoStETHBalance > fixedETHDeposits + minStETHWithdrawalAmount()) { +``` + +## Vulnerability Detail +However, the earnings are already sufficient enough for the distribution when the `lidoStETHBalance` is at least equal to the `fixedETHDeposits + minStETHWithdrawalAmount()`. + +Please note that `fixedETHDeposits` here is `==` `fixedSidestETHOnStartCapacity`. + +## Impact +In some edge case, there'll be a delay when the variable-side withdrawer will not get the portion of the distributed and accumulated Lido earnings, because the `if(...)` check will not be fired on time. + +And he will lose a portion of the earnings fee that he **may be entitled to**. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L524 + +## Tool used +Manual review. + +## Recommendation +Consider using a more loose comparison operator here to prevent that kind of edge case that may be irritating for the users. +```diff + // staking earnings have accumulated on Lido +- if (lidoStETHBalance > fixedETHDeposits + minStETHWithdrawalAmount()) { ++ if (lidoStETHBalance >= fixedETHDeposits + minStETHWithdrawalAmount()) { +``` \ No newline at end of file diff --git a/089.md b/089.md new file mode 100644 index 0000000..240d0ba --- /dev/null +++ b/089.md @@ -0,0 +1,40 @@ +Proper Rainbow Dinosaur + +High + +# The contract will result in compilation error + +### Summary + +In LidoVault.sol:154 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L154 +The function getVariableToVaultOngoingWithdrawalRequestIds uses the variable variableToVaultOngoingWithdrawalRequestIds before that variable is declared which will result in compilation error. + + +### Root Cause + +_No response_ + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +The contract will not compile and deploy. + +### PoC + +_No response_ + +### Mitigation + +First declare the variable and then use it in a function. \ No newline at end of file diff --git a/091.md b/091.md new file mode 100644 index 0000000..16e71d2 --- /dev/null +++ b/091.md @@ -0,0 +1,42 @@ +Proper Rainbow Dinosaur + +Medium + +# Logical errors because of deleting the member of an array + +### Summary + +In LidoVault.sol:600 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L601 +When we delete the member of an array, it sets it to address(0), and if the array is used to check the presence of the user, the check will fail. + + +### Root Cause + +_No response_ + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +Swap that element with the last element using the function pop +fixedOngoingWithdrawalUsers[i] = fixedOngoingWithdrawalUsers[arrayLength - 1]; +fixedOngoingWithdrawalUsers.pop(); \ No newline at end of file diff --git a/094.md b/094.md new file mode 100644 index 0000000..3e6fce5 --- /dev/null +++ b/094.md @@ -0,0 +1,20 @@ +Calm Bamboo Parrot + +Medium + +# Potential Vulnerability in `Early Exit Fee` Calculation + +## Summary +The early exit fee mechanism in the LidoVault contract may allow fixed side users to withdraw without paying a sufficient penalty as the fee decreases over time. This could result in a loss of yield for variable side users, contradicting the protocol's intended compensation structure. +## Vulnerability Detail +The early exit fee is designed to decrease over time, incentivizing fixed side users to remain in the vault until its end. However, if the fee is set to a very low initial value i.e just above 0 enough to pass this check ` require(params.earlyExitFeeBps != 0, "NEI");`, it can quickly decrease to zero, especially if the vault duration is long or if the fee calculation does not enforce a minimum threshold. This allows fixed users to withdraw without penalty, leading to reduced compensation for variable users who rely on these fees to offset their diminished yield. +## Impact +Fixed users can potentially withdraw without paying a penalty, leading to a loss of yield for variable users. +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L982C3-L998C4 +## Tool used + +Manual Review + +## Recommendation +Implement a Minimum Fee Threshold: Introduce a minimum early exit fee to ensure that the fee does not decrease to zero before the vault ends. This threshold should be sufficient to compensate variable users adequately. \ No newline at end of file diff --git a/097.md b/097.md new file mode 100644 index 0000000..221d570 --- /dev/null +++ b/097.md @@ -0,0 +1,154 @@ +Cuddly Scarlet Antelope + +High + +# FIXED side users receive part of the yield as well which results in losses for the VARIABLE side + +## Summary +Based on the info provided in the README the `FIXED` side users should receive a premium that is calculated based on the deposits from the `VARIABLE` side. The `VARIABLE` side loses their deposits because of this and all of them are distributed as premium for the `FIXED` side, the `VARIABLE` side receive yield generated by the stETH. The problem is that the current implementations makes the `FIXED` side receive part of the yield as well which is wrong in terms of the business logic. This means that `FIXED` side receive yield and premium while the `VARIABLE` side receives only yield which disincentivizes the `VARIABLE` side + +## Vulnerability Detail + +When the vault is started the `fixedSidestETHOnStartCapacity` is set to the `stakingBalance()` + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L387 + + +```solidity +if ( + fixedETHDepositTokenTotalSupply == fixedSideCapacity && variableBearerTokenTotalSupply == variableSideCapacity + ) { + startTime = block.timestamp; + endTime = block.timestamp + duration; +>> fixedSidestETHOnStartCapacity = stakingBalance(); + fixedIntialTokenTotalSupply = fixedClaimTokenTotalSupply; + emit VaultStarted(block.timestamp, msg.sender); + } + +``` + +This is wrong because the `stakingBalance()` returns the stEth from Lido. This means that the `FIXED` side are receiving the whole yield that was generated while the vault was still collecting funds in order to start. We can't calculate how much time it will take for the vault to start because this depends on the users. The protocol always converts the ETH to stEth when someone deposits meaning that we will start generating yield right from the deposit from the first `FIXED` side participant. Later this `fixedSidestETHOnStartCapacity` is used when the vault ends and the `FIXED` side has to get their initial collateral back. + +This happens in `finalizeVaultEndedWithdrawals()` here: + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L677 + +```solidity + + function finalizeVaultEndedWithdrawals(uint256 side) external { + require(side == FIXED || side == VARIABLE, "IS"); + //@note this is updated in vaultEndedWithdraw() + if (vaultEndedWithdrawalsFinalized) { + return vaultEndedWithdraw(side); + } + require(vaultEndedWithdrawalRequestIds.length != 0 && !vaultEndedWithdrawalsFinalized, "WNR"); + + vaultEndedWithdrawalsFinalized = true; + + // claim any ongoing fixed withdrawals too + claimOngoingFixedWithdrawals(); + uint256 amountWithdrawn = claimWithdrawals(msg.sender, vaultEndedWithdrawalRequestIds); +>> uint256 fixedETHDeposit = fixedSidestETHOnStartCapacity; + + if (amountWithdrawn > fixedETHDeposit) { + + vaultEndedStakingEarnings = amountWithdrawn - fixedETHDeposit; +>> vaultEndedFixedDepositsFunds = fixedETHDeposit; + } else { + vaultEndedFixedDepositsFunds = amountWithdrawn; + } + + uint256 protocolFee = applyProtocolFee(vaultEndedStakingEarnings); + vaultEndedStakingEarnings -= protocolFee; + + + emit LidoWithdrawalFinalized(msg.sender, vaultEndedWithdrawalRequestIds, side, true, true); + + return vaultEndedWithdraw(side); + } +``` + +The `vaultEndedFixedDepositsFunds` is used to track the amount of ETH used to cover the returning of fixed users initial collateral. + +This is the formula in the `vaultEndedWithdraw()` where the user's gets his initial deposited collateral back + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L748 + +```solidity + +...MORE CODE + +function vaultEndedWithdraw(uint256 side) internal { + + if (side == FIXED) { + require( + fixedToVaultOngoingWithdrawalRequestIds[msg.sender].requestIds.length == 0 && + fixedToVaultNotStartedWithdrawalRequestIds[msg.sender].length == 0, + "WAR" + ); + + uint256 sendAmount = fixedToPendingWithdrawalAmount[msg.sender]; + + // they submitted a withdraw before the vault had ended and the vault ending should have claimed it + if (sendAmount > 0) { + delete fixedToPendingWithdrawalAmount[msg.sender]; + } else { + uint256 bearerBalance = fixedBearerToken[msg.sender]; + //uint256 bearerBalance = fixedBearerToken.balanceOf(msg.sender); + require(bearerBalance > 0, "NBT"); +>> sendAmount = fixedBearerToken[msg.sender].mulDiv(vaultEndedFixedDepositsFunds, fixedLidoSharesTotalSupply()); + + fixedBearerToken[msg.sender] = 0; + fixedBearerTokenTotalSupply -= bearerBalance; + vaultEndedFixedDepositsFunds -= sendAmount; + } + + transferWithdrawnFunds(msg.sender, sendAmount); + + emit FixedFundsWithdrawn(sendAmount, msg.sender, true, true); + return; + + } + +...MORE CODE + +``` + +As we can see we are multiplying it by the `vaultEndedFixedDepositsFunds` meaning that he will get more than he initially deposited. He gets more because this variable contains part of the yield that was generated when the vault has not started yet. + +## Impact + +Impact is High because `VARIABLE` users are not getting the whole yield that was generated from Lido + +Likelihood is also High because this happens everytime + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L387 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L677 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L748 + +## Tool used + +Manual Review + +## Recommendation + +When starting the vault just set the to the `fixedSideCapacity` + +```diff + + if ( + fixedETHDepositTokenTotalSupply == fixedSideCapacity && variableBearerTokenTotalSupply == variableSideCapacity + ) { + startTime = block.timestamp; + endTime = block.timestamp + duration; +- fixedSidestETHOnStartCapacity = stakingBalance(); ++ fixedSidestETHOnStartCapacity = fixedSideCapacity; + fixedIntialTokenTotalSupply = fixedClaimTokenTotalSupply; + emit VaultStarted(block.timestamp, msg.sender); + } + +``` \ No newline at end of file diff --git a/099.md b/099.md new file mode 100644 index 0000000..f371b36 --- /dev/null +++ b/099.md @@ -0,0 +1,40 @@ +Fierce Neon Guppy + +Medium + +# vault won't start due to capacities restriction + +### Summary + +The deposit restriction of fixed side and variable side must be equal to their respective capacities in `LidoVault.sol` may cause the vault never starts + +### Root Cause + +in `LidoVault.sol:381` The vault only starts when the total deposits of the fixed side and variable side are exactly equal to their respective capacities. +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L381 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Vault May Not Start: Users are unaware of remaining capacity, they might attempt to deposit amounts exceeding the remaining capacity which leading to transaction failure. If no user deposits the exact amount of the remaining capacity, the vault will remain unstarted indefinitely, users' funds are locked, they cannot earn returns or be withdrawn. Users get poor user experience and funds remain idle for a long time. + +### PoC + +_No response_ + +### Mitigation + +1. Add functions to query the remaining capacity +2. Allow deposits exceeding capacity and refund the excess +3. Modify the vault's start conditions to avoid strict capacity requirements \ No newline at end of file diff --git a/100.md b/100.md new file mode 100644 index 0000000..d5130f2 --- /dev/null +++ b/100.md @@ -0,0 +1,76 @@ +Thankful Alabaster Porcupine + +Medium + +# Cross-Chain Replay Attack Vulnerability in createVault Function + +## Summary + +duplicate of https://github.com/code-423n4/2022-11-stakehouse-findings/issues/154 + +check this too https://github.com/code-423n4/2022-11-stakehouse-findings/issues/154#issuecomment-1332212179 + +## Vulnerability Detail + +The `createVault` function does not include the chain.id in its parameters or perform any check to ensure that the transaction is being executed on the correct chain. This omission makes it possible for an attacker to replay a transaction on a different network, potentially leading to the creation of the same vault address and unauthorized access to funds. + +```solidity + /// @notice Deploys a new vault + function createVault( + uint256 _fixedSideCapacity, + uint256 _duration, + uint256 _variableSideCapacity + ) public virtual { + // Deploy vault (Note: this does not run constructor) + address vaultAddress = Clones.clone(vaultContract); + + require(vaultAddress != address(0), "FTC"); + + // Store vault info + uint256 vaultId = nextVaultId++; + vaultInfo[vaultId] = VaultInfo({creator: msg.sender, addr: vaultAddress}); + vaultAddrToId[vaultAddress] = vaultId; + + InitializationParams memory params = InitializationParams({ + vaultId: vaultId, + duration: _duration, + fixedSideCapacity: _fixedSideCapacity, + variableSideCapacity: _variableSideCapacity, + earlyExitFeeBps: earlyExitFeeBps, + protocolFeeBps: protocolFeeBps, + protocolFeeReceiver: protocolFeeReceiver + }); + + // Initialize vault + ILidoVault(vaultAddress).initialize(params); + + emit VaultCreated( + vaultId, + _duration, + _fixedSideCapacity, + _variableSideCapacity, + earlyExitFeeBps, + protocolFeeBps, + protocolFeeReceiver, + msg.sender, + vaultAddress + ); + } +``` + + +## Impact + +The createVault function in VaultFactory.sol is susceptible to cross-chain replay attacks. This vulnerability can lead to unintended consequences, such as unauthorized creation of vaults or theft of funds, if a transaction valid on one blockchain is replayed on another blockchain. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/VaultFactory.sol#L106C1-L146C4 + +## Tool used + +Manual Review + +## Recommendation + +Include the chain.id \ No newline at end of file diff --git a/103.md b/103.md new file mode 100644 index 0000000..56e363e --- /dev/null +++ b/103.md @@ -0,0 +1,43 @@ +Zany Khaki Salmon + +High + +# Withdrawal Function Reverts on Variable Side Withdrawal, after vault started, Due to Uninitialized variableToWithdrawnStakingEarningsInShares Mapping + +### Summary + +When users attempt to withdraw their earnings from the variable side after the vault has started, the transaction reverts. This happens because the variableToWithdrawnStakingEarningsInShares[msg.sender] mapping is always initialized to 0 when the contract first tries to access or modify it. As a result, the function attempts to perform calculations(division) using 0, causing the function to revert. +the function becomes like this => 0.mulDiv(lidoStETHBalance, currentStakes). + +### Root Cause + +The mapping variableToWithdrawnStakingEarningsInShares[msg.sender] is always initialized to 0. When the contract attempts to calculate the withdrawal amount using the formula: + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L528 + +and the variableToWithdrawnStakingEarningsInShares[msg.sender] is not set before, the contract tries to set the value after this calculation but the function always reverts. + +### Internal pre-conditions + +The vault has already started. + +### External pre-conditions + +Users must have staked funds in the variable side. + +### Attack Path + +The user can't withdraw after the vault has started. + +### Impact + +Users are unable to withdraw their earnings from the variable side once the vault has started, as the contract always reverts. +This affects the functionality of withdrawals, particularly in live environments where users expect to withdraw their staked funds after the vault has started. + +### PoC + +_No response_ + +### Mitigation + +Before performing calculations on variableToWithdrawnStakingEarningsInShares[msg.sender], ensure the mapping is initialized correctly or add logic to handle cases where it has not been previously set. \ No newline at end of file diff --git a/105.md b/105.md new file mode 100644 index 0000000..741601c --- /dev/null +++ b/105.md @@ -0,0 +1,48 @@ +Crazy Ocean Nightingale + +Medium + +# Attacker will DoS `LidoVault` up to 36 days which will ruin expected apr for all parties involved + +### Summary + +The parameters of each `LidoVault` are tuned such that fixed depositors get an upfront premium and variable depositors in return get all the yield produxed by the fixed depositors' deposits. + +However, the protocol does not account for the fact that Lido may be DoSed for up to 36 days if it enters [bunker](https://blog.lido.fi/just-how-fast-are-ethereum-withdrawals-using-the-lido-protocol/) mode. Assuming the return is 4% a year, users are losing approximately `4 * 36 / 365 == 0.4 %`, which goes against the intended returns of the protocol. + +Additionally, an attacker may forcefully trigger this by transferring only [up](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L712) to 100 wei of steth, which will make the protocol request an withdrawal and be on hold for 36 days. + +The protocol should allow users to withdraw by swapping or similar, taking a much lower amount such as 0.12%, described [here](https://blog.lido.fi/just-how-fast-are-ethereum-withdrawals-using-the-lido-protocol/). + +### Root Cause + +In `LidoVault:712`, anyone may transfer just 100 wei of steth and DoS the protocol, so fixed, variable and the owner can not withdraw their funds for up to 36 days. + +### Internal pre-conditions + +None. + +### External pre-conditions + +Lido enters bunker mode, which is in scope as it happens when a mass slashing event happens, which is in scope +> The Lido Liquid Staking protocol can experience slashing incidents (such as this https://blog.lido.fi/post-mortem-launchnodes-slashing-incident/). These incidents will decrease income from deposits to the Lido Liquid Staking protocol and could decrease the stETH balance. The contract must be operational after it + +### Attack Path + +1. Vault has already requested all withdrawals, but they have not yet been claimed, so funds are in the protocol but it does not hold stEth anymore. +2. Attacker transfers 100 wei of steth, triggering the [request](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L722) of this 100 steth. +3. All funds are DoSed for 36 days. + +### Impact + +36 days DoS, which means the protocol can not get the expected interest rate calculated. + +### PoC + +Look at the function `LidoVault::vaultEndedWithdraw()` for confirmation. + +### Mitigation + +Firstly, an attacker should not be able to transfer 100 wei of steth and initiate a request because of this. The threshold should be computed based on an estimated earnings left to withdraw for variable depositors and fixed depositors that have not claimed, not just 100. + +Secondly, it would be best if there was an alternative way to withdraw in case requests are taking too much. \ No newline at end of file diff --git a/111.md b/111.md new file mode 100644 index 0000000..fc0fe14 --- /dev/null +++ b/111.md @@ -0,0 +1,47 @@ +Acrobatic Charcoal Turkey + +Medium + +# Funds can become stuck in the contract due to a lack of validation on the maximum `fixedSideCapacity` value. + +### Summary + +The README states the following: + +> Withdrawal is limited because there is a maximum stETH withdrawal amount of 1000 stETH. This means that if a withdrawal exceeds 1000 stETH, it is split into multiple withdrawal requests and placed in these arrays with undefined length. This happens in the function calculateWithdrawals. Given this situation, we have set the fixedSideCapacity to 100,000 ETH. This ensures that any transaction involving withdrawals from the Lido Liquid Staking protocol will be sufficiently sized to fit within a single Ethereum block. + +However, there is no restriction within the code to validate that `fixedSideCapacity` <= 100,000 ETH. + +In the worst-case scenario, this could lead to all the stETH in the contract becoming stuck and unclaimable. + +### Root Cause + +Missing validation in the `initialize()` function: +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L276-L309 + +### Internal pre-conditions + +* `fixedSideCapacity` is set to a value > 100,000 ETH. + +### External pre-conditions + +* The vault is started with more than 100,000 ETH. + +### Attack Path + +1. Users deposit more than 100,000 ETH to the Fixed side. +2. The vault operates as expected until the end. +3. After the end, when the `withdraw()` function is called, it reverts due to exceeding the block gas limit. + +### Impact + +* README requirement violation. +* Loss of funds. + +### PoC + +Not needed. + +### Mitigation + +Add a max `fixedSideCapacity` value validation to the `initialize()` function. \ No newline at end of file diff --git a/112.md b/112.md new file mode 100644 index 0000000..9316a7c --- /dev/null +++ b/112.md @@ -0,0 +1,47 @@ +Acrobatic Charcoal Turkey + +Medium + +# Fixed side users will not be able to withdraw during the `before the Vault starts` period in an edge case scenario. + +### Summary + +The README states the following for the section `What properties/invariants do you want to hold even if breaking them has a low/unknown impact?`: + +> Users on both fixed and variable sides can withdraw their initial capital before the Vault starts. + +In the case where a user deposits in the fixed side and calls `withdraw()`, their deposit will be converted into a Lido withdrawal request that will be placed in the Lido withdrawal queue. + +However, there is no restriction preventing the user from depositing again into the fixed side. In this case, they will not be able to call `withdraw()` again, even if the Vault has not started. + +As the Lido withdrawal finalization time is unknown, it could occur after the Vault starts, leading to a breach of the protocol invariant, as the user will not be able to withdraw their initial capital before the Vault starts. + +### Root Cause + +Missing validation in the `deposit()` function to check if the user has no withdrawal request on the Fixed side: +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L336-L365 + +### Internal pre-conditions + +* The user deposited to the fixed side and called `withdraw()` before the Vault started. +* The user deposited again to the fixed side. + +### External pre-conditions + +* The Lido withdrawal request is not processed. + +### Attack Path + +1. The user tries to call `withdraw()` before the Vault starts, but the transaction is reverted. + +### Impact + +* Broken protocol invariant. + +### PoC + +Not needed. + +### Mitigation + +Add validation in the `deposit()` function to prevent a user from depositing when the `fixedToVaultNotStartedWithdrawalRequestIds` array is not empty. \ No newline at end of file diff --git a/116.md b/116.md new file mode 100644 index 0000000..802903a --- /dev/null +++ b/116.md @@ -0,0 +1,77 @@ +Cheerful Admiral Peacock + +Medium + +# Sandwich Attack Vulnerability and Comment-Code Discrepancy in `LidoVault.sol:deposit` Mechanism Leads to Protocol Malfunction and Poor User Experience + +### Summary: + +An attacker can execute a sandwich attack to prevent legitimate users from depositing on both sides of the vault. This attack can cause protocol malfunction and lead to a poor user experience by manipulating the remaining capacity to be less than the minimum deposit amount, only allowing users to deposit the minimumDepositAmount to prevent reverts. +Additionally, there is a discrepancy between a code comment stating "no refunds allowed" and the actual implementation of the withdraw function, which does allow withdrawals (refunds) before the vault starts. +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L368 + +```solidity +// no refunds allowed +require(amount <= variableSideCapacity - variableBearerTokenTotalSupply, "OED"); +``` + +### Root Cause: + +The vulnerability stems from the check remainingCapacity >= minimumDepositAmount in the deposit function, which can be exploited by an attacker to force all user transactions above minimumDepositAmount to revert, leading to protocol malfunction and a poor user experience. + +### Internal pre-conditions: +The vault has not started (!isStarted() is true). + +### External pre-conditions: +The attacker can monitor the mempool and front-run transactions. +The attacker has sufficient ETH to perform the attack. + +### Impact: +Legitimate users are unable to deposit to the variable side as intended. +The protocol malfunctions, not operating as designed. +Poor user experience due to failed transactions and inability to participate as expected. +Increased gas costs for users due to failed transactions. + +### PoC: +The proof of concept is written for the variable side, as withdrawals in that part of the vault are instant and do not require a withdrawal request from Lido. +```solidity + function testDdosSandwichAttack() public { + // vault has been initialized with 100eth fixed part and 50 eth varaible part + address attacker = makeAddr("attacker"); + address victim = makeAddr("victim"); + + uint256 minimumDepositAmount = 0.01 ether; // Minimum deposit is 0.01 ETH + + // Victim attempts to deposit + uint256 victimDeposit = 0.02 ether; + vm.deal(victim, victimDeposit); + + // Front-run: Attacker deposits just enough to leave less than minimum deposit amount + uint256 attackerDeposit = VARIABLE_CAPACITY - victimDeposit + 1 wei; + vm.deal(attacker, attackerDeposit); + vm.prank(attacker); + lidoVault.deposit{value: attackerDeposit}(1); // Variable side + + // Victim's transaction should now revert + vm.prank(victim); + vm.expectRevert(bytes("OED")); // Remaining Capacity error + lidoVault.deposit{value: victimDeposit}(1); + + // Attacker withdraws their variable part + vm.prank(attacker); + lidoVault.withdraw(1); // 1 for VARIABLE side + + // Assert that the attacker has successfully withdrawn + assertEq(lidoVault.variableBearerToken(attacker), 0); + assertEq(address(attacker).balance, attackerDeposit); + + // Assert that the vault has not started and victim couldn't deposit + assertEq(lidoVault.isStarted(), false); + assertEq(lidoVault.variableBearerToken(victim), 0); + } +``` + +### Mitigation: +1. Remove or update the "no refunds allowed" comment to accurately reflect the contract's behavior. +2. If refunds are not intended, modify the withdraw function to disallow withdrawals before the vault starts. +3. If refunds are intended, add a time lock to prevent user withdrawals with the behavior explained above. diff --git a/121.md b/121.md new file mode 100644 index 0000000..9a9bb6d --- /dev/null +++ b/121.md @@ -0,0 +1,87 @@ +Early Foggy Ostrich + +High + +# Vault Balance Manipulation Through stETH Transfers + +## Summary + +A vault is started when both the `fixedSideCapacity` and `variableSideCapacity` are fully deposited. +For example, the vault might require 1000 ETH for the fixed side and 100 ETH for the variable side. +The `fixedSidestETHOnStartCapacity` is set based on the vault's total staked ETH (including stETH) at the moment +of initiation, determined by: + +```solidity +fixedSidestETHOnStartCapacity = stakingBalance(); +``` + +However, this balance can be manipulated by directly transferring stETH to the vault contract before the vault starts. +For example, a user could `transfer` 1000 stETH to the vault from a wallet. This results in: + +```solidity +fixedSidestETHOnStartCapacity = 2000 stETH; +``` + +Despite only 1000 ETH being the designated capacity for the fixed side, the staked balance effectively doubles to 2000 stETH. +As a result, the vault’s initial fixed balance is inflated. However, for the user not to lose funds, +the fixed side must still meet the full amount of fixed capacity, so other users cannot claim his transfered stETH. + +## Code Snippet + +```solidity +if ( + fixedETHDepositTokenTotalSupply == fixedSideCapacity && + variableBearerTokenTotalSupply == variableSideCapacity + ) { + startTime = block.timestamp; + endTime = block.timestamp + duration; + fixedSidestETHOnStartCapacity = stakingBalance(); + fixedIntialTokenTotalSupply = fixedClaimTokenTotalSupply; + emit VaultStarted(block.timestamp, msg.sender); + } +) +``` + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L387 + +Additionally, this manipulation can happen after the vault has been created. +If a user transfers stETH to the vault after it has started, it is recognized as part of the vault's staked earnings. +This would allow a user who fully funds the variable side to: + +- Receive the full amount of the transferred stETH back. +- Accumulate the yield generated by the transferred amount. + +This essentially allows the variable user to benefit from the transferred stETH without the +fixed users gaining a proportional reward, creating an imbalance in how rewards are distributed. + +## Context + +To contextualize this issue, it's important to note that: + +["It is acceptable to create contracts where, for example, Fixed capacity is much smaller than Variable Capacity. +Contracts with those parameters will never be profitable for Variable users, and we assume no one will deposit in them." ](https://audits.sherlock.xyz/contests/509) + +This highlights the inherent design risks in the vault's capacity structure. + +## Impact + +The vulnerability allows users to manipulate the vault’s fixed capacity, creating an unfair advantage for either fixed or variable depositors. This can lead to inflated yield calculations and imbalanced rewards. + +## Remediation + +Consider leveraging the `getPooledEthByShares` function, which utilizes the shares submitted via the deposit function. + +```solidity +if ( + fixedETHDepositTokenTotalSupply == fixedSideCapacity && + variableBearerTokenTotalSupply == variableSideCapacity + ) { + startTime = block.timestamp; + endTime = block.timestamp + duration; +-- fixedSidestETHOnStartCapacity = stakingBalance(); +++ fixedSidestETHOnStartCapacity = lido.getPooledEthByShares(fixedClaimTokenTotalSupply); + fixedIntialTokenTotalSupply = fixedClaimTokenTotalSupply; + emit VaultStarted(block.timestamp, msg.sender); + } +) +``` \ No newline at end of file diff --git a/123.md b/123.md new file mode 100644 index 0000000..e9334c4 --- /dev/null +++ b/123.md @@ -0,0 +1,29 @@ +Feisty Lipstick Gibbon + +Medium + +# The incorrect use of calldata + +## Summary + +## Vulnerability Detail + +Using `calldata` for function return values is not allowed in Solidity. The calldata keyword is specifically intended for input parameters in external functions to optimize gas usage when dealing with read-only data passed from outside the contract. However, return values must reside in memory or storage because they often need to be modified, and calldata is immutable. + +Return values must typically reside in `memory` or `storage`, as they need to be stored temporarily (in memory) or persistently (in storage). + +## Impact + +Attempting to use `calldata` for return types will result in a compilation error. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/interfaces/ILidoWithdrawalQueueERC721.sol#L21 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/interfaces/ILidoWithdrawalQueueERC721.sol#L29 + +## Tool used + +Manual Review + +## Recommendation \ No newline at end of file diff --git a/124.md b/124.md new file mode 100644 index 0000000..ec0de20 --- /dev/null +++ b/124.md @@ -0,0 +1,25 @@ +Calm Bamboo Parrot + +Medium + +# Potential Race Condition in `deposit` Function + +## Summary +The `fixedIntialTokenTotalSupply` and `fixedSidestETHOnStartCapacity` state variables are intended to be initialized only once when the vault reaches its capacity. However, if multiple users deposit at the same time, race conditions may lead to these variables being incorrectly set multiple times. +## Vulnerability Detail +When the vault reaches its capacity, the deposit function is triggered, and both `fixedIntialTokenTotalSupply` and `fixedSidestETHOnStartCapacity` are set. If multiple deposits are made concurrently, it’s possible for the state variables to be updated multiple times before the vault state is finalized, leading to incorrect or unexpected values. + +## Impact +Incorrect values for `fixedIntialTokenTotalSupply` and `fixedSidestETHOnStartCapacity` could affect the calculation of fixed user premiums, potentially leading to unfair distributions or incorrect payouts. +This could also impact the overall dynamics of the vault, as the incorrect values might influence the contract's behavior and calculations. +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L328C1-L393C1 +## Tool used + +Manual Review +## Recommendation +To prevent this race condition, consider implementing a mutex or a state check that ensures the state variables are only set once, for example: + +Add a `boolean` flag that indicates whether the vault has started. Set this flag when the vault reaches capacity and check this flag before modifying the state variables. +Reorganize the deposit logic to ensure that variable initialization occurs before any state changes that could affect subsequent transactions. +By ensuring that `fixedIntialTokenTotalSupply` and `fixedSidestETHOnStartCapacity` are only set once, you can protect the integrity of the vault's state and maintain accurate tracking of deposits and user entitlements. Additionally, this will reinforce the vault’s operational logic by preventing inconsistencies that could arise from concurrent deposit attempts. \ No newline at end of file diff --git a/127.md b/127.md new file mode 100644 index 0000000..c2782c1 --- /dev/null +++ b/127.md @@ -0,0 +1,53 @@ +Festive Marmalade Unicorn + +Medium + +# Fixed side depositors won't be eligible for referral rewards for depositing ETH + +### Summary + +When fixed side depositors deposits some amount of `Ether` to the vault, it deposits to the `Lido`. However, it sets its referral address as `address(0)`. As a result, they can't receive referral rewards for depositing ETH. + +### Root Cause + +When fixed side depositors deposits some amount of `Ether` to the vault, it deposits to the `Lido`. +At [LidoVault.sol#L351](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L423), it deposits `Ether` to the `Lido`. However, it sets a referral address as `address(0)` + +```solidity +File: lido-fiv\contracts\LidoVault.sol +328: function deposit(uint256 side) external payable { + [...] +351: uint256 shares = lido.submit{value: amount}(address(0)); // _referral address argument is optional use zero address +``` +The `Lido` protocol allows caller to pass referral argument when depositing ETH, and referral account can be eligible for referral rewards if it is valid. + +The `_referral` parameter indicates the referral account which will be eligible for referral rewards. +If a fixed depositor is eligible for referral rewards, he can't receive any rewards. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +Fixed side depositors are eligible for referral rewards. + +### Attack Path + +_No response_ + +### Impact + +Fixed side depositors won't be eligible for referral rewards as expected, this can be significant value leak to the them. + +### PoC + +_No response_ + +### Mitigation + +Use the address of a caller as referral instead `address(0)`. +```diff +- uint256 shares = lido.submit{value: amount}(address(0)); // _referral address argument is optional use zero address ++ uint256 shares = lido.submit{value: amount}(msg.sender); +``` \ No newline at end of file diff --git a/145.md b/145.md new file mode 100644 index 0000000..9f9ea9e --- /dev/null +++ b/145.md @@ -0,0 +1,6 @@ +Clumsy Raisin Tarantula + +Medium + +# - + diff --git a/147.md b/147.md new file mode 100644 index 0000000..3a1a682 --- /dev/null +++ b/147.md @@ -0,0 +1,56 @@ +Faithful Burgundy Corgi + +High + +# Users funds will be stuck if many users request for withdrawal from the fixed side when the vault has started due to two unbounded array lengths in `LidoVault::finalizeVaultOngoingFixedWithdrawals`. + +### Summary + +When a user calls `LidoVault::finalizeVaultOngoingFixedWithdrawals` to request for withdrawal from the fixed side when the vault has started, each of the 2 arrays in line 599 and line 1174 will be looped through. With sufficient number of withdrawals pending, the gas needed to complete this transaction will be greater than the block gas limit (30 million gas) and every transaction calling this function will fail. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L599 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1174 + +Funds of these users will be stuck. + +### Root Cause + +Two unbounded arrays will be looped through when `LidoVault::finalizeVaultOngoingFixedWithdrawals` is called by a user. The unbounded arrays are in line 599 and line 1174 as shown in the summary section. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Funds of users requesting for withdrawals from an ongoing vault will be stuck due to unusually high gas (higher than block gas limit) required to complete the transaction. Especially when this contract is running on Ethereum mainnet where gas fees are very high. + +### PoC + +30 million gas is the maximum gas limit per block in the Ethereum blockchain according to ethereum.org. Please click the link below. +https://ethereum.org/en/developers/docs/gas/#block-size:~:text=fee%20and%20tip.-,Block%20size,-Each%20block%20has + +As the number of users and withdrawal requests on the fixed side grows so also the array lengths in line 599 and line 1174 grow and gas needed to complete the withdrawal transaction. +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L599 + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1174 + +When a user calls `LidoVault::finalizeVaultOngoingFixedWithdrawals` to request for withdrawal, each of the 2 arrays will be looped through. With sufficient number of withdrawals pending, the gas needed to complete this transaction will be greater than the block gas limit (30 million gas) and every transaction calling this function will fail. + +### Mitigation + +The contract follows the steps below for a withdrawal from ongoing fixed-side vault to be finalised; + +1. user calls `LidoVault::withdraw` to place request for withdrawal. The user request is not sent to Lido immediately, it is queued up along with other requests; +2. all the queued up requests are sent when a user calls `LidoVault::finalizeVaultOngoingFixedWithdrawals`. Each request is sent individually in a loop. So a long loop of requests is expected to be processed in this one transaction. + +This logic is erroneous. Besides funds being stuck due to unusually high gas, a user is made to pay gas fees for other users requests to be sent to Lido. This logic must reviewed and revised. \ No newline at end of file diff --git a/149.md b/149.md new file mode 100644 index 0000000..2b39fe9 --- /dev/null +++ b/149.md @@ -0,0 +1,46 @@ +Tangy Raisin Woodpecker + +Medium + +# Invalid protocolFeeReceiver check whenever the global protocolFeeReceiver address is reset + +## Summary +Invalid protocolFeeReceiver check whenever the global protocolFeeReceiver address is reset, risk of compromised protocolFeeReceiver address takes protocol fees for all deployed vaults. + +## Vulnerability Detail +Verifying protocol role address is crucial in securing protocol funds. Current LidoVault.sol’s check of protocolFeeReceiver is essentially invalid whenever the global protocolFeeReceiver address is updated in VaultFactory.sol. It allows an old or potentially compromised address to continue withdrawing protocol fees. + +`protocolFeeReceiver` is stored locally in LidoVault.sol and can never be updated even though the factory updated the global protocolFeeReceiver. See the snippet below. + +An edge case is if the previous `protocolFeeReceiver` is compromised. Admin for vaultFactory.sol will have to change protocolFeeRecevier to a new address through [setProtocolFeeReceiver()](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/VaultFactory.sol#L96). + +The problem is LidoVault.sol ‘s check on` protocolFeeRecevier` is vulnerable and it doesn’t call VaultFactory to determine the correct protocolFeeRecevier. It will still use the compromised protocolFeeReceiver address throughout the lifetime of the vault. + +This allows the compromised protocolFeeReceiver to steal all the protocol fees in all deployed vaults. + +## Impact +manual + +## Code Snippet +```solidity +//contracts/LidoVault.sol + function withdraw(uint256 side) external { +... + //@audit protocolFeeReceiver is stored locally in LidoVault.sol and can never be updated even though the factory updated the global protocolFeeReceiver + |> if (msg.sender == protocolFeeReceiver && appliedProtocolFee > 0) { + require(variableToVaultOngoingWithdrawalRequestIds[msg.sender].length == 0, "WAR"); + return protocolFeeReceiverWithdraw(); + } +... +``` +(https://github.com/sherlock-audit/2024-08-saffron-finance/blob/38dd9c8436db341c331f1b14545770c1766fc0ee/lido-fiv/contracts/LidoVault.sol#L514) + + +## Tool used + +Manual Review + +## Recommendation +In LidoVault.sol, +(1) Store factory address during `initialize()` +(2) Call vaultFactory::protocolFeeReceiver when checking protocolFeeReceiver address \ No newline at end of file diff --git a/151.md b/151.md new file mode 100644 index 0000000..d5ad251 --- /dev/null +++ b/151.md @@ -0,0 +1,39 @@ +Keen Lead Squid + +High + +# FadoBagi - Lack of Validation on `earlyExitFeeBps` + +FadoBagi + +High + +# Lack of Validation on `earlyExitFeeBps` + +## Summary +The `VaultFactory` contract allows the owner to set the `earlyExitFeeBps` without any validation. This enables the owner to assign arbitrary and malicious fee values, which can result in users being overcharged or losing their entire withdrawal amounts. + +## Vulnerability Detail +In the `VaultFactory` contract, the `setEarlyExitFeeBps` function permits the owner to set the `earlyExitFeeBps` without any constraints: + + function setEarlyExitFeeBps(uint256 _earlyExitFeeBps) external onlyOwner { + earlyExitFeeBps = _earlyExitFeeBps; + // ... + } + +The function does not validate the value of `_earlyExitFeeBps` before setting it. The owner can set it to any value, including excessively high or low values. + +High `earlyExitFeeBps` can result in users being overcharged during withdrawals. Users might lose a significant portion or all of their withdrawal amounts. + +## Impact +The lack of validation on `earlyExitFeeBps` allows the contract owner to set arbitrary fee values, leading to users being overcharged or losing their entire withdrawal amounts. + +## Code Snippet +- **Function: `setEarlyExitFeeBps`** +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L101-L104 + +## Tool used +Manual Review + +## Recommendation +Implement validation in the `setEarlyExitFeeBps` function to restrict `earlyExitFeeBps` to a reasonable range, preventing the owner from setting excessively high or low fee values. \ No newline at end of file diff --git a/155.md b/155.md new file mode 100644 index 0000000..58e1528 --- /dev/null +++ b/155.md @@ -0,0 +1,41 @@ +Old Violet Salamander + +Medium + +# `VaultFactory` despite importing `Ownable2Step` is not inheriting from it thereby leaving the owner transfership mechanism vulnerable + +## Summary + +1. `VaultFactory` imports `Ownable2Step` as seen [here](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L6). +2. `VaultFactory` inherits from `Ownable` as seen [here](https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/VaultFactory.sol#L16) +3. This results in the ownership transfer mechanism to rely on `Ownable` instead of much safer `Ownable2Step`. + + +## Vulnerability Detail + +Single-step ownership transfer means that if a wrong address was passed when transferring ownership or admin rights it can mean that role is lost forever. The ownership pattern implementation for the protocol is in OpenZeppelin's `Ownable.sol` where a single-step transfer is implemented. This can be a problem for all methods marked in `onlyOwner` throughout `VaultFactory` contract, some of which are core contract functionality. + +## Impact + +High, because important protocol functionality will be bricked + +## Likelihood + +Low, because an error or mistake on the admin's end will cause this + +## Code Snippet + +```diff +- contract VaultFactory is ILidoVaultInitializer, Ownable { ++ contract VaultFactory is ILidoVaultInitializer, Ownable2Step { +``` + +## Tool used + +Manual Review + +## Recommendation + +It is a best practice to use two-step ownership transfer pattern, meaning ownership transfer gets to a `pending` state and the new owner should claim his new rights, otherwise the old owner still has control of the contract. Consider using OpenZeppelin's `Ownable2Step` contract. + +See Code Snippet section above for the recommended solution. \ No newline at end of file diff --git a/156.md b/156.md new file mode 100644 index 0000000..f109997 --- /dev/null +++ b/156.md @@ -0,0 +1,138 @@ +Jumpy Vanilla Gecko + +Medium + +# Improper Handling of the Zero Address in LidoVault.sol Withdrawal Arrays Will Cause Denial of Service for Vault Participants + +### Summary + +Improper handling of the zero address during the finalization of withdrawal requests leads to a Denial of Service (DoS). Once a withdrawal request is finalized using the delete keyword, the user’s address is replaced with `address(0)`. During subsequent finalization attempts, the contract encounters this address and reverts with the "WNR" (Withdrawal Not Ready) error, halting the withdrawal process and effectively preventing any further withdrawals or vault finalizations. + +### Root Cause + +This issue arises from the delete keyword being used to remove user addresses from the `fixedOngoingWithdrawalUsers` and `variableToVaultOngoingWithdrawalRequestIds` arrays during withdrawal finalization. Instead of removing the entry, delete sets the entry to `0x0000000000000000000000000000000000000000`. When the contract later encounters this invalid address in finalization processes, it reverts, stopping all future withdrawal requests from being processed. + +The issue affects the following functions: + - `withdraw` + - `finalizeVaultOngoingFixedWithdrawals` + - `finalizeVaultOngoingVariableWithdrawals` + +In particular, the line of interest where the deletion occurs is: `https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol?plain=1#L601` and `https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol?plain=1#L695`. +This vulnerability leads to a temporary Denial of Service, where users cannot successfully withdraw their funds due to the contract's reversion when encountering the zero address. + +### Internal pre-conditions + +The contract must have users with ongoing withdrawals in `fixedOngoingWithdrawalUsers` or `variableToVaultOngoingWithdrawalRequestIds`. +Withdrawal requests must be initiated by calling withdraw function, leading to addresses being pushed to respective arrays. + +### External pre-conditions + +External interaction or protocol change is not directly required for this issue to manifest, as it is contingent solely on the state management within the LidoVault contract itself. + +### Attack Path + +1. User calls `withdraw(SIDE.FIXED)` to initiate a withdrawal. +2. User then calls `finalizeVaultOngoingFixedWithdrawals`, setting their address in the array to the zero address. +3. Any user attempting to finalize withdrawals thereafter encounters the zero address, causing the contract to revert with the `WNR` error, resulting in a denial of service. + +### Impact + +The affected parties (users attempting to finalize withdrawals) cannot execute withdrawals or finalize them, causing funds to be locked without resolution unless the contract is updated or managed to bypass or handle zero address entries effectively. + +### PoC + +Paste the following code in the withdraw section of tests within the 1.LidoVault.test.ts file. +```typescript +async function setupWithdrawalFixture() { + const { lidoVault, addr1, addr2 } = await loadFixture(deployLidoVaultFixture) + + const depositAmount = parseEther('200') + + await lidoVault.connect(addr1).deposit(SIDE.FIXED, { value: depositAmount }) + await lidoVault.connect(addr2).deposit(SIDE.FIXED, { value: depositAmount }) + + return { lidoVault, addr1, addr2 } +} + +it('Should handle ZERO_ADDRESS in the withdrawal array without revert (Fixed DoS Test)', async function () { + const { lidoVault, addr1, addr2 } = await setupWithdrawalFixture() + + await lidoVault.connect(addr1).withdraw(SIDE.FIXED) + let fixedOngoingUsers = await lidoVault.fixedOngoingWithdrawalUsers(0) + + expect(fixedOngoingUsers).to.equal(addr1.address) + + await lidoVault.connect(addr1).finalizeVaultOngoingFixedWithdrawals() + + fixedOngoingUsers = await lidoVault.fixedOngoingWithdrawalUsers(0) + expect(fixedOngoingUsers).to.equal(ZERO_ADDRESS) + + await lidoVault.connect(addr2).withdraw(SIDE.FIXED) + fixedOngoingUsers = await lidoVault.fixedOngoingWithdrawalUsers(1) + expect(fixedOngoingUsers).to.equal(addr2.address) + + await expect(lidoVault.connect(addr1).finalizeVaultOngoingFixedWithdrawals()).to.not.be.reverted + + const withdrawalIds = await lidoVault.getFixedOngoingWithdrawalRequestIds(addr2.address) + expect(withdrawalIds.length).to.equal(0) +}) + +it('Should handle ZERO_ADDRESS in the withdrawal array without revert (Variable DoS Test)', async function () { + const { lidoVault, addr1, addr2 } = await setupWithdrawalFixture() + + await lidoVault.connect(addr1).withdraw(SIDE.VARIABLE) + let variableOngoingRequestIds = await lidoVault.variableToVaultOngoingWithdrawalRequestIds( + addr1.address, + 0 + ) + + expect(variableOngoingRequestIds).to.equal(1) + + await lidoVault.connect(addr1).finalizeVaultOngoingVariableWithdrawals() + + variableOngoingRequestIds = await lidoVault.variableToVaultOngoingWithdrawalRequestIds( + addr1.address, + 0 + ) + expect(variableOngoingRequestIds).to.equal(ZERO_ADDRESS) + + await lidoVault.connect(addr2).withdraw(SIDE.VARIABLE) + variableOngoingRequestIds = await lidoVault.variableToVaultOngoingWithdrawalRequestIds( + addr2.address, + 0 + ) + expect(variableOngoingRequestIds).to.equal(1) + + await expect(lidoVault.connect(addr1).finalizeVaultOngoingVariableWithdrawals()).to.not.be + .reverted + + const variableWithdrawalIds = await lidoVault.getVariableToVaultOngoingWithdrawalRequestIds( + addr2.address + ) + expect(variableWithdrawalIds.length).to.equal(0) +}) + +it('Should revert when trying to finalize an ended vault withdrawal containing ZERO_ADDRESS (Fixed)', async function () { + const { lidoVault, addr1 } = await loadFixture(endVaultFixture) + + await lidoVault.connect(addr1).withdraw(SIDE.FIXED) + + await lidoVault.connect(addr1).finalizeVaultOngoingFixedWithdrawals() + + await expect(lidoVault.finalizeVaultEndedWithdrawals(SIDE.FIXED)).to.be.revertedWith('WNR') +}) + +it('Should revert when trying to finalize an ended vault withdrawal containing ZERO_ADDRESS (Variable)', async function () { + const { lidoVault, addr1 } = await loadFixture(endVaultFixture) + + await lidoVault.connect(addr1).withdraw(SIDE.VARIABLE) + + await lidoVault.connect(addr1).finalizeVaultOngoingVariableWithdrawals() + + await expect(lidoVault.finalizeVaultEndedWithdrawals(SIDE.VARIABLE)).to.be.revertedWith('WNR') +}) +``` + +### Mitigation + +Let `claimFixedVaultOngoingWithdrawal` return 0 if fixedUser is `address(0)` (see `https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol?plain=1#L690-697`). \ No newline at end of file diff --git a/158.md b/158.md new file mode 100644 index 0000000..4b4fa6d --- /dev/null +++ b/158.md @@ -0,0 +1,43 @@ +Swift Rouge Grasshopper + +Medium + +# strict equality check in `LidoVault::deposit` function prevents vault from starting. + +## Summary +The `LidoVault::deposit` function has a condition where `strict equality` check, which prevents the vault from starting. +## Vulnerability Detail +In `LidoVault.sol:382` and `LidoVault.sol:383` lines uses strict equality checks +1. let us assume `fixedSideCapacity` is 1000 ether. +2. users started depositing their ETH in this contract. +3. If `fixedETHDepositTokenTotalSupply` becomes 999.999 eth +4. Now user has to deposit 0.001 eth in order to satisfy these conditions ` fixedETHDepositTokenTotalSupply == fixedSideCapacity && variableBearerTokenTotalSupply == variableSideCapacity`. +5. But he can't deposit 0.001 eth because `minimumDepositAmount` is `0.01 eth`. +6. So, the strict equality conditions fails and prevents the vault from starting. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L382-L383 +```solidity + if ( +@> fixedETHDepositTokenTotalSupply == fixedSideCapacity && variableBearerTokenTotalSupply == variableSideCapacity + ) { + startTime = block.timestamp; + endTime = block.timestamp + duration; + fixedSidestETHOnStartCapacity = stakingBalance(); + fixedIntialTokenTotalSupply = fixedClaimTokenTotalSupply; + emit VaultStarted(block.timestamp, msg.sender); + } +``` +## Tool used + +Manual Review + +## Recommendation +use tolerance +```solidity ++ uint256 tolerance = 0.01 ether; + if ( +- fixedETHDepositTokenTotalSupply == fixedSideCapacity && variableBearerTokenTotalSupply == variableSideCapacity ++ (fixedETHDepositTokenTotalSupply >= fixedSideCapacity - tolerance && fixedETHDepositTokenTotalSupply <= fixedSideCapacity) && (variableBearerTokenTotalSupply >= variableSideCapacity - tolerance && variableBearerTokenTotalSupply <= variableSideCapacity) + ) +``` \ No newline at end of file diff --git a/164.md b/164.md new file mode 100644 index 0000000..fec4888 --- /dev/null +++ b/164.md @@ -0,0 +1,64 @@ +Urban Latte Quail + +High + +# Fixed users can't withdraw funds after calling claimFixedPremium upfront + +### Summary + +Fixed depositors will have their claim tokens set to zero after claiming their premium, which will cause a revert on their withdrawals. + + + +### Root Cause + +In vault.sol, the logic of setting fixedClaimToken[msg.sender] to zero during the `claimFixedPremium` function prevents fixed depositors from withdrawing their funds afterward. + +1. Firstly in the `deposit` function, fixed depositor gets minted claim tokens. L359 +```solidity +// Mint claim tokens + fixedClaimToken[msg.sender] += shares; +``` +2. Secondly in the `claimFixedPremium` function, fixed depositor burns his claim tokens for upfront premium. User got minted bearer tokens and his claimToken balance is set to 0. +```solidity +// Burn claim tokens + fixedClaimToken[msg.sender] = 0; +``` +3. As third and last, fixed depositor tries to withdraw funds. However, a claimToken balance check in below `withdraw` function prevents him to withdraw his funds. +```solidity +if (side == FIXED) { + require(fixedToVaultNotStartedWithdrawalRequestIds[msg.sender].length == 0, "WAR"); + + // need to have claim tokens + uint256 claimBalance = fixedClaimToken[msg.sender]; + //uint256 claimBalance = fixedClaimToken.balanceOf(msg.sender); + require(claimBalance > 0, "NCT"); //@audit claimBalance is already set to 0 when claiming the premium, this call will revert +``` + +### Internal pre-conditions + +1. A fixed depositor needs to have a non zero balance of fixedClaimToken to call claimFixedPremium. --> After depositing that check will pass +2. The fixed depositor then will call claimFixedPremium to have his premium upfront, sourced by variable users. + When claimed, claimTokens will be burned and user balance will be set to 0. +3. The fixed depositor then will try to withdraw his funds. However, withdraw function checks if user claimToken balance is bigger than 0. +4. Since it has been set to 0 in claimFixedPremium, withdraw call will revert for fixed user. + +### External pre-conditions + +none + +### Attack Path + +There is no attack. I identified the flow above. + +### Impact + +Fixed user funds are stuck, unable to withdraw. + +### PoC +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L358-L360 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L412-L414 +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L428-L434 +### Mitigation + +_No response_ \ No newline at end of file diff --git a/165.md b/165.md new file mode 100644 index 0000000..0cce4ae --- /dev/null +++ b/165.md @@ -0,0 +1,35 @@ +Clumsy Raisin Tarantula + +Medium + +# Incorrect withdrawal distribution due to improper adjustment logic when remaining stETH is below the minimum threshold + +## Summary +The calculateWithdrawals function only adjusts the last two withdrawal requests when the remaining stETH is below the minimum withdrawal threshold. This fails to consider cases where more than two withdrawals require adjustment, leading to incorrect allocation of withdrawal amounts and mismanagement of stETH distribution for users. + +## Vulnerability Detail +The function’s logic assumes that only the last two withdrawals need adjusting when the remaining stETH is below the MIN_STETH_WITHDRAWAL_AMOUNT. However, if the total withdrawal involves more than two requests, this method leaves earlier withdrawals unadjusted, leading to a misallocation of funds. The lack of dynamic adjustment across all withdrawals creates a risk of users receiving inaccurate withdrawal amounts. + +1. Alice requests to withdraw 50,000 stETH. + The contract splits her withdrawal into 5 full requests of 10,000 stETH each. + Since there is 0 stETH remaining, no adjustments are needed. + The withdrawal completes successfully, and Alice receives her correct allocation. + +1. Bob requests to withdraw 40,000 stETH. +The contract splits his withdrawal into 4 full requests of 10,000 stETH each. +After the first 3 requests, there is 7,500 stETH remaining, which is less than the MIN_STETH_WITHDRAWAL_AMOUNT. +The contract adjusts only the last two requests, splitting the remaining 7,500 stETH between them. +This adjustment leaves the earlier withdrawals unchanged, resulting in Bob receiving more or less stETH than he should have. + +## Impact +This vulnerability can result in incorrect stETH distribution, where users receive either more or less than they are entitled to. This misallocation can lead to financial imbalances and undermine user trust in the protocol. Furthermore, the protocol may risk economic inefficiencies due to underfunded or overfunded withdrawals. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1215-L1225 + +## Tool used + +Manual Review + +## Recommendation +Implement a dynamic adjustment mechanism that distributes the remaining stETH across all relevant withdrawal requests, not just the last two. By iterating over all withdrawal amounts and adjusting them proportionally, the protocol can ensure more accurate and fair distribution of stETH for users. This will prevent misallocation and maintain the integrity of the withdrawal process. \ No newline at end of file diff --git a/166.md b/166.md new file mode 100644 index 0000000..3b884a1 --- /dev/null +++ b/166.md @@ -0,0 +1,41 @@ +Faithful Burgundy Corgi + +Medium + +# Transactions calling `LidoVault::finalizeVaultOngoingFixedWithdrawals` will fail if one of the requests for withdrawals to Lido reverts causing funds being stuck (DoS). + +### Summary + +When a user calls `LidoVault::finalizeVaultOngoingFixedWithdrawals` all the queued requests are sent individually in a loop to Lido. So a long loop of requests is expected to be processed in this one transaction as shown below. + +https://github.com/sherlock-audit/2024-08-saffron-finance/blob/main/lido-fiv/contracts/LidoVault.sol#L1174 + +If one of the requests reverts the transaction fails. Causing funds to be stuck in the protocol. + +### Root Cause + +Using a user's transaction to send all the queued requests for withdrawal to Lido. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +All users with queued requests for withdrawal from an ongoing vault will have their funds stuck. + +### PoC + +_No response_ + +### Mitigation + +Refactor `LidoVault::_claimWithdrawals` to send only msg.sender request or use try-and-catch block to send transactions to Lido. \ No newline at end of file diff --git a/invalid/.gitkeep b/invalid/.gitkeep new file mode 100644 index 0000000..e69de29