EigenLayer Contest - QiuhaoLi's results

Enabling restaking of staked Ether, to be used as cryptoeconomic security for decentralized protocols and applications.

General Information

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

EigenLayer

Findings Distribution

Researcher Performance

Rank: 5/43

Findings: 3

Award: $2,972.95

QA:
grade-b
Gas:
grade-a

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: Cyfrin

Also found by: Josiah, QiuhaoLi, RaymondFam

Labels

bug
2 (Med Risk)
downgraded by judge
judge review requested
satisfactory
duplicate-408

Awards

2037.7578 USDC - $2,037.76

External Links

Lines of code

https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L498-L505 https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/pods/EigenPod.sol#L241

Vulnerability details

Impact

Malicious staker can avoid the service's slash on its restaked ETH shares.

Proof of Concept

When a service/middleware decides to slash a staker, it first calls slasher.freezeOperator() to freeze the operator/staker and then call slashShares(). The freeze and slash operations are two independent APIs (not atomic):

    /**
     * @notice Used for 'slashing' a certain operator.
     * @param toBeFrozen The operator to be frozen.
     * @dev Technically the operator is 'frozen' (hence the name of this function), **and then subject to slashing pending a decision by a human-in-the-loop.**
     * @dev The operator must have previously given the caller (which should be a contract) the ability to slash them, through a call to `optIntoSlashing`.
     */
    function freezeOperator(address toBeFrozen) external;

Although calling to queueWithdrawal() requires the staker not frozen, verifyOvercommittedStake(EigenPos.sol)--> recordOvercommittedBeaconChainETH (StrategyManager) is callable by a frozen staker.

So a malicious staker can do the following: 1.Plan a time to make it have less than REQUIRED_BALANCE_WEI on the beacon chain and monitor the beacon/execution chain. 2.When the time arrives, the staker does malicious things for service. 3.The malicious behaviors are found by someone and the attacker is frozen. 4.The service decides to slash the staker's shares, broadcast a tx calling slashShares(). 5.The staker found the tx and front-run the verifyOvercommittedStake() to remove all his shares (also the ETH strategy). 6.The slash fails because there are no shares to slash.

And there are no incentives for watchers to call verifyOvercommittedStake, which increases the possibility the attacker win the time window -- maybe another issue.

Tools Used

Manual Review.

Add onlyNotFrozen modifier to make verifyOvercommittedStake(EigenPos.sol)/recordOvercommittedBeaconChainETH (StrategyManager) only callable from non-frozen podOwner.

Assessed type

MEV

#0 - 0xSorryNotSorry

2023-05-07T18:53:38Z

The issue is unclear on some points like how the dishonest staker avoided slashing, which functions they called or setup prior to discover the SlashShares() TX.

#1 - c4-pre-sort

2023-05-09T13:55:45Z

0xSorryNotSorry marked the issue as primary issue

#2 - c4-sponsor

2023-05-12T00:35:24Z

Sidu28 requested judge review

#3 - Sidu28

2023-05-12T00:35:29Z

We believe this is a duplicate of issue 259, as it appears to have the same root cause.

#4 - c4-judge

2023-06-01T13:50:17Z

GalloDaSballo marked the issue as duplicate of #210

#5 - c4-judge

2023-06-01T15:05:13Z

GalloDaSballo marked the issue as duplicate of #210

#6 - c4-judge

2023-06-05T13:31:06Z

GalloDaSballo marked the issue as not a duplicate

#7 - c4-judge

2023-06-05T13:31:20Z

GalloDaSballo marked the issue as duplicate of #408

#8 - c4-judge

2023-06-05T13:31:36Z

GalloDaSballo changed the severity to 2 (Med Risk)

#9 - c4-judge

2023-06-06T14:54:09Z

GalloDaSballo marked the issue as satisfactory

Findings Information

Labels

bug
grade-b
QA (Quality Assurance)
Q-08

Awards

71.6048 USDC - $71.60

External Links

[LOW] _removeStrategyFromStakerStrategyList() should check strategyIndex is not out-of-bounds:

https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L715

    function _removeStrategyFromStakerStrategyList(address depositor, uint256 strategyIndex, IStrategy strategy) internal {
        // if the strategy matches with the strategy index provided
        if (stakerStrategyList[depositor][strategyIndex] == strategy) {
            // replace the strategy with the last strategy in the list
            stakerStrategyList[depositor][strategyIndex] =
                stakerStrategyList[depositor][stakerStrategyList[depositor].length - 1];
        } else {
            //loop through all of the strategies, find the right one, then replace
            uint256 stratsLength = stakerStrategyList[depositor].length;
            uint256 j = 0;
            for (; j < stratsLength;) {

The strategyIndex is used as an optimistic method here, but forget to check it against the stakerStrategyList[depositor].length, which may lead to an array OOB.

[LOW] No Incentives for watchers to call verifyOvercommittedStake()

https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/pods/EigenPod.sol#L241

There are no incentives for watchers to call verifyOvercommittedStake(), which may help MEV attackers in some situations.

[non-critical] completeQueuedWithdrawals() should check queuedWithdrawals.length == tokens.length == middlewareTimesIndexes.length == receiveAsTokens.length

https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L456

    function completeQueuedWithdrawals(
        QueuedWithdrawal[] calldata queuedWithdrawals,
        IERC20[][] calldata tokens,
        uint256[] calldata middlewareTimesIndexes,
        bool[] calldata receiveAsTokens
    ) external
        onlyWhenNotPaused(PAUSED_WITHDRAWALS)
        // check that the address that the staker *was delegated to* – at the time that they queued the withdrawal – is not frozen
        nonReentrant
    {
        for(uint256 i = 0; i < queuedWithdrawals.length; i++) {
            _completeQueuedWithdrawal(queuedWithdrawals[i], tokens[i], middlewareTimesIndexes[i], receiveAsTokens[i]);
        }
    }

[non-critical] Wrong comment for depositBeaconChainETH in IStrategyManager

https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/interfaces/IStrategyManager.sol#L53 https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/core/StrategyManager.sol#L164

     * @dev Only callable by EigenPod for the staker.
     */
    function depositBeaconChainETH(address staker, uint256 amount) external;

The comment says depositBeaconChainETH is only callable by EigenPod, but in StrategyManager.sol#L164 it's onlyEigenPodManager:

    function depositBeaconChainETH(address staker, uint256 amount)
        external
        onlyEigenPodManager
        onlyWhenNotPaused(PAUSED_DEPOSITS)
        onlyNotFrozen(staker)
        nonReentrant
    {
        // add shares for the enshrined beacon chain ETH strategy
        _addShares(staker, beaconChainETHStrategy, amount);
    }

#0 - GalloDaSballo

2023-06-01T18:01:56Z

[LOW] _removeStrategyFromStakerStrategyList() should check strategyIndex is not out-of-bounds:

R

[LOW] No Incentives for watchers to call verifyOvercommittedStake()

R

[non-critical] completeQueuedWithdrawals() should check queuedWithdrawals.length == tokens.length == middlewareTimesIndexes.length == receiveAsTokens.length

OOS

[non-critical] Wrong comment for depositBeaconChainETH in IStrategyManager

L

#1 - c4-judge

2023-06-02T09:38:45Z

GalloDaSballo marked the issue as grade-b

Findings Information

Labels

bug
G (Gas Optimization)
grade-a
G-06

Awards

863.5876 USDC - $863.59

External Links

_claimDelayedWithdrawals doesn't reuse/delete the paid DelayedWithdrawal slot in _userWithdrawals

https://github.com/code-423n4/2023-04-eigenlayer/blob/main/src/contracts/pods/DelayedWithdrawalRouter.sol#L142

In createDelayedWithdrawal(), new DelayedWithdrawal is pushes into the _userWithdrawals array:

    function createDelayedWithdrawal(address podOwner, address recipient) external payable onlyEigenPod(podOwner) {
        uint224 withdrawalAmount = uint224(msg.value);
        if (withdrawalAmount != 0) {
            DelayedWithdrawal memory delayedWithdrawal = DelayedWithdrawal({
                amount: withdrawalAmount,
                blockCreated: uint32(block.number)
            });
            _userWithdrawals[recipient].delayedWithdrawals.push(delayedWithdrawal);
            emit DelayedWithdrawalCreated(podOwner, recipient, withdrawalAmount, _userWithdrawals[recipient].delayedWithdrawals.length - 1);
        }
    }

However, when these DelayedWithdrawals are paid/claimed, the useless slot is not reused.

    function _claimDelayedWithdrawals(address recipient, uint256 maxNumberOfDelayedWithdrawalsToClaim) internal {
        uint256 amountToSend = 0;
        uint256 delayedWithdrawalsCompletedBefore = _userWithdrawals[recipient].delayedWithdrawalsCompleted;
        uint256 _userWithdrawalsLength = _userWithdrawals[recipient].delayedWithdrawals.length;
        uint256 i = 0;
        while (i < maxNumberOfDelayedWithdrawalsToClaim && (delayedWithdrawalsCompletedBefore + i) < _userWithdrawalsLength) {
            // copy delayedWithdrawal from storage to memory
            DelayedWithdrawal memory delayedWithdrawal = _userWithdrawals[recipient].delayedWithdrawals[delayedWithdrawalsCompletedBefore + i];
            // check if delayedWithdrawal can be claimed. break the loop as soon as a delayedWithdrawal cannot be claimed
            if (block.number < delayedWithdrawal.blockCreated + withdrawalDelayBlocks) {
                break;
            }
            // otherwise, the delayedWithdrawal can be claimed, in which case we increase the amountToSend and increment i
            amountToSend += delayedWithdrawal.amount;
            // increment i to account for the delayedWithdrawal being claimed
            unchecked {
                ++i;
            }
        }
        // mark the i delayedWithdrawals as claimed
        _userWithdrawals[recipient].delayedWithdrawalsCompleted = delayedWithdrawalsCompletedBefore + i;
        // actually send the ETH
        if (amountToSend != 0) {
            AddressUpgradeable.sendValue(payable(recipient), amountToSend);
        }
        emit DelayedWithdrawalsClaimed(recipient, amountToSend, delayedWithdrawalsCompletedBefore + i);
    }

If we can reuse these slots (e.g. use a pre-allocated array as a circular buffer), we can make the storage cost from O(n) to a const number. This can be a big saving for a busy recipient.

#0 - GalloDaSballo

2023-06-01T16:42:17Z

Interesting idea that would avoid SSTOREs to zero values, awarding 2L

#1 - c4-judge

2023-06-01T16:58:54Z

GalloDaSballo marked the issue as grade-a

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter