Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cheerful Taffy Dolphin - Zero-Value Operation Bypass in Vault Update Function Enables Economic Manipulation Through Artificial Checkpoint Creation #48

Open
sherlock-admin4 opened this issue Jan 31, 2025 · 0 comments
Labels
Sponsor Disputed The sponsor disputed this issue's validity

Comments

@sherlock-admin4
Copy link
Contributor

Cheerful Taffy Dolphin

Medium

Zero-Value Operation Bypass in Vault Update Function Enables Economic Manipulation Through Artificial Checkpoint Creation

Summary

A critical vulnerability has been identified in Perennial V2's Vault implementation that allows manipulation of core economic mechanisms through zero-value operations. The vulnerability exists in the vault's _update() function, which manages deposits, redemptions, and claims. While the function implements checks for minimum deposits and single-sided operations, it fails to properly validate zero-value state transitions. This oversight allows an attacker to influence share price calculations, manipulate loss socialization, and force unnecessary market interactions—all without committing economic value to the system.

The severity of this issue is amplified by the vault's role as a core capital management component of the Perennial V2 protocol, directly affecting user funds and protocol stability. The primary risk lies in the manipulation of profit distribution and performance metrics, potentially impacting all vault participants through degraded price discovery and increased operational costs.

The _update() function's validation sequence creates a critical flaw in the vault's economic safeguards by failing to properly validate zero-value operations:

https://github.com/sherlock-audit/2025-01-perennial-v2-4-update/blob/main/perennial-v2/packages/vault/contracts/Vault.sol#L331

function _update(Context memory context, address account, UFixed6 depositAssets, UFixed6 redeemShares, UFixed6 claimAssets) private {
    if (!depositAssets.isZero() && depositAssets.lt(context.parameter.minDeposit))
        revert VaultInsufficientMinimumError();

This validation structure allows zero-value operations to trigger legitimate state changes in both global and local contexts:

context.global.update(context.currentId, claimAssets, redeemShares, depositAssets, redeemShares);
context.local.update(context.currentId, claimAssets, redeemShares, depositAssets, redeemShares);
context.currentCheckpoint.update(depositAssets, redeemShares);

Each artificial state change cascades through the system, affecting profit calculations via the checkpoint mechanism:

(context.mark, profitShares) = nextCheckpoint.complete(mark, parameter, _checkpointAtId(timestamp));

The impact amplifies when these state changes trigger unnecessary position adjustments:

_manage(context, depositAssets, claimAmount, !depositAssets.isZero() || !redeemShares.isZero());

Attack Surface

The primary attack vector exploits checkpoint creation through zero-value operations to manipulate share pricing and profit distribution:

context.global.update(context.currentId, claimAssets, redeemShares, depositAssets, redeemShares);
context.local.update(context.currentId, claimAssets, redeemShares, depositAssets, redeemShares);
context.currentCheckpoint.update(depositAssets, redeemShares);

An attacker can front-run legitimate deposits by creating artificial checkpoints that affect the profit calculation mechanism.

This manipulation affects both immediate share price calculations and long-term performance metrics through the high-water mark system. By strategically timing zero-value operations during price volatility, attackers can influence profit distribution and performance fee calculations.

Artificial state changes affect loss socialization calculations:

function _socialize(Context memory context, UFixed6 claimAssets) private pure {
    return claimAssets.muldiv(
        UFixed6Lib.unsafeFrom(context.totalCollateral).min(context.global.assets),
        context.global.assets
    );
}

By submitting zero-value operations during high volatility, attackers can force recalculation of the socialization ratio. The denominator (context.global.assets) becomes vulnerable to manipulation through pending claims, affecting loss distribution across all vault participants.

Market Position Griefing:

_manage(context, depositAssets, claimAmount, !depositAssets.isZero() || !redeemShares.isZero());

Attackers can front-run significant market updates with zero-value operations that trigger unnecessary rebalancing. This causes:

  • Increased transaction costs through forced rebalancing
  • Potential transaction failures from amplified slippage
  • Price impact during market volatility

The vulnerability stems from a fundamental architectural flaw in the vault's state transition model. By placing economic validity checks after state transition logic, the system fails to distinguish between meaningful and artificial state changes. This architectural weakness transforms zero-value operations from administrative actions into vectors for economic exploitation.

These design flaws allow an attacker to systematically degrade the vault's economic integrity through targeted zero-value operations, affecting share price discovery, loss socialization, and market position management.

Fix

This fix prevents artificial checkpoint creation and ensures all operations have economic substance before affecting vault state.

function _update(
    Context memory context,
    address account,
    UFixed6 depositAssets,
    UFixed6 redeemShares,
    UFixed6 claimAssets
) private {
    // Magic value handling
    if (claimAssets.eq(UFixed6Lib.MAX)) claimAssets = context.local.assets;
    if (redeemShares.eq(UFixed6Lib.MAX)) redeemShares = context.local.shares;

    // Operator validation
    if (msg.sender != account && !IVaultFactory(address(factory())).operators(account, msg.sender))
        revert VaultNotOperatorError();

    // Validate meaningful operation first
    if (depositAssets.isZero() && redeemShares.isZero() && claimAssets.isZero())
        revert VaultNoOperationError();

    // Single-sided operation check
    if (!depositAssets.add(redeemShares).add(claimAssets).eq(depositAssets.max(redeemShares).max(claimAssets)))
        revert VaultNotSingleSidedError();

    // Economic validation for non-zero operations
    if (depositAssets.gt(UFixed6Lib.ZERO)) {
        if (depositAssets.gt(_maxDeposit(context)))
            revert VaultDepositLimitExceededError();
        if (depositAssets.lt(context.parameter.minDeposit))
            revert VaultInsufficientMinimumError();
    }

    if (redeemShares.gt(UFixed6Lib.ZERO)) {
        if (context.latestCheckpoint.toAssets(redeemShares).lt(context.parameter.minDeposit))
            revert VaultInsufficientMinimumError();
    }

    if (context.local.current != context.local.latest) 
        revert VaultExistingOrderError();

    // Process operations
    UFixed6 claimAmount = _socialize(context, claimAssets);

    // Update positions
    context.global.update(context.currentId, claimAssets, redeemShares, depositAssets, redeemShares);
    context.local.update(context.currentId, claimAssets, redeemShares, depositAssets, redeemShares);
    context.currentCheckpoint.update(depositAssets, redeemShares);

    // Manage assets
    asset.pull(msg.sender, UFixed18Lib.from(depositAssets));
    _manage(context, depositAssets, claimAmount, !depositAssets.isZero() || !redeemShares.isZero());
    asset.push(msg.sender, UFixed18Lib.from(claimAmount));

    emit Updated(msg.sender, account, context.currentId, depositAssets, redeemShares, claimAssets);
}

error VaultNoOperationError();
@sherlock-admin3 sherlock-admin3 added the Sponsor Disputed The sponsor disputed this issue's validity label Feb 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Sponsor Disputed The sponsor disputed this issue's validity
Projects
None yet
Development

No branches or pull requests

2 participants