KolanHook Exploit: Unguarded Uniswap v4 Buyback Drained ETH | PandaSecurity
Web3 Security

KolanHook Exploit: Unguarded Uniswap v4 Buyback Drained ETH

PandaSecurity Team •
#Web3 Security#DeFi Exploit#Uniswap v4#Smart Contract Audit
Diagram of the KolanHook Uniswap v4 exploit showing a flash loan, a pumped spot price and a forced buyback

On 15 October 2026, an attacker took about 2.31 ETH out of KolanHook, a Uniswap v4 hook on Ethereum mainnet, and kept roughly 1.28 ETH (around $3K) as profit after repaying a flash loan. The dollar amount is small. The pattern is not. A hook that spends its own reserves on a swap, at whatever the pool’s spot price happens to be, triggered by a function anyone can call, is an exploit waiting for a caller.

This post-mortem walks through what Kolan does, why the buyback path was exploitable, how the attack was sequenced, and what auditors and hook developers should take away from it.

TL;DR

What Kolan Is

According to Kolan’s own documentation, the system is two contracts: KolanHook, which holds the pool liquidity and the “windows”, and KolanToken, a fixed-supply ERC-20 (1,000,000 KLN) minted once to the hook. There is no owner, no proxy and no pause, and the hook refuses outside liquidity. The docs also state plainly that the contracts are tested but not audited.

A “window” is attached to a purchase. A buyer pays a premium on top of the ETH they spend, and for a period of time they can undo the purchase. Once a window expires it can no longer be undone, only kept, and the docs describe closing it as something anyone can do after expiry. When a window is closed, the hook uses ETH it holds to buy back KLN and burn it.

That design gives the hook two jobs at once: it is the market’s liquidity and it is a trader that periodically buys from that market. Whenever a contract trades on its own behalf, the question an auditor must ask is simple: who decides when it trades, and at what price?

Root Cause: A Permissionless Trigger Meets an Unbounded Swap

Two properties, each defensible on its own, combined into the bug.

1. Anyone can close an expired window

Making accept() permissionless after expiry is a reasonable liveness choice. Nobody wants windows stuck open because the buyer went offline. But it also means the timing of the buyback is chosen by whoever calls it, including someone who has just moved the price.

2. The buyback accepts any price

Per Defimon’s analysis, the call chain is accept → _closeWindow → _buyBack(), and the buyback swaps the hook’s ETH into the pool with sqrtPriceLimitX96 = MIN_SQRT_PRICE + 1. In Uniswap v4 that limit is the furthest price the swap is allowed to move to. Setting it to the extreme bound tells the PoolManager “fill me at any price”.

The simplified sketch below is our own illustration of the pattern, not Kolan’s verified source:

// Simplified illustration of the vulnerable pattern (not verbatim Kolan code)
function accept(uint256 id) external {
    Window storage w = windows[id];
    require(block.timestamp >= w.expiry || msg.sender == w.owner, "WindowStillOpen");
    _closeWindow(id);
}

function _closeWindow(uint256 id) internal {
    uint256 ethForBuyback = windows[id].premium / 2;
    delete windows[id];
    _buyBack(ethForBuyback);
}

function _buyBack(uint256 ethIn) internal {
    // ETH -> KLN, exact input, burn the output
    BalanceDelta delta = poolManager.swap(
        key,
        IPoolManager.SwapParams({
            zeroForOne: true,
            amountSpecified: -int256(ethIn),
            sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 // no real limit
        }),
        ""
    );
    _settleAndBurn(delta);
}

Nothing in this path compares the current spot price with a reference price, a TWAP or a minimum output. The amount of KLN the hook receives is whatever the pool gives it at that moment.

The Attack, Step by Step

The figures below come from Defimon’s alert and the transaction breakdown published by ChainBounty; we have rounded them.

  1. Flash loan. The attacker borrows 6.7 WETH from Balancer.
  2. Pump the spot price. The borrowed ETH buys KLN, pushing the KLN price in the pool up sharply. The attacker now holds a pile of KLN bought before the buyback.
  3. Force the buyback. The attacker calls accept(10) and accept(42) on two expired windows. Each one closes a window and triggers _buyBack(), which spends the hook’s ETH buying KLN at the inflated price and burns it. In total about 2.31 ETH leaves the hook.
  4. Dump into the richer pool. The pool now holds the attacker’s ETH plus the hook’s buyback ETH. The attacker sells the KLN from step 2 back into it.
  5. Repay and keep the difference. The flash loan is repaid inside the same transaction, and roughly 1.28 ETH is left over as profit.

The gap between the hook’s 2.31 ETH outflow and the 1.28 ETH profit is worth spelling out. ChainBounty’s write-up rightly separates the two: one is what the hook lost, the other is what the attacker kept. The rest went to swap fees, the token’s 1% tax and price impact. Reporting “2.31 ETH stolen” or “1.28 ETH lost” alone would both be misleading.

This is the classic sandwich of a protocol-owned trade. Usually MEV bots sandwich users; here the victim was the protocol’s own treasury logic, and the attacker didn’t even need to wait for it to trade. They could make it trade on demand.

Why This Keeps Happening in Uniswap v4 Hooks

We covered how hooks work in Uniswap v4 Hooks: How They Work and Where They Break, and two recent incidents on this blog, the GoldPesa GPXHooks rebalance drain and the one-wei share vault exploit, point at the same theme: once a hook holds value and acts on its own, it inherits every risk of an on-chain trader.

Hooks make this easy to get wrong for three reasons:

Impact

Mitigations and Audit Lessons

For hook developers

  1. Never let protocol-owned swaps run at an unbounded price. Derive sqrtPriceLimitX96 from a reference price (a TWAP, an oracle, or the price recorded when the window opened) and revert if the spot price is too far away.
  2. Enforce a minimum output. Compute the expected KLN for the ETH spent and require the swap to return at least, say, 98% of it.
  3. Separate “close” from “spend”. Let anyone mark a window as expired, but queue the buyback and execute it in batches, or only when the spot price is close to the TWAP.
  4. Check for same-block manipulation. If the pool price has moved more than a set threshold in the current block, skip or defer the buyback.

A hardened version of the buyback could look like this (again illustrative):

function _buyBack(uint256 ethIn) internal {
    uint160 ref = _twapSqrtPrice(30 minutes);              // manipulation-resistant reference
    uint160 limit = uint160(uint256(ref) * 9_900 / 10_000); // allow ~1% adverse move
    (uint160 spot,,,) = poolManager.getSlot0(key.toId());
    if (spot < limit) {
        pendingBuyback += ethIn; // defer instead of trading into a manipulated pool
        return;
    }
    BalanceDelta delta = poolManager.swap(
        key,
        IPoolManager.SwapParams({
            zeroForOne: true,
            amountSpecified: -int256(ethIn),
            sqrtPriceLimitX96: limit
        }),
        ""
    );
    require(uint128(delta.amount1()) >= _minOut(ethIn, ref), "Slippage");
    _settleAndBurn(delta);
}

For auditors

Our smart contract security checklist includes these slippage and price-source checks for any contract that swaps.

Key Addresses

Sources

Closing Thoughts

KolanHook lost a few thousand dollars, but its design is not unusual. Plenty of new hooks buy back, rebalance or compound with their own funds, and many of them let anyone trigger it. If your hook trades, the price it accepts is part of your security model, not a gas optimization you can skip.

Building a Uniswap v4 hook or a token with automated buybacks? Contact Panda Security for a review before launch, or see our audit pricing.

Related articles

← Back to Blog