Root Cause
GPXHooks is a Uniswap v4 hook that rebalances GoldPesa's protocol-owned GPX/USDC liquidity inside beforeSwap(), once per hour. During rebalancing it calls:
PositionManager.modifyLiquiditiesWithoutUnlock(
abi.encode([BURN_POSITION, TAKE_PAIR])
);
TAKE_PAIR withdraws only the PositionManager's net positive delta across the caller's entire active unlock — not solely the hook's delta. Uniswap v4's PositionManager uses a shared accounting context within a single unlock session, so any unsettled positions opened earlier in the same unlock affect what TAKE_PAIR sees.
The hook never verifies that its own GPX/USDC deltas are zero before relying on TAKE_PAIR. An attacker who opens a large phantom WETH/USDC debt in the same unlock can divert the hook's USDC release to themselves.
Attack Steps
| Step | Action |
|---|---|
| 1 | Flash-borrow 175,000 USDC from Morpho |
| 2 | Open PoolManager unlock |
| 3 | MINT_POSITION on WETH/USDC pool without settling → PoolManager carries -114,999.999 USDC phantom debt |
| 4 | Two 5,500 USDC GPX buys trigger the hook's hourly beforeSwap() rebalance |
| 5 | Hook: BURN_POSITION releases +148,868.602 USDC credit in PoolManager |
| 6 | TAKE_PAIR withdraws net delta only: 148,868.602 − 114,999.999 = 33,868.603 USDC paid to hook |
| 7 | Attacker burns their phantom WETH/USDC position (now settled by hook's credit), withdrawing 114,999.999 USDC |
| 8 | Repay Morpho flash loan; net profit: ~114,428 USDC |
Impact
~$114,428 USDC drained from the GPX/USDC PoolManager on Base on October 2, 2026. No privileged access required — any caller could trigger the hourly rebalance via a small swap. No recovery reported.
Lessons for Auditors
- Uniswap v4 hooks that call
modifyLiquiditiesWithoutUnlockinside an active unlock inherit the caller's full delta context.TAKE_PAIRis not scoped to the hook's own operations; validate that the hook's currency deltas are isolated before relying on it. beforeSwapandafterSwapexecute inside a PoolManager unlock. Hooks doing additional liquidity operations there must reason carefully about shared accounting state — the accounting is global per unlock, not per hook.- Test hook rebalance logic with adversarial flash-loan scenarios. Specifically: can an attacker open an unsettled position in the same transaction before the hook fires, and benefit from the hook's accounting work?
- This attack pattern (shared-unlock delta theft) generalises to any v4 hook that uses TAKE_PAIR or TAKE without first asserting
delta[hook] == 0for all relevant currencies.