BoostHook on Ethereum was exploited on 2026-05-13 in transaction `0xb45cc4d9c13c2c24b4bbf71db9e6f52ed24d174ad23ed2622a290289cebd3811` at block `25080848`. The attacker used a 120 WETH Morpho flash loan to push the ETH/PERP Uniswap v4 pool price upward, opened nine leveraged long positions through `BoostHook.openLong()` while the pool was temporarily overpriced, then reversed the price move and forced `BoostHook.afterSwap()` to liquidate only five toxic positions because `MAX_LIQS_PER_BLOCK` is hard-capped at 5. The transaction ended with a net attacker gain of `20.932897159743546561 WETH`, while BoostHook realized `38.327408004126135579 ETH` of bad debt on the five liquidated positions and left four additional 8 ETH-debt positions open.
## Root Cause
### Vulnerable Contract
The primary vulnerable component is `BoostHook` at `0x3db1ebb71c735980d12422f153987d89f4d7eacc`. Verified source was fetched from Etherscan and the exploit path is in `src/hook/BoostHook.sol`. The hook is wired to Uniswap v4 `PoolManager` `0x000000000004444c5dc75cb358380d2e3de08a90`, uses `PERP` token `0x6c6be583c45075a5a3da03f81c2874607ac111f8`, and routes 1% borrow fees to `BoostStaking` `0x4ae2458e6d087aaa3625d81242f22f0b513bca07`.
### Vulnerable Function
The core issue is in `openLong(uint256 leverage, uint256 minHoldingOut, uint256 deadline)`, selector `0x6c2ee359`, together with `afterSwap(address,PoolKey,SwapParams,BalanceDelta,bytes)`, selector `0xb47b2fb1`, and `_scanAndLiquidate()`. `openLong()` uses the live pool slot price to mint a leveraged position and only checks swap output slippage. It records `holdingTOKEN` and `debtETH` immediately after `poolManager.unlock(Action.OPEN_LONG, …)` returns, without a post-open solvency or debt-coverage invariant. `afterSwap()` runs liquidation scanning before writing the new observation, but `_scanAndLiquidate()` is limited by `MAX_LIQS_PER_BLOCK = 5`, so the attack can create more toxic positions than the hook is willing to liquidate in the same block.
### Vulnerable Code
The verified source shows the two failure points directly:
“`
function openLong(uint256 leverage, uint256 minHoldingOut, uint256 deadline) external payable nonReentrant returns (uint256 positionId, uint256 holdingOut) { uint256 collateral = msg.value; uint256 borrowEth = collateral * (leverage – 1); uint256 borrowFee = (borrowEth * BORROW_FEE_BPS) / 10_000; uint256 effectiveCol = collateral – borrowFee; (uint160 sqrtP,,,) = poolManager.getSlot0(_poolId()); bytes memory ret = poolManager.unlock(abi.encode( Action.OPEN_LONG, abi.encode(borrowEth, effectiveCol, borrowFee, msg.sender) )); (uint256 actualBorrowed, uint256 swapTokensOut) = abi.decode(ret, (uint256, uint256)); if (swapTokensOut < minHoldingOut) revert SlippageExceeded(); _positions[positionId] = Position({ owner: msg.sender, collateralETH: effectiveCol, debtETH: actualBorrowed, holdingTOKEN: swapTokensOut, openSqrtPriceX96: sqrtP, leverage: uint8(leverage), openedAtBlock: uint64(block.number), realizedETHOut: 0 }); }
“`
“`
uint16 public constant MAX_LIQS_PER_BLOCK = 5; uint32 public constant TWAP_SECONDS = 300; function afterSwap(address sender, PoolKey calldata key, SwapParams calldata params, BalanceDelta delta, bytes calldata) external onlyPoolManager returns (bytes4, int128) { if (sender == address(this)) return (IHooks.afterSwap.selector, 0); if (!_inLiquidation) { _scanAndLiquidate(); } _writeObservation(); … } function _scanAndLiquidate() internal { … if (_liqsThisBlock >= MAX_LIQS_PER_BLOCK) return; uint256 blockRemaining = MAX_LIQS_PER_BLOCK – _liqsThisBlock; … if (healthBps < LIQUIDATION_HEALTH_BPS) toLiq[count++] = posId; … for (uint256 i = 0; i < count; i++) { _liquidateInternal(toLiq[i]); } _liqsThisBlock += uint16(count); }
“`
### Why It’s Vulnerable
Expected behavior: leveraged entry should verify that the just-opened position remains solvent under a manipulation-resistant valuation before persisting `debtETH` and `holdingTOKEN`. If the system relies on delayed auto-liquidation, that liquidation path must be able to neutralize all toxic positions created by a single atomic attack path or otherwise block the entry itself.
Actual behavior: `openLong()` prices the position from the pool state manipulated earlier in the same transaction, checks only `swapTokensOut >= minHoldingOut`, and then records the position with 8 ETH of debt and whatever inflated PERP amount the manipulated price returns. When the attacker reverses the pool move, `_scanAndLiquidate()` uses a 300-second TWAP to decide liquidatability and can process at most five positions in the block. Because the attacker opened nine positions, five are liquidated immediately and four remain as undercollateralized debt exposure.
This creates a two-part exploit surface:
1. Same-transaction spot manipulation lets the attacker acquire too much PERP collateral for each 8 ETH debt tranche.
2. The liquidation cap turns what should be a total cleanup into a partial cleanup, allowing multiple toxic positions to survive the attack transaction.
## Attack Execution
### High-Level Flow
1. Attacker EOA `0xb0a019dd22c363e82fa4f96ae1e4b993341f5104` called exploit contract `0xb64bff7b5199abcbb98fee2bf4014265fca85a6d`.
2. The exploit contract borrowed `120 WETH` from Morpho `0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb`.
3. The contract unwrapped the full 120 WETH to ETH, approved PERP spending, and used `Sat1SwapRouter.buy()` to push `100 ETH` through the ETH/PERP pool.
4. While price was inflated, the attacker called `BoostHook.openLong()` exactly nine times with `2 ETH` collateral each and leverage 5x, creating nine positions with `1.92 ETH` effective collateral and `8 ETH` debt apiece.
5. The attacker then sold PERP back through `Sat1SwapRouter.sell()`, collapsing the manipulated price.
6. That sell path triggered `BoostHook.afterSwap()`, which liquidated only five positions because `MAX_LIQS_PER_BLOCK = 5`.
7. The attacker repaid the Morpho flash loan and kept `20.932897159743546561 WETH` profit.
### Detailed Call Trace
– Depth 0: EOA `0xb0a019dd22c363e82fa4f96ae1e4b993341f5104` called attacker contract `0xb64bff7b5199abcbb98fee2bf4014265fca85a6d` using selector `0xea769582`.
– Depth 1: attacker contract called Morpho `flashLoan(address,uint256,bytes)`( `0xe0232b42`).
– Depth 2: Morpho transferred `120 WETH` to the attacker contract, then invoked attacker callback selector `0x31f57072`.
– Depth 3: attacker contract unwrapped `120 WETH` via `WETH.withdraw(uint256)` and received `120 ETH`.
– Depth 3: attacker contract called `Sat1SwapRouter.buy((address,address,uint24,int24,address),uint256)`( `0x0a209187`) with `100 ETH` value.
– Depth 4-8: the router unlocked Uniswap v4 `PoolManager`, settled `100 ETH`, executed `swap`, and triggered BoostHook `beforeSwap()` and `afterSwap()` hooks.
– Depth 3: attacker contract called `BoostHook.openLong()`( `0x6c2ee359`) nine times, each with `2 ETH` value.
– Each open delegated into `poolManager.unlock(Action.OPEN_LONG, …)`, borrowed `8 ETH`, moved liquidity with repeated `modifyLiquidity()` calls, swapped the borrowed-plus-collateral ETH into PERP, settled with the pool manager, and sent `0.08 ETH` borrow fee to `BoostStaking.notifyReward()`.
– Receipt logs 15, 25, 35, 45, 55, 65, 75, 85, and 95 are `PositionOpened` events. Decoding their data shows each position recorded:
– `collateralETH = 1.92 ETH`
– `debtETH = 8 ETH`
– `holdingTOKEN` decreasing from `3495.943232391446696211 PERP` on the first open to `1612.254939090051216142 PERP` on the ninth open as the attacker consumed the manipulated pool depth.
– Later in the transaction, the attacker sold PERP back through the router, which again routed through PoolManager and triggered BoostHook `afterSwap()`.
– Receipt logs 101/102, 106/107, 111/112, 117/118, and 122/123 show five `BadDebtRealized` + `PositionLiquidated` pairs. The bad-debt increments are:
– `7.052511843490864145 ETH`
– `7.203235052405256104 ETH`
– `7.311905480863325404 ETH`
– `7.399989028897381661 ETH`
– `7.470946705785818691 ETH`
– The cumulative `totalBadDebtETH` after the fifth liquidation is `38.327408004126135579 ETH`, matching the alert.
– No further `PositionLiquidated` events occur in the transaction, confirming that four positions survived the same-block liquidation pass despite the hook still showing `9 * 8 = 72 ETH` initial debt and only `5 * 8 = 40 ETH` having been processed. The remaining open debt exposure is therefore `32 ETH` across four survivor positions.
## Financial Impact
`funds_flow.json` shows the attacker EOA gained `20.932897159743546561 WETH`, which is the net profit after flash-loan repayment. The attacker contract itself ends flat in WETH and PERP after forwarding the proceeds to the EOA. Morpho’s net WETH change is zero because the `120 WETH` flash loan is fully repaid in-transaction.
The protocol loss has two layers:
– Realized loss: the five liquidated positions wrote off `38.327408004126135579 ETH` into `totalBadDebtETH`, confirmed by the five `BadDebtRealized` events.
– Residual toxic exposure: four positions with `8 ETH` debt each remained open because the liquidation cap stopped further clean-up, leaving `32 ETH` of surviving debt exposure in state after the exploit transaction.
## Evidence
– `tx.json` shows `from = 0xb0a019dd22c363e82fa4f96ae1e4b993341f5104`, `to = 0xb64bff7b5199abcbb98fee2bf4014265fca85a6d`, block `25080848`( `0x17eb410`).
– `receipt.json.status` is `0x1`, confirming successful execution.
– `decoded_calls.json` shows one `Morpho.flashLoan`, one `Sat1SwapRouter.buy`, nine `BoostHook.openLong`, and the reverse swap path that triggers the liquidation sequence.
– BoostHook source confirms `MAX_LIQS_PER_BLOCK = 5`, `TWAP_SECONDS = 300`, `openLong()` spot-based position recording, and `_scanAndLiquidate()` bounded cleanup.
– Receipt logs confirm nine `PositionOpened` events and exactly five `PositionLiquidated` events in the exploit transaction.
– `funds_flow.json` reports `Attacker gained: 20.932897159743546561 WETH`, matching the alert figure.
