Part 2

Zaros
PerpetualsDEXFoundrySolidity
70,000 USDC
View results
Submission Details
Severity: high
Valid

lastValuePerShare Reset to Zero Enables Value Theft by Claiming Pre-Exit Rewards on Re-entry

Summary

A critical vulnerability has been identified in the Zaros protocol's Distribution system affecting reward calculations for users who exit and re-enter the staking system. This flaw enables potential exploitation of reward distributions through manipulation of the value-per-share accounting mechanism.

The Distribution library implements a reward tracking system used by VaultRouterBranch for managing staker rewards. The vulnerability exists in the _updateLastValuePerShare function:

function _updateLastValuePerShare(
Data storage self,
Actor storage actor,
UD60x18 newActorShares
)
private
returns (SD59x18 valueChange)
{
valueChange = _getActorValueChange(self, actor);
actor.lastValuePerShare = newActorShares.eq(UD60x18_ZERO) ? int256(0) : self.valuePerShare;
}

When an actor's shares become zero during exit, their lastValuePerShare is reset to 0 instead of maintaining the global valuePerShare value. This creates an accounting discrepancy that can be exploited when users re-enter the system.

Exploitation Path

Consider this sequence:

  1. A user enters with globalValuePerShare = 100

    • User's lastValuePerShare is set to 100

    • Normal reward accrual begins

  2. Global value increases to 200 through rewards

    • User's reward calculation: 200 - 100 = 100 (correct)

  3. User exits the system

    • Instead of maintaining lastValuePerShare = 200

    • System incorrectly sets lastValuePerShare = 0

  4. Global value increases to 300

  5. User re-enters the system

    • System calculates rewards as: 300 - 0 = 300

    • Correct calculation should be: 300 - 200 = 100

    • User receives 200 extra reward units

Impact

The vulnerability creates a compounding economic drain through reward accounting errors. When users exploit the exit/re-entry pattern, they receive inflated rewards at the expense of legitimate stakers, directly undermining the protocol's incentive structure. This exploitation scales with protocol activity - higher reward frequencies, value volatility, and pool sizes amplify potential gains, while increased user activity masks the manipulation. The resulting feedback loop threatens protocol sustainability as successful exploitation breeds further abuse, creating an exponential drain on resources.

The vulnerability can be further demonstrated through the VaultRouterBranch contract:

function unstake(uint128 vaultId, uint256 shares) external {
// ...
wethRewardDistribution.accumulateActor(actorId);
UD60x18 updatedActorShares = actorShares.sub(ud60x18(shares));
wethRewardDistribution.setActorShares(actorId, updatedActorShares);
// ...
}

Even with the protective check:

if (!amountToClaimX18.isZero()) revert Errors.UserHasPendingRewards(...)

The underlying accounting error persists and can be exploited through careful timing of exits and entries.

Example Scenario:

  1. User State Before Exit:

- Has accumulated rewards (amountToClaimX18 > 0)
- Current global valuePerShare = 200
  1. User Actions:

- First claims all pending rewards (amountToClaimX18 becomes 0)
- Immediately calls unstake()
- Now passes the check `if (!amountToClaimX18.isZero())`
- lastValuePerShare incorrectly resets to 0
  1. During Unstaked Period:

- Global valuePerShare increases to 300
- User is not accumulating rewards (as expected)
  1. User Re-enters:

- lastValuePerShare starts at 0 (instead of 200)
- Will receive rewards for period they weren't staked
- Calculation: 300 - 0 = 300 (instead of 300 - 200 = 100)

The protection only prevents unstaking with unclaimed rewards. It doesn't address the underlying issue of lastValuePerShare being reset to 0. A user can always clear their pending rewards first, then execute the exit/re-entry strategy to exploit the accounting error.

Recommendation

Modify _updateLastValuePerShare:

function _updateLastValuePerShare(
Data storage self,
Actor storage actor,
UD60x18 newActorShares
)
private
returns (SD59x18 valueChange)
{
valueChange = _getActorValueChange(self, actor);
actor.lastValuePerShare = self.valuePerShare; // Always maintain last global value
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Inside VaultRouterBranch if you stake wait some time then stake again makes you lose the rewards.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.