Platform: Code4rena
Start Date: 27/04/2023
Pot Size: $90,500 USDC
Total HM: 4
Participants: 43
Period: 7 days
Judge: GalloDaSballo
Id: 233
League: ETH
Rank: 18/43
Findings: 2
Award: $1,223.48
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: ABA
Also found by: 0xWaitress, 8olidity, ABA, MiloTruck, ToonVH, bughunter007, bytes032, juancito, rvierdiiev
695.2259 USDC - $695.23
A malicious strategy can permanently DoS all currently pending withdrawals that contain it.
In order to withdrawal funds from the project a user has to:
queueWithdrawal
)completeQueuedWithdrawal(s)
)Queuing a withdrawal, via queueWithdrawal
modifies all internal accounting to reflect this:
// modify delegated shares accordingly, if applicable delegation.decreaseDelegatedShares(msg.sender, strategies, shares);
if (_removeShares(msg.sender, strategyIndexes[strategyIndexIndex], strategies[i], shares[i])) {
and saves the withdrawl hash in order to be used in completeQueuedWithdrawal(s)
queuedWithdrawal = QueuedWithdrawal({ strategies: strategies, shares: shares, depositor: msg.sender, withdrawerAndNonce: withdrawerAndNonce, withdrawalStartBlock: uint32(block.number), delegatedAddress: delegatedAddress }); } // calculate the withdrawal root bytes32 withdrawalRoot = calculateWithdrawalRoot(queuedWithdrawal); // mark withdrawal as pending withdrawalRootPending[withdrawalRoot] = true;
In other words, it is final (as there is no cancelWithdrawl
mechanism implemented).
When executingcompleteQueuedWithdrawal(s)
, the withdraw
function of the strategy is called (if receiveAsTokens
is set to true).
// tell the strategy to send the appropriate amount of funds to the depositor queuedWithdrawal.strategies[i].withdraw( msg.sender, tokens[i], queuedWithdrawal.shares[i] );
In this case, a malicious strategy can always revert, blocking the user from retrieving his tokens.
If a user sets receiveAsTokens
to false
, the other case, then the tokens will be added as shares to the delegation contract
for (uint256 i = 0; i < strategiesLength;) { _addShares(msg.sender, queuedWithdrawal.strategies[i], queuedWithdrawal.shares[i]); unchecked { ++i;
// if applicable, increase delegated shares accordingly delegation.increaseDelegatedShares(depositor, strategy, shares);
This still poses a problem because the increaseDelegatedShares
function's counterpart, decreaseDelegatedShares
is only callable by the strategy manager (so as for users to indirectly get theier rewards worth back via this workaround)
/** * @notice Decreases the `staker`'s delegated shares in each entry of `strategies` by its respective `shares[i]`, typically called when the staker withdraws from EigenLayer * @dev Callable only by the StrategyManager */ function decreaseDelegatedShares( address staker, IStrategy[] calldata strategies, uint256[] calldata shares ) external onlyStrategyManager {
but in StrategyManager
there are only 3 cases where this is called, none of which is beneficial for the user:
recordOvercommittedBeaconChainETH
- slashes user rewards in certain conditionsqueueWithdrawal
- accounting side effects have already been done, will fail in _removeShares
slashShares
- slashes user rewards in certain conditionsIn other words, the workaround (of setting receiveAsTokens
to false
in completeQueuedWithdrawal(s)
) just leaves the funds accounted and stuck in DelegationManager
.
In both cases all shares/tokens associated with other strategies in the pending withdrawal are permanently blocked.
Manual analysis
Add a cancelQueuedWithdrawl
function that will undo the accounting and cancel out any dependency on the malicious strategy. Although this solution may create a front-running opportunity for when their withdrawal will be slashed via slashQueuedWithdrawal
, there may exist workarounds to this.
Another possibility is to implement a similar mechanism to how slashQueuedWithdrawal
treats malicious strategies: adding a list of strategy indices to skip
https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L532-L535 https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L560-L566
* @param indicesToSkip Optional input parameter -- indices in the `strategies` array to skip (i.e. not call the 'withdraw' function on). This input exists * so that, e.g., if the slashed QueuedWithdrawal contains a malicious strategy in the `strategies` array which always reverts on calls to its 'withdraw' function, * then the malicious strategy can be skipped (with the shares in effect "burned"), while the non-malicious strategies are still called as normal. */ ... for (uint256 i = 0; i < strategiesLength;) { // check if the index i matches one of the indices specified in the `indicesToSkip` array if (indicesToSkipIndex < indicesToSkip.length && indicesToSkip[indicesToSkipIndex] == i) { unchecked { ++indicesToSkipIndex; } } else {
DoS
#0 - c4-pre-sort
2023-05-09T13:37:22Z
0xSorryNotSorry marked the issue as primary issue
#1 - c4-sponsor
2023-05-12T00:39:38Z
Sidu28 marked the issue as disagree with severity
#2 - Sidu28
2023-05-12T00:39:42Z
The description of lack of check here is accurate. There is zero actual impact; we think this is informational severity.
#3 - GalloDaSballo
2023-05-28T19:32:01Z
@Sidu28 if we assumed the Strategy not to be malicious, but to revert for some reason (e.g. lack of liquidity, smart contract bug, etc...)
Would you consider the finding as valid since the user cannot withdraw from all others due to having one withdraw, or is there some other reason why you believe the finding to be Informational?
#4 - c4-judge
2023-05-31T18:13:59Z
GalloDaSballo changed the severity to 2 (Med Risk)
#5 - GalloDaSballo
2023-05-31T18:16:19Z
The Warden has shown how, any revert in a strategy (for example it being paused), will make queued withdrawals revert, even if the withdrawal should work for other strategies
This falls into the category of DOS and I believe is more appropriately judged as Medium
#6 - ChaoticWalrus
2023-06-04T14:54:58Z
The Warden has shown how, any revert in a strategy (for example it being paused), will make queued withdrawals revert, even if the withdrawal should work for other strategies
This falls into the category of DOS and I believe is more appropriately judged as Medium
Even if a malicious strategy exists, there is no permanent delay here.
Users can mark the receiveAsTokens
input to completeQueuedWithdrawal
as false here https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L437-L442 and then the shares will be transferred to the 'withrdrawing' user here https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L795-L803 who can then queue a new withdrawal not containing the malicious strategy.
The description provided by the warden of the DelegationManager's behavior is inaccurate -- the funds do not end up 'stuck in the DelegationManager' -- they will be withdrawable as part of a new queued withdrawal which excludes the malicious strategy.
#7 - abarbatei
2023-06-05T09:14:16Z
The Warden has shown how, any revert in a strategy (for example it being paused), will make queued withdrawals revert, even if the withdrawal should work for other strategies This falls into the category of DOS and I believe is more appropriately judged as Medium
Even if a malicious strategy exists, there is no permanent delay here.
Users can mark the
receiveAsTokens
input tocompleteQueuedWithdrawal
as false here https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L437-L442 and then the shares will be transferred to the 'withrdrawing' user here https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L795-L803 who can then queue a new withdrawal not containing the malicious strategy.The description provided by the warden of the DelegationManager's behavior is inaccurate -- the funds do not end up 'stuck in the DelegationManager' -- they will be withdrawable as part of a new queued withdrawal which excludes the malicious strategy.
Hey, https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L795-L803 does not do:
shares will be transferred to the 'withrdrawing' user
it simply increments internal accounting and delegated shares associated to user in the delegation contract. But these are not withdrawn without a queueWithdrawal
call
queue a new withdrawal not containing the malicious strategy.
a new queue can not reuse the already used balance as it was already accounted for in https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L345-L346
Maybe I am missing something of course. Regardless, awaiting judge feedback.
#8 - ChaoticWalrus
2023-06-05T14:55:35Z
it simply increments internal accounting and delegated shares associated to user in the delegation contract. But these are not withdrawn without a
queueWithdrawal
call
Yes, this is what I would describe as transferring the shares, similar to how the 'transfer in' portion of an ERC20 transfer works. But this is a semantic argument; I agree with the substance of your description.
a new queue can not reuse the already used balance as it was already accounted for in https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L345-L346
Maybe I am missing something of course. Regardless, awaiting judge feedback.
I think perhaps you are missing that the internal _addShares
function calls the Delegation Manager as well? See here https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L646-L647
If the user marks receiveAsTokens
as false, then this line will be triggered https://github.com/code-423n4/2023-04-eigenlayer/blob/5e4872358cd2bda1936c29f460ece2308af4def6/src/contracts/core/StrategyManager.sol#L798
which calls into the internal _addShares
function and 're-adds' these delegated shares, so they can indeed be queued in a new withdrawal.
I can provide an example if it will help?
#9 - ChaoticWalrus
2023-06-05T14:57:30Z
Basically, the 're-adding' reverses the accounting taken in the initial queueWithdrawal
action, to clarify. This then allows the accounting done in the queueWithdrawal
action to be performed once again, when a new withdrawal is queued.
#10 - ChaoticWalrus
2023-06-05T14:59:52Z
@GalloDaSballo would appreciate you taking another look at this -- I am happy to provide additional context and/or an example, if desired.
#11 - GalloDaSballo
2023-06-05T15:09:50Z
Thank you @ChaoticWalrus for the tag, will check today
#12 - GalloDaSballo
2023-06-06T14:57:08Z
@ChaoticWalrus
It seems like the worst case scenario would be having to re-queue without the missing strategy which would require waiting for the withdrawal to unlock
So the question left to answer is whether an additional wait period is acceptable
NOTE: I edited this comment because as you pointed out the shares accounting is internal and doesn't trigger an interaction with the strategy
#13 - ChaoticWalrus
2023-06-06T17:36:15Z
It seems like the worst case scenario would be having to re-queue without the missing strategy which would require waiting for the withdrawal to unlock
Yes, agreed this is the impact + worst-case scenario. The existing functionality was designed -- in part at least -- to address concerns around malicious Strategies.
So the question left to answer is whether an additional wait period is acceptable
I suppose so, yes. We have deemed this acceptable ourselves but I could see it being viewed differently. Regardless, the impact here is orders of magnitude less than the original claimed impact.
#14 - GalloDaSballo
2023-06-08T10:10:05Z
The Warden has shown how, due to the possibility of queueing of a withdrawal with multiple strategies, in the case in which one of the strategies stops working (reverts, paused, malicious), the withdrawal would be denied
As the sponsor said, in those scenarios, the withdrawer would have to perform a second withdrawal, which would have to be re-queued
Intuitively, a withdrawer that always withdraws a separate strategy would also never see their withdrawal denied (except for the malicious strategy)
As we can see there are plenty of side steps to the risk shown, however, the functionality of the function is denied, even if temporarily, leading to it not working as intended, and for this reason I believe Medium Severity to be the most appropriate
As shown above, the finding could be nofixed, however it seems to me like most users would want to plan around the scenarios described in this finding and its duplicates
#15 - c4-judge
2023-06-08T12:26:57Z
GalloDaSballo marked the issue as satisfactory
#16 - c4-judge
2023-06-08T12:28:11Z
GalloDaSballo marked the issue as selected for report
🌟 Selected for report: ABA
Also found by: 0xWaitress, 8olidity, ABA, MiloTruck, ToonVH, bughunter007, bytes032, juancito, rvierdiiev
695.2259 USDC - $695.23
https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L329-L340 https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L442-L446 https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L456-L464
A pod malfunction or a malicious actor exploiting it pauses all strategies withdrawals with the current existing pauses mechanism. Depending on the issue, user funds may remain blocked for a undetermined period of time.
In StrategyManager
the function that calls the beacon chain ETH withdraw is _withdrawBeaconChainETH
// withdraw the beaconChainETH to the recipient eigenPodManager.withdrawRestakedBeaconChainETH(staker, recipient, amount);
StrategyManager
uses a pause mechanism that has onlyWhenNotPaused(PAUSED_WITHDRAWALS)
: it pauses all withdraws, for both pods and normal tokens.
If there is an issue only with withdrawing from the beacon chain, other strategies will also result in being paused. This in turn leads to user funds being blocked, either temporary or permanently.
https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L329-L340 https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L442-L446 https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L456-L464
Manual analysis.
Add a PAUSED_WITHDRAW_RESTAKED_ETH
pause state, similar to how it is done in EigenPodManager
and guard StrategyManager._withdrawBeaconChainETH
with it.
DoS
#0 - c4-pre-sort
2023-05-09T13:39:18Z
0xSorryNotSorry marked the issue as duplicate of #132
#1 - c4-judge
2023-06-08T12:26:46Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: volodya
Also found by: 0xWaitress, 0xnev, ABA, Aymen0909, Cyfrin, QiuhaoLi, RaymondFam, btk, bughunter007, ihtishamsudo, juancito, libratus, niser93, sashik_eth
528.2547 USDC - $528.25
During the audit, 1 low, 4 non-critical and 3 refactoring issues were found.
Total: 1 instances over 1 issues
# | Issue | Instances |
---|---|---|
[L-01] | StrategyManager._removeShares does not check for 0 address; reverts with misleading message | 1 |
Total: 5 instances over 3 issues
# | Issue | Instances |
---|---|---|
[R-01] | Reuse userShares in StrategyManager.recordOvercommittedBeaconChainETH calculation | 1 |
[R-02] | Use already defined PAUSE_ALL instead of type(uint256).max | 3 |
[R-03] | Add createIfNotExisting flag on EigenPodManager.stake | 1 |
Total: 8 instances over 4 issues
# | Issue | Instances |
---|---|---|
[NC-01] | If amount is 0 the executions passes through in StrateguManager.recordOvercommittedBeaconChainETH | 1 |
[NC-02] | Check that argument _pauserRegistry from Pausable._initializePauser is a contract | 1 |
[NC-03] | Remove redundant functions that wrap over other functions from StrategyBase | 3 |
[NC-04] | Misleading or incorrect documentation | 3 |
StrategyManager._removeShares
does not check for 0 address; reverts with misleading messageWehn withdrawing from the project, execution flow reaches StrategyManager._removeShares
.
This function takes an strategy object (address) as one of its parameter but it does not check if it is 0.
// sanity checks on inputs require(depositor != address(0), "StrategyManager._removeShares: depositor cannot be zero address"); require(shareAmount != 0, "StrategyManager._removeShares: shareAmount should not be zero!");
Execution will revert but only because of the 0 address strategy not being found in _removeStrategyFromStakerStrategyList
require(j != stratsLength, "StrategyManager._removeStrategyFromStakerStrategyList: strategy not found");
_removeShares
expects callers to check the address but not all flows do check this. Examples
- queueWithdrawal
-> _removeShares
- slashShares
-> _removeShares
As such, if a user mistakenly adds the 0 address in the list of strategies to withdraw from, when calling queueWithdrawal, or the admin when calling slashShares, then the misleading strategy not found revert message will be seen.
Add 0 address check in _removeShares
.
userShares
in StrategyManager.recordOvercommittedBeaconChainETH
calculationIn StrategyManager
at line 191 debt is calculate as so:
uint256 debt = amount - userShares;
and at line 193 amount
is then set to its new value:
amount -= debt;
There is a redundant subtraction here that can be changed to
amount = userShares;
Change line 193 to:
amount = userShares;
PAUSE_ALL
instead of type(uint256).max
The Pausable
contract declares a PAUSE_ALL
constant with the value type(uint256).max
uint256 constant internal PAUSE_ALL = type(uint256).max;
But further down in the contract, in the function Pausable.pauseAll
that is used to pause all functionalities of a contract, it is not used.
/** * @notice Alias for `pause(type(uint256).max)`. */ function pauseAll() external onlyPauser { _paused = type(uint256).max; emit Paused(msg.sender, type(uint256).max); }
Change type(uint256).max)
to PAUSE_ALL
in the contest of Pausable.pauseAll
.
EigenPodManager.stake
In EigenPodManager
, the function stake
creates a pod if the one you want to stake in does not exists (as it's documented to do).
Although this is to be a QoL feature, when staking one does not consider naturally to also create the staking environment.
Consider adding a flag that if true will create the pod if not existing.
Add a flag that if true will create the pod if not existing, do not have this behavior as default.
amount
is 0 the executions passes through in StrateguManager.recordOvercommittedBeaconChainETH
In StrateguManager.recordOvercommittedBeaconChainETH
there is a check that if amount is not 0 then _removeShares
should not be called.
if (amount != 0) { _removeShares(overcommittedPodOwner, beaconChainETHStrategyIndex, beaconChainETHStrategy, amount); }
This check should include all the leftover code as it would call a decreaseDelegatedShares
with an empty amount to withdrawal.
Add all the code after the check in the if block.
_pauserRegistry
from Pausable._initializePauser
is a contractThe currently done checks in the function Pausable._initializePauser
for the _pauserRegistry
do not include a contract check.
As such, an EOA can be set.
function _initializePauser(IPauserRegistry _pauserRegistry, uint256 initPausedStatus) internal { require( address(pauserRegistry) == address(0) && address(_pauserRegistry) != address(0), "Pausable._initializePauser: _initializePauser() can only be called once" );
Add a check that _pauserRegistry
should be a contract.
StrategyBase
In StrategyBase
there are several functions where the documentation indicates
* @notice In contrast to <next_function>, this function **may** make state modifications
while simply they simply call the function that, in its turn indicatesthat it does not modify state changes.
/** * @notice Used to convert a number of shares to the equivalent amount of underlying tokens for this strategy. * @notice In contrast to `sharesToUnderlying`, this function guarantees no state modifications * @param amountShares is the amount of shares to calculate its conversion into the underlying token * @dev Implementation for these functions in particular may vary signifcantly for different strategies */ function sharesToUnderlyingView(uint256 amountShares) public view virtual override returns (uint256) { if (totalShares == 0) { return amountShares; } else { return (_tokenBalance() * amountShares) / totalShares; } } /** * @notice Used to convert a number of shares to the equivalent amount of underlying tokens for this strategy. * @notice In contrast to `sharesToUnderlyingView`, this function **may** make state modifications * @param amountShares is the amount of shares to calculate its conversion into the underlying token * @dev Implementation for these functions in particular may vary signifcantly for different strategies */ function sharesToUnderlying(uint256 amountShares) public view virtual override returns (uint256) { return sharesToUnderlyingView(amountShares); }
All these functions are view so they cannot change state.
https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/strategies/StrategyBase.sol#L166-L188 https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/strategies/StrategyBase.sol#L190-L213 https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/strategies/StrategyBase.sol#L215-L229
Leave only one example of each (the one without the View
in the name, as this is not standard).
There are places in the project where there is either incorrect or misleading code documentation (either via NatSpec or inline). These need to be corrected
StrategyManager.depositBeaconChainETH
declares the amount
param 2 times, the second time being a leftover from a copy-paste (code)
in StrategyManager
functions addStrategiesToDepositWhitelist
and removeStrategiesFromDepositWhitelist
document that they are Owner-only function
when in fact they are executable only by the strategy whitelister (onlyStrategyWhitelister
) (code)
in BeaconChainProofs.getBalanceFromBalanceRoot
remove incorrect "is" from documentation (code)
Resolve the mentioned issues
#0 - c4-pre-sort
2023-05-09T14:34:29Z
0xSorryNotSorry marked the issue as high quality report
#1 - GalloDaSballo
2023-06-02T09:17:00Z
| [L-01] | StrategyManager._removeShares
does not check for 0 address; reverts with misleading message | 1 |
R
| [R-01]| Reuse userShares
in StrategyManager.recordOvercommittedBeaconChainETH
calculation | 1 |
R
| [R-02]| Use already defined PAUSE_ALL
instead of type(uint256).max
| 3 |
R
| [R-03]| Add createIfNotExisting
flag on EigenPodManager.stake
| 1 |
Disagree, I think their solution is comfier
| [NC-01]| If amount
is 0 the executions passes through in StrateguManager.recordOvercommittedBeaconChainETH
| 1 |
NC
| [NC-02]| Check that argument _pauserRegistry
from Pausable._initializePauser
is a contract | 1 |
R
| [NC-03]| Remove redundant functions that wrap over other functions from StrategyBase
| 3 |
Disagree, the base inherits from interface and changes to view, but the function is non-view so it can be overriden as such
| [NC-04]| Misleading or incorrect documentation | 3 |
L because docs
1L 4R 1NC
#2 - c4-judge
2023-06-02T09:38:31Z
GalloDaSballo marked the issue as grade-a