A malicious actor can permanently freeze other users’ staked assets in the Staking contract by exploiting unbounded iteration in withdrawal logic. The vulnerability occurs because stakes are stored in an ever-growing array and iterated over on every withdraw and emergencyWithdraw. By strategically inserting thousands of zero-value stakes, an attacker can cause all withdrawal-related transactions for a victim to run out of gas, resulting in permanently frozen funds for affected users.
Vulnerability Details
The Staking contract maintains a list of Stake structs per user in a dynamic array:
mapping(address staker => Stake[])public stakes;
Each time a user deposits, a new Stake entry is appended:
function_deposit(addressby,addressto,uint256assets,uint256shares)internaloverride{super._deposit(by, to, assets, shares); stakes[to].push(Stake({shares: shares, timestamp:block.timestamp}));}
During withdrawals, the _consumeUnlockedSharesOrRevert and _removeAnySharesFor functions iterate over the entire stakes[staker] array, performing swap-and-pop operations to consume unlocked stakes:
Since there are no bounds or gas-efficiency constraints on the array length, the time complexity grows linearly with the number of stakes. A malicious user can exploit this by depositing a large number of zero-value stakes into another user’s stakes array (possible in the current implementation).
When the victim later attempts to withdraw, the function will attempt to iterate through thousands of stakes, ultimately exceeding the block gas limit and reverting. This creates a permanent denial of service: the victim’s withdrawal (and emergencyWithdraw) will consistently revert, effectively freezing their assets indefinitely.
Exploitability is worsened because the attacker spends no tokens to bloat another user’s stakes array with many entries of zero value.
Exploitation Steps
1
Step
Attacker deposits 0 tokens at least once before the victim's legitimate deposit:
2
Step
Victim deposits tokens at some point in time:
3
Step
Attacker deposits 0 tokens multiple times using staking.deposit(0, victimAddress). Around 3,500 deposits were sufficient to exceed typical Ethereum block gas limits in testing.
4
Step
Victim calls withdraw() or emergencyWithdraw(); the loop in _consumeUnlockedSharesOrRevert runs out of gas before completing and the transaction reverts, leaving the victim unable to access their staked funds.
Impact Details
Impact: Permanent freezing of assets
An attacker can target any staker by bloating their stakes array with 0-amount deposits. Once the gas cost exceeds the block gas limit, the victim’s withdrawal and emergency withdrawal functions will always revert, regardless of the gas limit provided. In testing, approximately 3,500 zero-amount deposits were sufficient to cause a revert on Ethereum mainnet gas limits.
As a result, users’ tokens become permanently locked in the contract, and no administrative mechanism exists to clean or reset stake entries.
This impact qualifies as "Permanent freezing of user funds", which is a Critical severity according to Immunefi’s impact classification.
References
Staking.sol: pushing new entries to stakes
https://github.com/immunefi-team/audit-comp-belong/blob/main/contracts/v2/periphery/Staking.sol#L245
Staking.sol: iterating over all entries of stakes at withdraw
https://github.com/immunefi-team/audit-comp-belong/blob/main/contracts/v2/periphery/Staking.sol#L258-L290
Staking.sol: iterating over all entries of stakes at emergencyWithdraw
https://github.com/immunefi-team/audit-comp-belong/blob/main/contracts/v2/periphery/Staking.sol#L296-L315
Proof of Concept
Proof of Concept (coded)
Add the following PoC at test/v2/platform/staking.test.ts in Staking features.
Proof of Concept (in steps)
1
Step
Attacker creates initial zero stake before victim: