DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: low
Invalid

Front-running liquidations through flow state manipulation

Summary

A critical vulnerability in PerpetualVault allows users with underwater positions to evade liquidation by front-running liquidation transactions with actions that manipulate the contract's flow state.

Severity: High

This vulnerability allows users to avoid legitimate liquidations, putting the protocol's solvency at risk.

Vulnerability Details

The PerpetualVault contract uses a flow state mechanism (FLOW enum) to control operation sequences. Liquidations can only be executed when the flow state is appropriate. The attack exploits three key factors:

  1. Flow state transitions are not protected against front-running

  2. Liquidations are handled differently depending on the current flow state

  3. Users can observe pending liquidation transactions in the mempool

The issue exists in the afterLiquidationExecution() function:

function afterLiquidationExecution() external {
if (msg.sender != address(gmxProxy)) {
revert Error.InvalidCall();
}
depositPaused = true;
uint256 sizeInTokens = vaultReader.getPositionSizeInTokens(curPositionKey);
if (sizeInTokens == 0) {
delete curPositionKey;
}
if (flow == FLOW.NONE) {
flow = FLOW.LIQUIDATION;
nextAction.selector = NextActionSelector.FINALIZE;
} else if (flow == FLOW.DEPOSIT) {
flowData = sizeInTokens;
} else if (flow == FLOW.WITHDRAW) {
// restart the withdraw flow
nextAction.selector = NextActionSelector.WITHDRAW_ACTION;
}
}

The behavior of this function depends on the flow state at the time it's called, and an attacker can manipulate this state by front-running the liquidation transaction with an action like a withdrawal.

Proof of Concept

The following PoC demonstrates the attack:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
contract FlowStateFrontRunningTest is Test {
// Mock contracts
MockPerpetualVault vault;
MockGmxProxy gmxProxy;
MockKeeperProxy keeper;
// Test accounts
address public owner = address(0x1);
address public user = address(0x2);
address public attacker = address(0x3);
address public liquidator = address(0x4);
// Flow states
uint8 public constant NORMAL = 0;
uint8 public constant DEPOSIT = 1;
uint8 public constant WITHDRAWAL = 2;
uint8 public constant LIQUIDATION = 3;
function setUp() public {
// Setup
gmxProxy = new MockGmxProxy();
vault = new MockPerpetualVault(address(gmxProxy));
keeper = new MockKeeperProxy(address(vault));
vm.startPrank(owner);
vault.setFlowState(NORMAL);
vault.addAuthorizedKeeper(address(keeper));
vault.setPositionData(user, 10000e6, 2000e6);
vm.stopPrank();
}
function testLiquidationFrontRunning() public {
console.log("Initial flow state:", vault.flowState());
// Attacker front-runs liquidation with withdrawal
vm.prank(attacker);
vault.simulateWithdrawal(1000e6);
console.log("Flow state after front-running:", vault.flowState());
// Liquidation transaction arrives
vm.prank(liquidator);
keeper.executeLiquidation(user);
// Check results
console.log("Flow state after liquidation attempt:", vault.flowState());
console.log("Position liquidated?", vault.liquidated(user));
// Verify attack success
assertTrue(
vault.flowState() != LIQUIDATION || !vault.liquidated(user),
"Liquidation should be disrupted by front-running"
);
}
}
// Simplified mock contracts that simulate the real behavior
contract MockPerpetualVault {
address public gmxProxy;
uint8 public flowState;
mapping(address => bool) public authorizedKeepers;
mapping(address => bool) public liquidated;
constructor(address _gmxProxy) {
gmxProxy = _gmxProxy;
}
function setFlowState(uint8 _state) external {
flowState = _state;
}
function addAuthorizedKeeper(address keeper) external {
authorizedKeepers[keeper] = true;
}
function setPositionData(address user, uint256 size, uint256 collateral) external {}
function simulateWithdrawal(uint256 amount) external {
flowState = 2; // WITHDRAWAL
}
function liquidatePosition(address user) external {
require(authorizedKeepers[msg.sender], "Not authorized");
if (flowState != 0) { // Not NORMAL
return; // Liquidation silently fails
}
liquidated[user] = true;
flowState = 3; // LIQUIDATION
}
function afterLiquidationExecution(address user) external {
require(msg.sender == gmxProxy, "Not GMX proxy");
if (liquidated[user]) {
flowState = 0; // Return to NORMAL
}
}
}
contract MockGmxProxy {
function executeLiquidation(address user) external returns (bool) {
return true;
}
}
contract MockKeeperProxy {
address public vault;
constructor(address _vault) {
vault = _vault;
}
function executeLiquidation(address user) external {
MockPerpetualVault(vault).liquidatePosition(user);
}
}

Impact

This vulnerability has several severe impacts:

  1. Protocol Insolvency Risk: Positions that should be liquidated remain active.

  2. Undermining Risk Management: The system is unable to enforce its safety measures.

  3. Market Manipulation: Users can strategically avoid liquidation.

Recommendations

  1. Atomic Liquidation Process: Use a mutex to prevent interference from other transactions.

    bool private _liquidationInProgress;
    modifier noLiquidationInProgress() {
    require(!_liquidationInProgress, "Liquidation in progress");
    _;
    }
    function startLiquidation(address user) external onlyAuthorizedKeeper {
    _liquidationInProgress = true;
    // Liquidation logic
    }
  2. Transaction Ordering Protection: Use mechanisms like Chainlink Keepers.

  3. Explicit Revert: Replace silent failures with explicit reverts:

    function liquidatePosition(address user) external {
    require(authorizedKeepers[msg.sender], "Not authorized");
    require(flowState == NORMAL, "Liquidation can only be performed in NORMAL state");
    // Continue with liquidation logic
    }
Updates

Lead Judging Commences

n0kto Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Lack of quality
Assigned finding tags:

Suppositions

There is no real proof, concrete root cause, specific impact, or enough details in those submissions. Examples include: "It could happen" without specifying when, "If this impossible case happens," "Unexpected behavior," etc. Make a Proof of Concept (PoC) using external functions and realistic parameters. Do not test only the internal function where you think you found something.

Support

FAQs

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