Originally published at KolanHook Exploit: Unguarded Uniswap v4 Buyback Drained ETH.
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
-
Protocol: Kolan, a Uniswap v4 "window" protocol. Hook:
KolanHook, token:KLN. - Chain: Ethereum mainnet.
- Loss: about 2.31 ETH left the hook; attacker net profit about 1.28 ETH.
-
Root cause:
accept(id)lets anyone close an expired window. Closing a window runs a buyback swap with the hook's own ETH, and that swap used a price limit ofMIN_SQRT_PRICE + 1, which in practice means no slippage protection at all. - Enabler: a 6.7 WETH flash loan from Balancer to push the KLN spot price up before forcing the buyback.
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.
- Flash loan. The attacker borrows 6.7 WETH from Balancer.
- 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.
-
Force the buyback. The attacker calls
accept(10)andaccept(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. - 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.
- 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:
-
Spot price is one call away.
getSlot0()and an unbounded swap are the path of least resistance. A safe price needs extra work. - Flash loans are cheap on Ethereum. Moving a thin pool costs only the fees, and a new token pool is almost always thin.
- "Permissionless" is treated as a feature, without checking what the permissionless caller can make the contract do at that moment.
Impact
- Direct loss: about 2.31 ETH of hook reserves, roughly $5.4K at the time per Defimon.
- Attacker profit: about 1.28 ETH, roughly $3.0K.
- Scope: the hook is ownerless and immutable, so there is no admin fix. Any remaining expired window can be closed the same way as long as reserves and a manipulable pool remain. At the time of writing we found no public remediation statement from Kolan.
Mitigations and Audit Lessons
For hook developers
-
Never let protocol-owned swaps run at an unbounded price. Derive
sqrtPriceLimitX96from 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. - Enforce a minimum output. Compute the expected KLN for the ETH spent and require the swap to return at least, say, 98% of it.
- 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.
- 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
- List every place the contract trades its own funds and, for each one, write down who can trigger it and what price it accepts. If the answers are "anyone" and "any", it is a finding.
-
Grep for extreme price limits.
MIN_SQRT_PRICE + 1,MAX_SQRT_PRICE - 1andamountOutMin = 0are fine in tests and in user-facing routers where the user sets the bound. Inside protocol logic they are red flags. - Write the flash-loan test. A Foundry test that borrows, moves the pool, calls the permissionless function and checks the hook's balance takes under an hour and would have caught this.
Our smart contract security checklist includes these slippage and price-source checks for any contract that swaps.
Key Addresses
- Attack transaction:
0x7b131926e6f06491ae1aead3e419b208c8aa70ed7898e8c5a33d9f52e40915a9 - Attacker:
0x8df735014eab81905e76b87ef0c069bd8e0ae382 - Victim hook (KolanHook):
0x0136feCf49E0217BFd2D9551E8e7b05f174Ba8eC - Token (KLN):
0x2CADc4F2660Bb77a118b0e74fC9E48E935b3a1bF
Sources
- Defimon Alerts on X: loss figures, call chain and transaction
- ChainBounty: KolanHook transaction breakdown: exact ETH flows
- Telem incident summary
- Kolan contract reference and technical FAQ: contract design and addresses
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.
Top comments (0)