2026-05-12 · Loss: ~20.9329 WETH profit; 38.3274 ETH realized bad debt · Price Manipulation
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.
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.
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.
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);
}
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:
0xb0a019dd22c363e82fa4f96ae1e4b993341f5104 called exploit contract 0xb64bff7b5199abcbb98fee2bf4014265fca85a6d.120 WETH from Morpho 0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb.Sat1SwapRouter.buy() to push 100 ETH through the ETH/PERP pool.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.Sat1SwapRouter.sell(), collapsing the manipulated price.BoostHook.afterSwap(), which liquidated only five positions because MAX_LIQS_PER_BLOCK = 5.20.932897159743546561 WETH profit.0xb0a019dd22c363e82fa4f96ae1e4b993341f5104 called attacker contract 0xb64bff7b5199abcbb98fee2bf4014265fca85a6d using selector 0xea769582.flashLoan(address,uint256,bytes) (0xe0232b42).120 WETH to the attacker contract, then invoked attacker callback selector 0x31f57072.120 WETH via WETH.withdraw(uint256) and received 120 ETH.Sat1SwapRouter.buy((address,address,uint24,int24,address),uint256) (0x0a209187) with 100 ETH value.PoolManager, settled 100 ETH, executed swap, and triggered BoostHook beforeSwap() and afterSwap() hooks.BoostHook.openLong() (0x6c2ee359) nine times, each with 2 ETH value.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().PositionOpened events. Decoding their data shows each position recorded:collateralETH = 1.92 ETHdebtETH = 8 ETHholdingTOKEN 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.afterSwap().BadDebtRealized + PositionLiquidated pairs. The bad-debt increments are:7.052511843490864145 ETH7.203235052405256104 ETH7.311905480863325404 ETH7.399989028897381661 ETH7.470946705785818691 ETHtotalBadDebtETH after the fifth liquidation is 38.327408004126135579 ETH, matching the alert.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.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:
38.327408004126135579 ETH into totalBadDebtETH, confirmed by the five BadDebtRealized events.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.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.MAX_LIQS_PER_BLOCK = 5, TWAP_SECONDS = 300, openLong() spot-based position recording, and _scanAndLiquidate() bounded cleanup.PositionOpened events and exactly five PositionLiquidated events in the exploit transaction.funds_flow.json reports Attacker gained: 20.932897159743546561 WETH, matching the alert figure.