Skip to content

Queued withdrawals are not treated correctly when a slash occurs, leading to loss of user funds #404

Open
@code423n4

Description

@code423n4

Lines of code

https://github.com/code-423n4/2023-04-eigenlayer/blob/5e4872358cd2bda1936c29f460ece2308af4def6/src/contracts/core/StrategyManager.sol#L523
https://github.com/code-423n4/2023-04-eigenlayer/blob/5e4872358cd2bda1936c29f460ece2308af4def6/src/contracts/core/StrategyManager.sol#L567-L573

Vulnerability details

Description

According to the documentation, the EigenLayer protocol allows for partial slashes. If an operator is slashed, all stakers who have delegated to the operator will be subject to the same percentage slash. This means that if a delegate is slashed by $X$%, all stakers who trusted the operator will also be slashed by $X$%. However, there is an issue with queued withdrawals during partial slashes. Currently, the implementation reduces the queued withdrawal to 100%, resulting in a loss of funds for the user.

Impact

Despite intending to implement a partial slash, the current issue has resulted in the complete cancellation of queued withdrawals and the loss of funds for the user, therefore we evaluate the impact of this issue as HIGH.

Proof of Concept

The scenario below explains the issue:

Context:

  • Alice is a staker who has delegated their shares
  • Bob is the operator
  • Alice and Bob have staked 100 shares each

Scenario:

  1. Alice initiates a withdrawal of 100 shares using the queueWithdrawal function, which sets her shares in the stakerStrategyShares mapping to 0.
  2. A day later, Bob is slashed by governance for 30 shares due to misbehavior, using the StategyManager::slashShares function.
  3. To slash Alice's shares that do not correspond to the queued withdrawal, calling the StategyManager::slashShares function does not make sense, as the stakerStrategyShares element in the mapping was previously set to 0. Doing so would cause the StategyManager::slashShares transaction to revert.
  4. Instead, the StategyManager owner calls the StategyManager::slashQueuedWithdrawals function to slash Alice's queued withdrawal. Alice should only be slashed for 30% of her shares in the strategy. BUT, the amount to withdraw from the strategy corresponds to all the shares in the queuedWithdrawal.share[i] element, which results in Alice being fully slashed.
  5. As a result, Alice loses all her shares, instead of just 30.

Recommended Mitigation steps

The issue stems from the failure to account for the percentage of shares of the operator that are cut during a slash.
We propose two options to mitigate this issue:

Option A

Disallow partial slashes and only allow full slashes.

Option B

When an operator is slashed, take into account the percentage that is slashed and slash the queued withdrawals accordingly.
This can be done by including the amount of shares to slash in the function StrategyManager::slashQueuedWithdrawal.

    function slashQueuedWithdrawal(
        address recipient,
        QueuedWithdrawal calldata queuedWithdrawal,
        IERC20[] calldata tokens,
        uint256[] calldata indicesToSkip
+       uint256[] calldata sharesToSlash,
    )
        external
        onlyOwner
        onlyFrozen(queuedWithdrawal.delegatedAddress)
        nonReentrant
    {
        require(tokens.length == queuedWithdrawal.strategies.length, "StrategyManager.slashQueuedWithdrawal: input length mismatch");

+       require(sharesToSlash.length == queuedWithdrawal.strategies.length, "StrategyManager.slashQueuedWithdrawal: input length mismatch");

        // find the withdrawalRoot
        bytes32 withdrawalRoot = calculateWithdrawalRoot(queuedWithdrawal);

        // verify that the queued withdrawal is pending
        require(
            withdrawalRootPending[withdrawalRoot],
            "StrategyManager.slashQueuedWithdrawal: withdrawal is not pending"
        );

        // reset the storage slot in mapping of queued withdrawals
        withdrawalRootPending[withdrawalRoot] = false;

        // keeps track of the index in the `indicesToSkip` array
        uint256 indicesToSkipIndex = 0;

        uint256 strategiesLength = queuedWithdrawal.strategies.length;
        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 {
                if (queuedWithdrawal.strategies[i] == beaconChainETHStrategy){
                     //withdraw the beaconChainETH to the recipient
-                   _withdrawBeaconChainETH(queuedWithdrawal.depositor, recipient, queuedWithdrawal.shares[i]);
+                   _withdrawBeaconChainETH(queuedWithdrawal.depositor, recipient,sharesToSlash[i]);
                } else {
                    // tell the strategy to send the appropriate amount of funds to the recipient
-                   queuedWithdrawal.strategies[i].withdraw(recipient, tokens[i], queuedWithdrawal.shares[i]);
+                   queuedWithdrawal.strategies[i].withdraw(recipient, tokens[i], sharesToSlash[i]);
                }
                unchecked {
                    ++i;
                }
            }
        }

Assessed type

Other

Metadata

Metadata

Assignees

No one assigned

    Labels

    QA (Quality Assurance)Assets are not at risk. State handling, function incorrect as to spec, issues with clarity, syntaxbugSomething isn't workingdisagree with severitySponsor confirms validity, but disagrees with warden’s risk assessment (sponsor explain in comments)downgraded by judgeJudge downgraded the risk level of this issuegrade-ahigh quality reportThis report is of especially high qualityprimary issueHighest quality submission among a set of duplicates

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions