28788 - [SC - Critical] Slash during a withdrawal from EigenLayer will ...

Submitted on Feb 27th 2024 at 08:04:12 UTC by @dontonka for Boost | Puffer Finance

Report ID: #28788

Report type: Smart Contract

Report severity: Critical

Target: https://etherscan.io/address/0xd9a442856c234a39a81a089c06451ebaa4306a72

Impacts:

  • Protocol insolvency

Description

Brief/Intro

There is an edge case that can occur during EigenLayer withdrawal process that can break some key invariants of the PufferVault contract. Essentially, if the queuedWithdrawal is being slashed in the middle of the withdrawal process (which is a 2-step process), PufferVault's accounting will be in a broken state and not recoverable, which can lead to unexpected results and most likely to protocol insolvency and seem to warrant Critical severity.

Vulnerability Details

The EigenLayer withdrawal process is a 2-step process, so first a call to initiateStETHWithdrawalFromEigenLayer is done and later on claimWithdrawalFromEigenLayer to actually complete the withdrawal and receive the funds. If somehow _EIGEN_STRATEGY_MANAGER.slashQueuedWithdrawal is called successfully against the queuedWithdrawal generated when calling initiateStETHWithdrawalFromEigenLayer, this will cause the problem, as claimWithdrawalFromEigenLayer will always revert afterward for this queuedWithdrawal, which mean $.eigenLayerPendingWithdrawalSharesAmount will be inflated, as the funds being withdrawn are gone already (slashed, so transfered to an external recipient), and there will be no way to apply this correction against eigenLayerPendingWithdrawalSharesAmount. Granted that this is an edge case as mention earlier, but it can definately happen as slashQueuedWithdrawal is not something Puffer team can control, it's an external event that can always happen.

Impact Details

getELBackingEthAmount() and totalAssets() will return an inflated value compare to the real underlying value of the vault once slash occurs in the middle of a withdrawal, which means translate into protocol insolvency. This gap will compounds overtime as those issues occurs.

References

Here are the code snipet that helps understand the problem.

Recommendation

There is no magic mitigation for this issue. Since at that point isValidWithdrawal passed, we know the queuedWithdrawal is valid and initiateStETHWithdrawalFromEigenLayer was called, so we try our best effort to get the funds, so if we put this into a try/catch at least the PufferVault accounting will remain in good state. It's not perfect either as completeQueuedWithdrawal can revert for multiple reasons, some might be only temporary, which would work in the near future if we would retry (but not the current edge case which would be a permanent revert), so the mitigation I'm proposing also have downsides. The problem is also that slashQueuedWithdrawal is not even emitting a log. Otherwise, you could leave claimWithdrawalFromEigenLayer as is, but add another restricted function that could correct eigenLayerPendingWithdrawalSharesAmount manually in case the edge case reported here is detected (manually I guess), that would not be perfect either as there will be a window where the vault accounting will be broken. So there is no perfect mitigation to this issue.

Proof of Concept

Unfortunatelly, I cannot have a coded PoC for this report as the slashQueuedWithdrawal is owned by EigenLayer (onlyOwner), so I cannot really simulate this with the current integration test suite, but we can reason about it in a clear way with the code indicated previously, so I don't think it is really required.

So if a normal withdrawal is attempted from EigenLayer but inadvertently such queuedWithdrawal is being slashed successfully before the call to claimWithdrawalFromEigenLayer, we will have this problematic edge case.

  1. initiateStETHWithdrawalFromEigenLayer is called.

  2. _EIGEN_STRATEGY_MANAGER.slashQueuedWithdrawal is called againts the queuedWithdrawal genered previously.

  3. claimWithdrawalFromEigenLayer is called, but always revert.

  4. call to getELBackingEthAmount() and totalAssets() will result in an inflated value, which is the problem.

Last updated

Was this helpful?