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:
- Same-transaction spot manipulation lets the attacker acquire too much PERP collateral for each 8 ETH debt tranche.
- 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
- Attacker EOA
0xb0a019dd22c363e82fa4f96ae1e4b993341f5104called exploit contract0xb64bff7b5199abcbb98fee2bf4014265fca85a6d. - The exploit contract borrowed
120 WETHfrom Morpho0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb. - The contract unwrapped the full 120 WETH to ETH, approved PERP spending, and used
Sat1SwapRouter.buy()to push100 ETHthrough the ETH/PERP pool. - While price was inflated, the attacker called
BoostHook.openLong()exactly nine times with2 ETHcollateral each and leverage 5x, creating nine positions with1.92 ETHeffective collateral and8 ETHdebt apiece. - The attacker then sold PERP back through
Sat1SwapRouter.sell(), collapsing the manipulated price. - That sell path triggered
BoostHook.afterSwap(), which liquidated only five positions becauseMAX_LIQS_PER_BLOCK = 5. - The attacker repaid the Morpho flash loan and kept
20.932897159743546561 WETHprofit.
Detailed Call Trace
- Depth 0: EOA
0xb0a019dd22c363e82fa4f96ae1e4b993341f5104called attacker contract0xb64bff7b5199abcbb98fee2bf4014265fca85a6dusing selector0xea769582. - Depth 1: attacker contract called Morpho
flashLoan(address,uint256,bytes)(0xe0232b42). - Depth 2: Morpho transferred
120 WETHto the attacker contract, then invoked attacker callback selector0x31f57072. - Depth 3: attacker contract unwrapped
120 WETHviaWETH.withdraw(uint256)and received120 ETH. - Depth 3: attacker contract called
Sat1SwapRouter.buy((address,address,uint24,int24,address),uint256)(0x0a209187) with100 ETHvalue.- Depth 4-8: the router unlocked Uniswap v4
PoolManager, settled100 ETH, executedswap, and triggered BoostHookbeforeSwap()andafterSwap()hooks.
- Depth 4-8: the router unlocked Uniswap v4
- Depth 3: attacker contract called
BoostHook.openLong()(0x6c2ee359) nine times, each with2 ETHvalue.- Each open delegated into
poolManager.unlock(Action.OPEN_LONG, ...), borrowed8 ETH, moved liquidity with repeatedmodifyLiquidity()calls, swapped the borrowed-plus-collateral ETH into PERP, settled with the pool manager, and sent0.08 ETHborrow fee toBoostStaking.notifyReward().
- Each open delegated into
- Receipt logs 15, 25, 35, 45, 55, 65, 75, 85, and 95 are
PositionOpenedevents. Decoding their data shows each position recorded:collateralETH = 1.92 ETHdebtETH = 8 ETHholdingTOKENdecreasing from3495.943232391446696211 PERPon the first open to1612.254939090051216142 PERPon 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+PositionLiquidatedpairs. The bad-debt increments are:7.052511843490864145 ETH7.203235052405256104 ETH7.311905480863325404 ETH7.399989028897381661 ETH7.470946705785818691 ETH
- The cumulative
totalBadDebtETHafter the fifth liquidation is38.327408004126135579 ETH, matching the alert. - No further
PositionLiquidatedevents occur in the transaction, confirming that four positions survived the same-block liquidation pass despite the hook still showing9 * 8 = 72 ETHinitial debt and only5 * 8 = 40 ETHhaving been processed. The remaining open debt exposure is therefore32 ETHacross 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 ETHintototalBadDebtETH, confirmed by the fiveBadDebtRealizedevents. - Residual toxic exposure: four positions with
8 ETHdebt each remained open because the liquidation cap stopped further clean-up, leaving32 ETHof surviving debt exposure in state after the exploit transaction.
Evidence
tx.jsonshowsfrom = 0xb0a019dd22c363e82fa4f96ae1e4b993341f5104,to = 0xb64bff7b5199abcbb98fee2bf4014265fca85a6d, block25080848(0x17eb410).receipt.json.statusis0x1, confirming successful execution.decoded_calls.jsonshows oneMorpho.flashLoan, oneSat1SwapRouter.buy, nineBoostHook.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
PositionOpenedevents and exactly fivePositionLiquidatedevents in the exploit transaction. funds_flow.jsonreportsAttacker gained: 20.932897159743546561 WETH, matching the alert figure.