Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
Your Uniswap v4 Hook Is Public: 5 Ways It Gets Drained

Your Uniswap v4 Hook Is Public: 5 Ways It Gets Drained

20 min
On this page▾

TL;DR

  • A Uniswap v4 hook is a contract the pool calls on every swap. It is also a public contract. Anyone can call its functions directly, and anyone can create a pool that uses it.
  • Put onlyPoolManager on every hook callback. Cork Protocol lost about $12M in May 2025, and one of its two flaws was a beforeSwap with no caller check. In our demo, an attacker who never swapped called afterSwap directly and emptied a 10,000 tUSDC rebate budget.
  • sender in a hook is the router, not the user. Round every value your hook pays out in the pool's favour: Bunni lost about $8.4M in September 2025 to a rounding error repeated 44 times.
  • Contracts around the hook count too. In September 2026, a Safe's custom v4 liquidity module was driven through a public helper and routed into a pool the attacker created, with the attacker's own hook. About 2,900 rsETH, around $7.73M, left the Safe. Check who calls your helpers, and only swap through pools and hooks you have allowlisted.
  • One test to run today: call every hook callback from a random address. Every call should revert.

The video runs every attack and every fix on screen. The article below covers the same ground, with the code, if you would rather read it.

Introduction

Your hook runs on every swap. Who else can call it?
In September 2026, a hooked pool pulled about $7.73M out of one Safe wallet. The year before, two protocols built on v4 hooks, Cork and Bunni, lost about $20M between them. In each case, the root cause the analysts named was in code built around Uniswap: a hook with no caller check, a withdrawal that rounded the wrong way, a helper with a broken authorization check.
So here is a dare. Open your hook. Does every callback check that the caller is the pool manager? If one doesn't, keep reading.
Since 2024, teams and audit firms have paid us more than half a million dollars to find what breaks in their code before attackers do. We rebuilt Uniswap V3 from scratch on our channel, and we were one of three winning teams at the Uniswap Hook Incubator hackathon. This article walks through five ways a v4 hook, or the code around it, gets drained, the fix for each one in code, and one test to run today.
Every demo runs locally: an anvil chain on 127.0.0.1, our own minimal stand-ins (a pool manager, a router, hooks, a smart wallet, a module, a helper and a vault), test tokens (tUSDC, tUSD, tGOLD, tRSETH, tETH) and anvil's public dev keys. None of it is Uniswap's, Safe's or any other protocol's code, and nothing touches a live network. The full harness is public: ZealynxSecurity/zealynx-demos, newtech-09-v4hooks.

How a v4 hook works

In Uniswap v4, one contract, the pool manager, holds every pool. A pool is identified by a key that includes its two tokens, its fee and its hook. Creating a pool is permissionless: anyone can create one, with any hook. The singleton design and flash accounting are covered in more depth in our Uniswap v4 security guide.
A user calls a router. The router calls the pool manager. The pool manager calls the pool's hook before and after the swap. Which of those callbacks run is written into the last bits of the hook's address, which is why hook developers mine their hook addresses. If you have not written a hook yet, our first-hook guide covers hook permissions and the libraries you need.
Hooks can also return deltas. A delta is a change in what each side owes: a hook can take part of the input, or pay part of the output, itself. v4 records these as it goes and settles everything at the end of the transaction. This is called flash accounting.
So two things carry the risk. Who is allowed to call your hook, and the numbers it hands back. Attacks 1, 2 and 4 are about the caller. Attacks 3 and 5 are about the numbers.
Our stand-in follows the same shape, with two simplifications to stay small: it settles each swap immediately instead of at the end, and it reads a hook's callbacks from a flags() function instead of from its address bits. Neither changes the bugs below.

1. A callback anyone can call: no caller check

In May 2025, Cork Protocol lost about $12M (3,761 wstETH). BlockSec's analysis found two independent flaws. One of them: CorkHook.beforeSwap never checked that the caller was the pool manager, so anyone could call it directly, with hook data they made up.

The demo

Our rebate hook pays every swapper 1% of their swap, in tUSDC, from a 10,000 tUSDC budget. It runs in afterSwap, and it trusts the swap amount and the recipient it is given:
1// contracts/src/RebateHook.sol
2function afterSwap(address, PoolKey calldata, SwapParams calldata params, uint256, bytes calldata hookData)
3 external virtual override returns (bytes4)
4{
5 return _rebate(params, hookData);
6}
7
8function _rebate(SwapParams calldata params, bytes calldata hookData) internal returns (bytes4) {
9 address to = abi.decode(hookData, (address));
10 uint256 budget = rewardToken.balanceOf(address(this));
11 uint256 r = params.amountIn / 100;
12 rewardToken.transfer(to, r < budget ? r : budget);
13 return this.afterSwap.selector;
14}
The attacker never swaps. They call afterSwap directly, with a fake swap of 1,000,000 tUSDC and themselves as the recipient. 1% of a million is 10,000, the whole budget:
1rebate hook budget: 10,000 tUSDC · pays 1% of every swap
2$ attacker → hook.afterSwap(fake swap of 1,000,000 tUSDC, recipient = attacker) # not via the pool manager
3 ✓ accepted
4 real swaps through the pool manager: 0
5 attacker received 10,000 tUSDC · hook budget 0
60 SWAPS · BUDGET 10,000 → 0

The fix

Every callback gets onlyPoolManager. If the caller isn't the pool manager, revert:
1// contracts/src/MiniPoolManager.sol (BaseHook)
2modifier onlyPoolManager() {
3 if (msg.sender != address(poolManager)) revert NotPoolManager();
4 _;
5}
6
7// contracts/src/RebateHook.sol
8function afterSwap(address, PoolKey calldata, SwapParams calldata params, uint256, bytes calldata hookData)
9 external override onlyPoolManager returns (bytes4)
10{
11 return _rebate(params, hookData);
12}
Uniswap's v4-periphery ships the same modifier, in ImmutableState. Use it on every callback your hook implements, not just the ones that move money today. Same call:
1rebate hook budget: 10,000 tUSDC · pays 1% of every swap
2$ attacker → hook.afterSwap(fake swap of 1,000,000 tUSDC, recipient = attacker) # not via the pool manager
3 ✗ reverted: NotPoolManager()
4 real swaps through the pool manager: 0
5 attacker received 0 tUSDC · hook budget 10,000
6REVERTED · BUDGET STILL 10,000
onlyPoolManager proves the call came through a real swap. It doesn't prove the pool is yours: since anyone can create a pool with your hook, a hook that holds funds for specific pools should also check the pool it is called for.
One-line test: from a random address, call each callback your hook implements. Each must revert with NotPoolManager().

2. Trusting sender: it's the router, not the user

Every hook callback receives a parameter called sender. It looks like the user. It isn't. In v4-core's IHooks, sender is "the initial msg.sender for the swap call": whoever called the pool manager. For almost every user, that is a router. (See msg.sender if the distinction is new.)

The demo

Our pool is permissioned: only three allowlisted users, alice, bob and carol, may swap. The hook checks sender:
1// contracts/src/AllowlistHook.sol
2function beforeSwap(address sender, PoolKey calldata, SwapParams calldata, bytes calldata)
3 external virtual override onlyPoolManager returns (bytes4, uint256, uint256)
4{
5 lastSeen = sender;
6 if (!allowed[sender]) revert NotAllowed(sender);
7 return (this.beforeSwap.selector, 0, 0);
8}
Because sender is the router, nobody could swap through the router unless the router itself is on the list. So it goes on the list. Now anyone who uses the router gets in. The attacker isn't allowlisted, and swaps 5,000 tUSDC anyway:
1allowlist: alice, bob, carol, the router
2attacker 0x3C44…93BC on the allowlist: no
3$ attacker → router.swap(5,000 tUSDC → tUSD)
4 hook saw sender as: the router
5 ✓ swapped · attacker holds 4,975 tUSD
6NOT ON THE LIST · SWAPPED 5,000 tUSDC · GOT 4,975 tUSD

The fix

Take the router off the allowlist. Keep a separate list of routers you trust, and ask a trusted router who the real user is. Uniswap's v4-periphery routers expose this through IMsgSender, a function called msgSender():
1// contracts/src/AllowlistHook.sol (AllowlistHookFixed)
2function beforeSwap(address sender, PoolKey calldata, SwapParams calldata, bytes calldata)
3 external override onlyPoolManager returns (bytes4, uint256, uint256)
4{
5 address user = trustedRouter[sender] ? IMsgSender(sender).msgSender() : sender;
6 lastSeen = user;
7 if (!allowed[user]) revert NotAllowed(user);
8 return (this.beforeSwap.selector, 0, 0);
9}
Only call msgSender() on routers you have listed yourself. An attacker can deploy their own router whose msgSender() returns alice's address. Same swap, then alice:
1allowlist: alice, bob, carol · trusted router: ours
2attacker 0x3C44…93BC on the allowlist: no
3$ attacker → router.swap(5,000 tUSDC → tUSD)
4 ✗ reverted: NotAllowed(0x3C44…93BC)
5$ alice → router.swap(5,000 tUSDC → tUSD)
6 hook saw sender as: alice
7 ✓ swapped · alice holds 4,975 tUSD
8ATTACKER REVERTED · ALICE STILL SWAPS
One-line test: swap through your trusted router from an address that is not allowlisted. It must revert, and an allowlisted user's swap must still succeed.
Shipping a v4 hook, or routing funds through other people's pools? Send us your hook before it goes live. We will tell you where it breaks, and what it would take to make it safe. Request a hook review →

3. A custom curve that rounds the wrong way

On 2 September 2025, Bunni, an exchange built on Uniswap v4 hooks, lost about $8.4M. BlockSec traced it to a rounding error in its withdrawal logic, repeated in 44 small withdrawals. Our demo is not Bunni's code or its exact bug. It shows the same class: a rounding direction that favours the caller, repeated until it adds up.

The demo

Our hook prices swaps on its own curve: 1 tGOLD = 1,000 tUSD, paid out of the hook's own inventory of 10.00 tGOLD. It takes the whole input and returns the output as its delta. tUSD has 6 decimals and tGOLD has 2, so the smallest unit of tGOLD (0.01) is worth 10 tUSD. The payout rounds up:
1// contracts/src/CurveHook.sol
2uint256 public constant PRICE = 1;
3uint256 public constant ONE = 10_000_000;
4
5function quote(uint256 amountIn) public pure virtual returns (uint256) {
6 return FullMath.mulDivRoundingUp(amountIn, PRICE, ONE); // rounds the payout up
7}
Pay the smallest unit of tUSD, 0.000001, and the true payout is a tiny fraction of a tGOLD unit. Rounded up, it becomes a whole unit, 0.01 tGOLD, worth 10 tUSD. The attacker does that 100 times in one transaction:
1curve hook: 1 tGOLD = 1,000 tUSD · inventory 10.00 tGOLD
2$ attacker → 100 swaps of 0.000001 tUSD each, one transaction
3 swaps: 100 · paid: 0.0001 tUSD
4 received: 1.00 tGOLD (worth 1,000 tUSD)
5 hook inventory 10.00 → 9.00 tGOLD
6PAID 0.0001 tUSD · TOOK 1.00 tGOLD (1,000 tUSD)
One hundredth of a cent in, a thousand test dollars of gold out.

The fix

Choose the rounding direction for every value your hook hands back, and always in the pool's favour: down on what you pay out, up on what you take in.
1// contracts/src/CurveHook.sol (CurveHookFixed)
2function quote(uint256 amountIn) public pure override returns (uint256) {
3 return FullMath.mulDiv(amountIn, PRICE, ONE); // round down, never mulDivRoundingUp
4}
Our stand-in's FullMath is named after v4-core's, which has both functions. Same 100 swaps:
1curve hook: 1 tGOLD = 1,000 tUSD · inventory 10.00 tGOLD
2$ attacker → 100 swaps of 0.000001 tUSD each, one transaction
3 swaps: 100 · paid: 0.0001 tUSD
4 received: 0.00 tGOLD (worth 0 tUSD)
5 hook inventory 10.00 → 10.00 tGOLD
6SAME 100 SWAPS · PAID OUT 0.00
One-line test: loop the smallest possible input through your hook many times in one transaction, and assert that the value out never exceeds the value in.

4. A helper anyone can drive: checking the target, not the caller

On 15 September 2026, a Safe, a widely used multisig wallet, had a custom module managing its Uniswap v4 liquidity. A wallet module is an extra contract the owners enable so it can act for the wallet without them signing each transaction. Alongside it, the owner had authorized an auxiliary multicall contract, a public helper meant for keeper bots. Analysts from BlockSec, SlowMist and AstraSec, as reported by Forklog, traced the loss to an authorization check error in that helper. Through it, the module was routed into a pool the attacker had created. About 2,900 rsETH, around $7.73M, left the Safe. An MEV bot front-ran the attacker in the same block and captured most of it.
Safe's core contracts and keys were not at fault. The weak link was the extra components this one wallet's owner had added.

The demo

Our own minimal wallet (not Safe code) holds a 2,900 tRSETH position. Its liquidity module may only be called by its helper:
1// contracts/src/KeeperHelper.sol (LPModule)
2function moveLiquidity(address newPool) external {
3 require(msg.sender == helper, "not helper");
4 wallet.execFromModule(token, newPool, token.balanceOf(address(wallet)));
5}
The helper's public multicall checks what you call. It never checks who you are:
1// contracts/src/KeeperHelper.sol
2function multicall(Call[] calldata calls) external virtual {
3 _run(calls);
4}
5
6function _run(Call[] calldata calls) internal {
7 for (uint256 i; i < calls.length; i++) {
8 require(approvedTarget[calls[i].target], "target");
9 (bool ok,) = calls[i].target.call(calls[i].data);
10 require(ok, "call failed");
11 }
12}
The module is an approved target. So the attacker, who is not a keeper, sends one multicall that tells the module to move the position to an address the attacker picks:
1wallet LP position: 2,900 tRSETH · module enabled · helper authorized on the module
2attacker is a keeper: no · module is an approved target: yes
3$ attacker → helper.multicall([module.moveLiquidity(0x3C44…93BC)])
4 ✓ accepted
5 wallet position 2,900 → 0 tRSETH · attacker 2,900
6ONE MULTICALL · WALLET 2,900 → 0 tRSETH

The fix

Check the caller, not just the target. Only your keepers can drive a contract that drives a module holding your funds:
1// contracts/src/KeeperHelper.sol (KeeperHelperFixed)
2function multicall(Call[] calldata calls) external override {
3 if (!isKeeper[msg.sender]) revert NotKeeper();
4 _run(calls);
5}
Same multicall:
1wallet LP position: 2,900 tRSETH · module enabled · helper authorized on the module
2attacker is a keeper: no · module is an approved target: yes
3$ attacker → helper.multicall([module.moveLiquidity(0x3C44…93BC)])
4 ✗ reverted: NotKeeper()
5 wallet position 2,900 → 2,900 tRSETH · attacker 0
6REVERTED · WALLET KEEPS 2,900 tRSETH
A module's require(msg.sender == helper) is only as strong as the helper's own access control. Follow the chain of trust from the wallet to every contract that can reach it, and check the caller at each link.
One-line test: from a random address, send your helper a multicall to each approved target. It must revert.

5. Routing into the attacker's pool: letting the caller pick the pool

The Safe attack had a second half. The pool the funds were routed into was the attacker's, and so was its hook. In v4, anyone can create a pool with any hook, and a hook with the return-delta permission can take part or all of the swap's input. If your contract swaps through whatever pool it is handed, the attacker can hand it theirs.

The demo

Our vault sells its tUSDC rewards for tETH. Anyone can trigger the harvest. Public harvests are common, so the job still runs if your own keeper is down. The caller picks the pool, and there is no minimum amount out:
1// contracts/src/HarvestVault.sol
2function harvest(PoolKey calldata key) external virtual returns (uint256 out) {
3 return _swap(key, 0);
4}
5
6function _swap(PoolKey calldata key, uint256 minOut) internal returns (uint256 out) {
7 require(key.currency0 == address(reward) && key.currency1 == address(want), "pair");
8 uint256 amt = reward.balanceOf(address(this));
9 reward.approve(address(manager), amt);
10 out = manager.swap(key, SwapParams(true, amt), "");
11 require(out >= minOut, "min out");
12}
The token pair check passes: the attacker's pool is a real tUSDC/tETH pool. Its hook just takes everything:
1// contracts/src/HarvestVault.sol (AttackerHook)
2function beforeSwap(address, PoolKey calldata, SwapParams calldata params, bytes calldata)
3 external override onlyPoolManager returns (bytes4, uint256, uint256)
4{
5 return (this.beforeSwap.selector, params.amountIn, 0);
6}
1vault rewards: 8,000 tUSDC · harvest() is public, caller picks the pool
2$ attacker → poolManager.initialize(tUSDC/tETH, hook = 0x0dcd…cd82) # their own hook
3$ attacker → vault.harvest(their pool)
4 ✓ harvested
5 in: 8,000 tUSDC · out: 0 tETH · attacker hook holds 8,000 tUSDC
68,000 tUSDC IN · 0 OUT · THE HOOK KEPT IT
Note that the attacker's hook passes its own onlyPoolManager check. It is a perfectly well-behaved hook, for its owner.

The fix

Only route into pools whose hooks you have allowlisted, and set a minimum amount out, so a bad route reverts instead of paying nothing:
1// contracts/src/HarvestVault.sol (HarvestVaultFixed)
2uint256 public constant MIN_OUT_BPS = 9_500; // at least 95% of the expected amount
3
4function harvest(PoolKey calldata key) external override returns (uint256 out) {
5 if (!allowedHook[key.hooks]) revert HookNotAllowed(key.hooks);
6 return _swap(key, expectedOut() * MIN_OUT_BPS / 10_000);
7}
Same harvest, then the allowlisted pool:
1vault rewards: 8,000 tUSDC · harvest() is public, caller picks the pool
2$ attacker → poolManager.initialize(tUSDC/tETH, hook = 0x9a67…7508) # their own hook
3$ attacker → vault.harvest(their pool)
4 ✗ reverted: HookNotAllowed(0x9A67…7508)
5 in: 0 tUSDC · out: 0 tETH · attacker hook holds 0 tUSDC
6$ anyone → vault.harvest(the allowlisted pool)
7 ✓ harvested · vault holds 2.00 tETH
8HOOK NOT ALLOWED · LOST 0 · REAL POOL PAID 2.00 tETH
In the demo, the expected amount is a number our admin sets, and 95% is our configuration, not an industry figure. In production, derive the minimum from a price the caller can't move in the same transaction, such as a TWAP or an independent feed. The minimum is your slippage limit: it also protects you on a legitimate pool that was pushed off price.
One-line test: create a pool with a hook that keeps the whole input, and pass it to every function in your code that accepts a PoolKey. Each must revert.

The checklist

Screenshot this one.
  1. onlyPoolManager on every callback.
  2. sender is the router. Get the real user from a router you trust, never from one you don't.
  3. Round every delta in the pool's favour. Down on what you pay out, up on what you take in.
  4. Helpers and modules check who calls, not just what.
  5. Route only into allowlisted pools and hooks, with a minimum out.
Then do the one test from the introduction. Write a test that calls every hook callback from a random address. Every single one should revert. If any of them goes through, that's check number 1. Fix that first.
For more v4 hook bug patterns, with proofs of concept, see Uniswap v4 hook attacks: 4 exploit patterns with PoCs.

FAQ

1. Can anyone call my Uniswap v4 hook directly?
Yes. A hook is an ordinary contract, and its callback functions are external. The pool manager calls them during a swap, but nothing stops anyone else from calling them too, unless the hook checks msg.sender. Cork Protocol lost about $12M in May 2025, and one of its two flaws was a beforeSwap with no such check. In our demo, an attacker called a rebate hook's afterSwap directly with a fake 1,000,000 tUSDC swap and took its whole 10,000 tUSDC budget. With onlyPoolManager on the callback, the same call reverted with NotPoolManager().
2. Is `sender` in a hook callback the user?
No. It is whoever called the pool manager, which for most users is a router. A hook that allowlists sender has to list the router, and then anyone who swaps through the router gets in. In our demo, an attacker who wasn't on the list swapped 5,000 tUSDC that way. Ask routers you trust for the real user through msgSender(), and never call it on a router you don't control or haven't listed: an attacker's router can return any address.
3. Which way should a hook round its deltas?
Always in the pool's favour: round down what the hook pays out, round up what it takes in. A one-unit rounding error looks harmless until someone repeats it. Bunni lost about $8.4M in September 2025 to a rounding error in withdrawals, repeated 44 times. In our demo, a custom curve that rounded payouts up paid 1.00 tGOLD, worth 1,000 tUSD, for 100 swaps that cost 0.0001 tUSD. Rounding down, the same swaps paid out nothing.
4. What went wrong in the September 2026 Safe rsETH loss?
As reported, a Safe had a custom module managing its Uniswap v4 liquidity, and the owner had authorized an auxiliary multicall contract for keepers. An authorization check error in that helper let the module be driven into a pool the attacker had created, with the attacker's own hook. About 2,900 rsETH, around $7.73M, left the Safe, and an MEV bot front-ran the attacker in the same block. Safe's core contracts and keys were not at fault. Our demos split this into two bugs: a helper that checks the call target but not the caller (fixed with a keeper check), and a contract that swaps through any pool it is given (fixed with a hook allowlist and a minimum out).
5. How do I test a v4 hook for these bugs?
Start with one test: call every hook callback from a random address, and assert that each one reverts. Then add one per bug class: swap through your trusted router from an address that isn't allowlisted; loop the smallest possible input many times and check value out never exceeds value in; send your helper a multicall from a non-keeper; and pass a pool whose hook keeps the whole input to every function that accepts a PoolKey. Every one of these should revert or pay nothing.

Glossary

TermDefinition
HooksExternal smart contracts in Uniswap v4 that execute custom logic at specific points in a pool's lifecycle.
Pool ManagerThe singleton contract in Uniswap v4 that manages all pools, liquidity, and swaps in a unified system.
msg.senderA Solidity global variable that returns the address of the account or contract that directly called the current function.
Rounding DirectionThe deliberate choice, made per operation, of whether integer division rounds up or down so that every truncation favours the protocol rather than the caller.
Wallet ModuleAn extra contract a smart wallet's owners enable so it can act for the wallet without the owners signing each transaction.
MulticallA function or contract that executes a list of calls in one transaction.
KeeperA bot that calls a contract's maintenance functions, such as harvests, rebalances or liquidations.
SlippageThe difference between the expected price of a trade and the actual execution price.

Build it safely

Every attack and fix in this article is runnable on a local chain: ZealynxSecurity/zealynx-demos, newtech-09-v4hooks. Clone it, run each demo vulnerable and then with --fixed, and compare them with your own hook, router, helpers and vaults. The contracts there are minimal teaching stand-ins, not Uniswap's, Safe's or any real protocol's code, and they say nothing about who was at fault in any real incident.
Shipping a v4 hook, or routing funds through other people's pools? Send us your hook before it goes live. We will tell you where it breaks, and what it would take to make it safe. Request a hook review →

Working auditors in your corner, all year

Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.

Carlos (Bloqarl)
Written by

Founder at Zealynx

Keep reading
Flash loan attacks: anatomy of nine-figure DeFi exploits
Attack vectorsApr 29

Flash loan attacks: anatomy of nine-figure DeFi exploits

37 min read
Your Vault Prices Collateral at $1: 5 Ways to Bad Debt
Attack vectorsNov 6

Your Vault Prices Collateral at $1: 5 Ways to Bad Debt

17 min read
ERC-4626 inflation attack: how it works and how to stop it
Attack vectorsJul 2

ERC-4626 inflation attack: how it works and how to stop it

20 min read