57008 sc critical emergencywithdraw function malfunction due to missing validation in removeanysharesfor

Submitted on Oct 22nd 2025 at 15:38:01 UTC by @Happy_Hunter for Audit Comp | Belongarrow-up-right

  • Report ID: #57008

  • Report Type: Smart Contract

  • Report severity: Critical

  • Target: https://github.com/immunefi-team/audit-comp-belong/blob/main/contracts/v2/periphery/Staking.sol

  • Impacts:

    • Contract fails to deliver promised returns, but doesn't lose value

Description

Brief/Intro

The Staking contract's emergencyWithdraw() function does not work properly when share transfers occur because the _removeAnySharesFor() function is missing the critical validation check:

if (remaining != 0) revert MinStakePeriodNotMet()

When users transfer their sLONG shares to another address, the stake records remain in the original owner's account, creating orphaned entries. The emergencyWithdraw() function then succeeds even when called on an address with no stake records, because _removeAnySharesFor() silently returns without validating whether it successfully removed the required shares. This causes the function to process withdrawals it should reject, leaving permanent orphaned stake records that corrupt state data used by third-party integrations.

Vulnerability Details

The Staking contract has two internal functions for managing stake records:

  1. _consumeUnlockedSharesOrRevert() - Used in normal withdrawals (SAFE):

  1. _removeAnySharesFor() - Used in emergency withdrawals (VULNERABLE):

The _removeAnySharesFor() function is missing the validation check if (remaining != 0) revert. This means emergency withdrawals succeed even when the owner has insufficient or zero stake records, completely bypassing the lock mechanism.

1

Initial Deposit

Alice deposits 1000 LONG using account_01

  • Receives 1000 sLONG shares

  • balanceOf(account_01) = 1000

  • stakes[account_01][0] = {shares: 1000, timestamp: T}

  • Shares are locked for minStakePeriod (1 day by default)

2

Emergency Situation

Alice urgently needs her LONG tokens immediately but cannot wait for the lock period to expire. The normal withdraw() function will revert with MinStakePeriodNotMet.

3

Share Transfer

Alice transfers all 1000 sLONG shares to account_02 (also owned by her):

  • balanceOf(account_01) = 0 (updated by ERC20 transfer)

  • balanceOf(account_02) = 1000 (updated by ERC20 transfer)

  • stakes[account_01][0] = {shares: 1000, timestamp: T} (NOT updated - remains orphaned)

  • stakes[account_02] = [] (empty - no stake records transferred)

4

Approval

Alice approves account_01 to spend account_02's shares

5

emergencyWithdraw Execution

Alice calls emergencyWithdraw(1000, account_01, account_02) from account_01:

  • _removeAnySharesFor(account_02, 1000) attempts to remove stake records from account_02

  • Loop through stakes[account_02] (empty array) - loop doesn't execute

  • remaining = 1000 (unchanged)

  • Missing validation: function returns without checking if (remaining != 0) revert MinStakePeriodNotMet()

  • _burn(account_02, 1000) succeeds (account_02 has the share balance)

  • Alice receives 900 LONG (1000 - 10% penalty)

6

Corrupt State

The withdrawal succeeds despite account_02 having no stake records to validate:

  • stakes[account_01] remains with orphaned record {shares: 1000, timestamp: T}

  • balanceOf(account_01) = 0 (no actual shares)

  • Third-party integrations reading stakes[account_01] see 1000 locked shares that don't exist

Impact Details

The emergencyWithdraw() function fails to properly validate stake ownership due to the missing if (remaining != 0) revert check in _removeAnySharesFor(). When shares are transferred between addresses, stake records remain orphaned in the original owner's account while the new owner has the actual share balance but no stake entries. The _removeAnySharesFor() function silently succeeds even when it cannot find any stake records to remove, allowing emergencyWithdraw() to process withdrawals for addresses that have no locked stakes. This breaks the function's intended behavior of only allowing emergency withdrawals for properly staked positions and creates permanent orphaned stake records that cannot be cleaned up.

If there is any third-party integration that uses the stakes variable to track staked positions, locked amounts, or time-lock status, this flaw is very serious as it provides completely incorrect data where addresses show locked stakes they don't own while actual share holders show no stake records at all.

References

  • contracts/v2/periphery/Staking.sol

  • Solady ERC4626: https://github.com/Vectorized/solady/blob/main/src/tokens/ERC4626.sol

Recommendation

Add the missing validation to _removeAnySharesFor() by mirroring the check in _consumeUnlockedSharesOrRevert():

This enforces that emergency withdrawals can only proceed when the required shares are actually removed from the stake records, preserving the intended lock semantics and preventing orphaned stake entries.

https://gist.github.com/SproutGoodHub/4b50713fe9e5396a279b064bd5116c01

Proof of Concept

Test Code

Running the Test

Test Output

Was this helpful?