The vulnerability comes from the interplay between the contract’s rigid flow states, the GMX callback mechanism, and the locking modifier that forbids state cancellation while waiting on GMX. A closer look at the relevant sections:
This rigid design creates a path for indefinite blocking: once in FLOW.DEPOSIT with _gmxLock == true, no state transitions can occur without a successful GMX callback or direct owner override. Hence, user funds become stuck in the contract, and standard operations cannot proceed.
The vulnerability can effectively freeze the entire vault, permanently blocking deposits and withdrawals if GMX fails to respond for any reason, resulting in a denial of service and trapping user collateral without a safe, automated recovery path.
pragma solidity ^0.8.4;
import {Test, console} from "forge-std/Test.sol";
import "forge-std/StdCheats.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ArbitrumTest} from "./utils/ArbitrumTest.sol";
import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import { ProxyAdmin } from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import { GmxProxy } from "../contracts/GmxProxy.sol";
import { KeeperProxy } from "../contracts/KeeperProxy.sol";
import { PerpetualVault } from "../contracts/PerpetualVault.sol";
import { VaultReader } from "../contracts/VaultReader.sol";
import { MarketPrices, PriceProps } from "../contracts/libraries/StructData.sol";
import { MockData } from "./mock/MockData.sol";
import { Error } from "../contracts/libraries/Error.sol";
interface IExchangeRouter {
struct SimulatePricesParams {
address[] primaryTokens;
PriceProps[] primaryPrices;
uint256 minTimestamp;
uint256 maxTimestamp;
}
function simulateExecuteOrder(bytes32 key, SimulatePricesParams memory oracleParams) external;
}
interface IOrderHandler {
function executeOrder(
bytes32 key,
MockData.OracleSetPriceParams calldata oracleParams
) external;
}
contract PermanentLockPOC is Test, ArbitrumTest {
address payable vault;
address keeper;
MockData mockData;
address alice;
address gmxProxy;
event GmxPositionCallbackCalled(bytes32 requestKey, bool success);
function setUp() public {
address ethUsdcMarket = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
address orderHandler = address(0xe68CAAACdf6439628DFD2fe624847602991A31eB);
address liquidationHandler = address(0xdAb9bA9e3a301CCb353f18B4C8542BA2149E4010);
address adlHandler = address(0x9242FbED25700e82aE26ae319BCf68E9C508451c);
address gExchangeRouter = address(0x900173A66dbD345006C51fA35fA3aB760FcD843b);
address gmxRouter = address(0x7452c558d45f8afC8c83dAe62C3f8A5BE19c71f6);
address dataStore = address(0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8);
address orderVault = address(0x31eF83a530Fde1B38EE9A18093A333D8Bbbc40D5);
address gmxReader = address(0x5Ca84c34a381434786738735265b9f3FD814b824);
address referralStorage= address(0xe6fab3F0c7199b0d34d7FbE83394fc0e0D06e99d);
ProxyAdmin proxyAdmin = new ProxyAdmin();
GmxProxy gmxUtilsLogic = new GmxProxy();
bytes memory data = abi.encodeWithSelector(
GmxProxy.initialize.selector,
orderHandler,
liquidationHandler,
adlHandler,
gExchangeRouter,
gmxRouter,
dataStore,
orderVault,
gmxReader,
referralStorage
);
gmxProxy = address(
new TransparentUpgradeableProxy(
address(gmxUtilsLogic),
address(proxyAdmin),
data
)
);
payable(gmxProxy).transfer(1 ether);
KeeperProxy keeperLogic = new KeeperProxy();
data = abi.encodeWithSelector(
KeeperProxy.initialize.selector
);
keeper = address(
new TransparentUpgradeableProxy(
address(keeperLogic),
address(proxyAdmin),
data
)
);
address owner = KeeperProxy(keeper).owner();
KeeperProxy(keeper).setKeeper(owner, true);
KeeperProxy(keeper).setDataFeed(0xaf88d065e77c8cC2239327C5EDb3A432268e5831, 0x50834F3163758fcC1Df9973b6e91f0F0F0434aD3, 86400, 500);
KeeperProxy(keeper).setDataFeed(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1, 0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612, 86400, 500);
VaultReader reader = new VaultReader(
orderHandler,
dataStore,
orderVault,
gmxReader,
referralStorage
);
PerpetualVault perpetualVault = new PerpetualVault();
data = abi.encodeWithSelector(
PerpetualVault.initialize.selector,
address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336),
keeper,
makeAddr("treasury"),
gmxProxy,
reader,
1e8,
1e28,
10_000
);
vm.prank(address(this), address(this));
vault = payable(
new TransparentUpgradeableProxy(
address(perpetualVault),
address(proxyAdmin),
data
)
);
mockData = new MockData();
alice = makeAddr("alice");
payable(alice).transfer(1 ether);
}
function test_Y1_PermanentLock_DueToMissingGmxCallbacks() external {
console.log("\n=== INITIAL SETUP ===");
console.log("Step 1: Make a deposit from Alice");
depositFromUser(alice, 1e10);
console.log("\nInitial state checks:");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
console.log("- User collateral:", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(alice));
console.log("- Vault collateral:", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(vault));
console.log("\n=== DEMONSTRATE VULNERABILITY ===");
console.log("Step 2: Initiate a position change through the keeper");
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory data = new bytes[](1);
data[0] = abi.encode(3380000000000000);
vm.prank(keeper);
PerpetualVault(vault).run(true, false, prices, data);
console.log("\nPost-run state checks:");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
console.log("\nStep 3: Verify GMX order was created but callback is pending");
(bytes32 requestKey, ) = GmxProxy(payable(gmxProxy)).queue();
console.log("- GMX order key:", vm.toString(requestKey));
console.log("\nStep 4: Simulate scenario where GMX executes order but callback fails");
console.log("(e.g., due to network issues, GMX upgrade, or other edge case)");
console.log("\nStep 5: Wait for a significant period (1 day)");
vm.warp(block.timestamp + 1 days);
console.log("\n=== DEMONSTRATE PERMANENT LOCK ===");
console.log("Step 6: Check vault state after time has passed");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
assertEq(uint8(PerpetualVault(vault).flow()), 2);
assertTrue(PerpetualVault(vault).isLock());
console.log("\nStep 7: Try to make a new deposit (should fail)");
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
uint256 amount = 1e10;
deal(address(collateralToken), alice, amount);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
vm.startPrank(alice);
collateralToken.approve(vault, amount);
vm.expectRevert();
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(amount);
vm.stopPrank();
console.log("- Deposit failed as expected due to lock");
console.log("\nStep 8: Try to withdraw existing funds (should fail)");
uint256[] memory depositIds = PerpetualVault(vault).getUserDeposits(alice);
executionFee = PerpetualVault(vault).getExecutionGasLimit(false);
vm.startPrank(alice);
vm.expectRevert();
PerpetualVault(vault).withdraw{value: executionFee * tx.gasprice}(alice, depositIds[0]);
vm.stopPrank();
console.log("- Withdrawal failed as expected due to lock");
console.log("\nStep 9: Try to use keeper operations to recover");
vm.startPrank(keeper);
vm.expectRevert();
PerpetualVault(vault).cancelFlow();
vm.stopPrank();
console.log("- Keeper operations failed due to lock");
console.log("\nStep 10: Wait an extremely long time (30 days)");
vm.warp(block.timestamp + 30 days);
console.log("\nStep 11: Verify vault is still locked even after 30 days");
console.log("- Vault flow state:", uint8(PerpetualVault(vault).flow()));
console.log("- Is locked:", PerpetualVault(vault).isLock());
assertTrue(PerpetualVault(vault).isLock());
console.log("\n=== ATTEMPT TO RECOVER ===");
address owner = PerpetualVault(vault).owner();
console.log("Step 12: Try owner functions to recover");
vm.startPrank(owner);
try PerpetualVault(vault).cancelFlow() {
console.log("- Owner cancelFlow succeeded (unexpected)");
} catch {
console.log("- Owner cancelFlow failed (expected)");
}
vm.stopPrank();
console.log("\n=== CONCLUSION ===");
console.log("Vault is PERMANENTLY LOCKED without recovery mechanism");
}
function depositFromUser(address user, uint256 amount) internal {
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
vm.startPrank(user);
deal(address(collateralToken), user, amount);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
collateralToken.approve(vault, amount);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(amount);
vm.stopPrank();
}
}
Ran 1 test for test/PermanentLockPOC.t.sol:PermanentLockPOC
[PASS] test_Y1_PermanentLock_DueToMissingGmxCallbacks() (gas: 1667783)
Logs:
=== INITIAL SETUP ===
Step 1: Make a deposit from Alice
Initial state checks:
- Vault flow state: 0
- Is locked: false
- User collateral: 0
- Vault collateral: 10000000000
=== DEMONSTRATE VULNERABILITY ===
Step 2: Initiate a position change through the keeper
Post-run state checks:
- Vault flow state: 2
- Is locked: true
Step 3: Verify GMX order was created but callback is pending
- GMX order key: 0x2e9b56aff7ad76a388482089a48c1ad1e043cb606088141ae8d535ca504b10cb
Step 4: Simulate scenario where GMX executes order but callback fails
(e.g., due to network issues, GMX upgrade, or other edge case)
Step 5: Wait for a significant period (1 day)
=== DEMONSTRATE PERMANENT LOCK ===
Step 6: Check vault state after time has passed
- Vault flow state: 2
- Is locked: true
Step 7: Try to make a new deposit (should fail)
- Deposit failed as expected due to lock
Step 8: Try to withdraw existing funds (should fail)
- Withdrawal failed as expected due to lock
Step 9: Try to use keeper operations to recover
- Keeper operations failed due to lock
Step 10: Wait an extremely long time (30 days)
Step 11: Verify vault is still locked even after 30 days
- Vault flow state: 2
- Is locked: true
=== ATTEMPT TO RECOVER ===
Step 12: Try owner functions to recover
- Owner cancelFlow failed (expected)
=== CONCLUSION ===
Vault is PERMANENTLY LOCKED without recovery mechanism
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.89ms (7.37ms CPU time)
Ran 1 test suite in 444.65ms (14.89ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)