Back to blogs
Written by
Giovanni Di Siena
Published on
November 14, 2025

Uniswap V4 Hooks Security Deep Dive

Deep dive analysis of Uniswap v4 hooks security. Raise the bar for secure implementation; discover attack vectors and known smart contract vulnerabilities.

Table of Contents

Building on a previous article written for the Cyfrin blog (and featured in the BlockThreat newsletter) that covered the main architectural changes and technical innovations of Uniswap v4, this article categorizes and analyzes findings from public audit reports specifically related to custom v4 hooks. For background and framing, resources such as the Known Effects of Hook Permissions by Uniswap Labs and this checklist by Composable Security provide useful reference points.

Illustrated in BlockSec’s threat model “Thorns in the Rose: Exploring Security Risks in Uniswap v4’s Novel Hook Mechanism,” hooks can be either ‘benign but vulnerable’ or ‘intentionally malicious.’ This article focuses on the former but there is overlap between categories and it’s equally important to account for the latter. Other issues need to be taken into account when interacting with unverified or otherwise unvetted hooks such as upgradeability risks, private key compromise, and centralisation concerns.

It is also important to note that potential attack vectors can arise from existing, well-known smart contract vulnerabilities that manifest within the hook itself. While a few particularly interesting examples will be included, this article focuses primarily on exploits and issues specific to Uniswap v4 as they relate to custom hooks taking custody of funds and managing critical application state.

Let’s dive in.

Hooks and permissions

Deploying hooks with permissions that are not implemented

Hook permissions are encoded in least significant bits of the hook address and compared against flags to determine whether it should implement the corresponding function:

function hasPermission(IHooks self, uint160 flag) internal pure returns (bool) {
    return uint160(address(self)) & flag != 0;
}

If a hook lacks the necessary permissions for a given function, it effectively behaves as a no-op. However, when the hook address encodes a certain permission for which the corresponding function is not implemented, the execution will revert (assuming no fallback logic is included).

The v4 periphery BaseHook abstract contract is designed to avoid deploying such a hook by validating that the deployed hook address agrees with the expected permissions of the hook. Similar and more extended functionality is provided by the OpenZeppelin implementation.

Heuristic: Does the hook inherit an abstract BaseHook implementation? If not, are there checks in place to avoid deploying a hook that encodes specific permissions but does not implement the corresponding functions?

Deploying hooks without permissions required for implemented functions

Noted above, if a hook lacks the necessary permissions for a given function then its execution will be skipped. This is contrary to cases where permission is granted but the necessary functionality is not implemented. Here, failing to encode the necessary permissions for implemented functions will most likely result in unexpected and undesired behaviour because key logic is omitted.

In this example, a protocol fee is intended to be taken in the afterSwap() hook; however, absence of the AFTER_SWAP_RETURNS_DELTA_FLAG permission results in a denial-of-service (DoS) of all swaps once the protocol fee is enabled.

The contract creation code in addition to the salt and deployer address determine the hook address, so different constructor parameters and deployer addresses result in different hook addresses. Again, use of a base hook implementation will help to verify that the permissions of the derived hook address are encoded as intended:

/// @notice Utility function intended to be used in hook constructors to ensure
/// the deployed hooks address causes the intended hooks to be called
/// @param permissions The hooks that are intended to be called
/// @dev permissions param is memory as the function will be called from constructors
 function validateHookPermissions(IHooks self, Permissions memory permissions) internal pure {
    if (
        permissions.beforeInitialize != self.hasPermission(BEFORE_INITIALIZE_FLAG)
            || permissions.afterInitialize != self.hasPermission(AFTER_INITIALIZE_FLAG)
            || permissions.beforeAddLiquidity != self.hasPermission(BEFORE_ADD_LIQUIDITY_FLAG)
            || permissions.afterAddLiquidity != self.hasPermission(AFTER_ADD_LIQUIDITY_FLAG)
            || permissions.beforeRemoveLiquidity != self.hasPermission(BEFORE_REMOVE_LIQUIDITY_FLAG)
            || permissions.afterRemoveLiquidity != self.hasPermission(AFTER_REMOVE_LIQUIDITY_FLAG)
            || permissions.beforeSwap != self.hasPermission(BEFORE_SWAP_FLAG)
            || permissions.afterSwap != self.hasPermission(AFTER_SWAP_FLAG)
            || permissions.beforeDonate != self.hasPermission(BEFORE_DONATE_FLAG)
            || permissions.afterDonate != self.hasPermission(AFTER_DONATE_FLAG)
            || permissions.beforeSwapReturnDelta != self.hasPermission(BEFORE_SWAP_RETURNS_DELTA_FLAG)
            || permissions.afterSwapReturnDelta != self.hasPermission(AFTER_SWAP_RETURNS_DELTA_FLAG)
            || permissions.afterAddLiquidityReturnDelta != self.hasPermission(AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG)
            || permissions.afterRemoveLiquidityReturnDelta
                != self.hasPermission(AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG)
    ) {
        HookAddressNotValid.selector.revertWith(address(self));
    }
}

Heuristic: Does the hook inherit an abstract BaseHook implementation? If not, are there checks in place to avoid deploying a hook that implements a specific function without encoding the corresponding permission?

Unimplemented functions allowing bypass through underlying Uniswap v4 contracts

When focusing on a specific hook implementation, it is easy to forget about the entrypoints that still exist on Uniswap v4 itself. Even when all hook permissions are correctly encoded for functions that are expected to be exposed, there can still be instances in which unimplemented functions should have been implemented (and permissions set accordingly). This issue can take many forms depending on the context but ultimately arises because the business logic of the application may dictate that certain actions should only be performed through the hook or a related contract.

For instance, a builder might intend to restrict liquidity modification to happen exclusively through the hook. But, if overlooked, it’s still possible to do so directly through the PoolManager contract. In this case, unless relevant liquidity modification hooks are implemented to revert, it is possible to circumvent execution of intended hook logic.`

In this example, the intended behaviour of a hook designed to penalize just-in-time (JIT) liquidity provision in the afterRemoveLiquidity() function can be bypassed by increasing the liquidity to collect all accrued swap fees. This resets the fee state and bypasses the penalty mechanism. In this scenario, the solution is to extend the hook permissions to additionally track fee collection that occurs during liquidity additions within the beforeAddLiquidity() function.

A simpler example is when business logic determines it is not desirable to allow donations. Without activating the beforeDonate() hook to always revert on donations, calls to PoolManager::donate would succeed in increasing the fee growth.

Heuristic: Can actions intended to originate from the hook, or otherwise alter the behaviour of other implemented hook functions, be executed directly on the underlying Uniswap v4 contracts? Are the necessary hooks implemented to take control of execution?

Insufficient pool key validation

Uniswap v4 does not restrict who can create new liquidity pools, or which hook address to use in a new liquidity pool. If a hook is not restricted to a specific pool or set of pools, an attacker could deploy a malicious pool with fake tokens and use/abuse the hook through attack vectors such as reentrancy or internal accounting manipulation of the internal accounting.

As with all unsafe external calls and caller-supplied inputs, insufficient validation can potentially compromise the hook contract depending on the context. Thus, hooks designed for specific pools should validate token pairs specified within the pool key during initialization. Generally, hook functions should also grant permissioned access to only the specific subset of Uniswap v4 pools that are intended to use and interact with the given hook contract.

Certora’s 2024 review and report of Doppler protocol uncovered a critical (C-01) that details a variation of this vulnerability. The victim contract, intended to coordinate all ecosystem hooks and associated contracts, can be drained by specification of a malicious pool key due to insufficient input validation of the hook and currency addresses.

More examples: [C-02, H-01, M-02, L-12, 5, 6, 7].

Heuristic: Are the pool key and associated currency addresses validated to ensure that permissioned access is granted only to the specific subset of Uniswap v4 pools expected to use and interact with the hook?

Hook callbacks

Insufficient callback access controls

Similar to other callback types, such as those invoked by flash loans providers, the v4 unlockCallback() and other hook function callbacks should in general only be callable by the singleton PoolManager contract. These details may differ slightly depending on the specifics of a given protocol design, in which case careful access control management is of even more pertinent concern.

As above, protections can be implemented by inheriting one of the BaseHook implementations along with the SafeCallback base contract and overriding _unlockCallback().

/// @dev We force the onlyPoolManager modifier by exposing a virtual function after the onlyPoolManager check.
function unlockCallback(bytes calldata data) external onlyPoolManager returns (bytes memory) {
    return _unlockCallback(data);
}

/// @dev to be implemented by the child contract, to safely guarantee the logic is only executed by the PoolManager
function _unlockCallback(bytes calldata data) internal virtual returns (bytes memory);

Perhaps the most high profile example of this attack vector was its use as a lever in the $12 million Cork Protocol exploit in which the beforeSwap() function was called by an attacker with malicious hook data.

Other examples include:

  • H-02 from Certora’s Doppler review where an insufficient access control allowed any address to directly call beforeInitialize() and overwrite the stored pool key.
  • C-06 in Guardian Audits review of Gamma UniV4 Limit Orders where an insufficient access control allowed any address to call beforeSwap() and afterSwap(), undermining the core limit order mechanism.
  • The example v4-stoploss hook which allows any address to call afterSwap() with arbitrary parameters, again undermining the limit order mechanism.

Heuristic: Does the hook inherit an abstract BaseHook implementation? Does the hook or related contract(s) inherit the abstract SafeCallback? If not, are there checks in place to prevent unprivileged callers from invoking unlockCallback() and other hook functions?

Incorrect callback return data encoding

The length of data returned by a hook callback should be, at minimum, sufficient to contain the 4 byte selector, encoded in 32 bytes:

// Length must be at least 32 to contain the selector. Check expected selector and returned selector match.
if (result.length < 32 || result.parseSelector() != data.parseSelector()) {
    InvalidHookResponse.selector.revertWith();
}

For hook functions invoked using Hooks::callHookWithReturnDelta and expecting to parse a return delta, the length of data should be exactly 64 bytes to contain both the encoded selector plus the 32 byte delta:

// A length of 64 bytes is required to return a bytes4, and a 32 byte delta
if (result.length != 64) InvalidHookResponse.selector.revertWith();
return result.parseReturnDelta();

Additionally, Hooks::beforeSwap expects a 3 byte fee override and enforces a length of 96 bytes:

// A length of 96 bytes is required to return a bytes4, a 32 byte delta, and an LP fee
if (result.length != 96) InvalidHookResponse.selector.revertWith();

The execution will revert if the length of return data does not match the expected length. This could be due to encoding an incorrect type or any other reason.

Heuristic: Does the hook inherit an abstract BaseHook implementation? If not, do the hook function signatures and return data lengths match what’s expected by the Hooks library?

Before vs after hooks

It may not seem important whether some functionality is implemented in a hook to execute before an operation versus after, and indeed in certain circumstances this may be perfectly fine. However, hook developers and auditors should be clear on the assumptions being made regarding the placement of such logic as this can give rise to an entire category of very subtle bugs.

Consider the example of some custom incentives logic. Perhaps, the logic intends to clean up critical state used in such computations upon complete removal of liquidity. If this is performed in the beforeRemoveLiquidity() hook rather than afterRemoveLiquidity(), the liquidity will remain unchanged at the point of execution and the state will never be updated. Consequently, incentives would be computed for liquidity positions that no longer exist. While this seems obvious in hindsight, it is challenging to spot as complexity grows.

This also plays out in reverse. In this example, tick initialization logic present in the afterAddLiquidity() hook should have been placed in beforeAddLiquidity(). Because, executing after the core liquidity modification logic has run means the tick will have already been initialized once execution returns to the hook; thus, the conditional logic will never trigger.

More examples: [1].

Heuristic: Is hook logic dependent on whether it’s executed before or after the core Uniswap v4 logic? Will liquidity, tick initialization state, etc. differ depending on the choice of executing before or after the hook? Will a mistake cause key logic to execute differently, incorrectly, or be entirely omitted?

Unhandled reverts

When reviewing hooks, it is important to be very mindful of potential sources of reverts both in the hook business logic and based on liquidity modification/swaps.

Sync before unlock

PoolManager::sync has the onlyWhenUnlocked modifier to ensure that currency and reserves transient storage can only be synchronised after PoolManager::unlock has been called. If a hook function attempts to synchronise the currency reserves before unlocking the PoolManager, execution will revert. Instead, this logic should be moved to the unlock callback and executed prior to any custom accounting.

Heuristic: Are calls to sync() always performed following a prior unlock()?

💡 This is no longer the case in the most recent version of the PoolManager contract – sync() can now be called without first unlocking the singleton.

Unsettled deltas from uncleared or unsynchronised dust

The key invariant of flash accounting and the unlock mechanism is that all deltas must be settled before returning execution from the unlock callback to the PoolManager. Actions such as swapping tokens or modifying liquidity generate deltas which represent the net changes in token balances owed by or to the pool and are accumulated in transient storage. If there are any unsettled deltas at the end of execution, the integrating contract may need to withdraw assets owed to the user using the take() function or deposit assets owed to the pool followed by a call to the settle() function.

In some instances, there may be small “dust” balances that prevent account deltas from being fully settled and so the clear() function should be used to explicitly account for this to the pool. This is implemented as a protection against loss of caller funds. However, dust balances can thus be used as a DoS attack vector if this scenario is not properly handled, as seen in this H-01 example in Guardian Audits review of Gamma’s UniV4 Limit Order.

In similar fashion, the same review L-13 details a DoS scenario in which tokens are donated, but not synchronized, in transient storage with a call to sync(), resulting in a revert when attempting to settle the deltas of an operation.

Heuristic: Are there any scenarios in which dust could accumulate and, if so, is it explicitly cleared? Are the currency and reserves always synced in transient storage before performing operations that modify account deltas?

Reverts when modifying liquidity

Unhandled reverts in either the beforeRemoveLiquidity() or afterRemoveLiquidity() hook can result in liquidity provider (LP) funds being permanently locked unless there is some way of escaping from this DoS state. This scenario can also prevent fees from ever being collected, because, unlike Uniswap v3, the delta from fee growth is required to be settled on every liquidity modification. Reverts during the addition of liquidity are slightly less problematic since this cannot result in funds becoming locked but still cause severe disruption to the protocol.

Other examples include H-8 in which the initial LP cannot withdraw due to protocol-specific initialization, additionally noted by M-6 that does not refund unused tokens for this initial liquidity provision.

Heuristic: Are there any calls within the liquidity modification hooks that could revert? Are these potentially problematic calls explicitly handled to prevent unexpected reverts?

Reverts in peripheral hook logic

Peripheral logic like custom incentives and rebalancing must be treated with care to avoid DoS and potential loss of funds from unhandled reverts. The severity of such reverts depends on which functionality is disabled and whether normal functioning of the logic can be recovered. While an inability to add liquidity and swap against an affected pool represents loss of core functionality, the most impactful cases are those in which funds are permanently locked. For example, from the inability to remove existing liquidity from the pool or withdraw other tokens in the custody of the contract(s).

While not critical, some sources of reverts may never allow the intended functionality to run where incorrect access control prevents an external integration from executing correctly. If the hook is not upgradeable then it needs to be redeployed with a fix, causing severe disruption to the protocol and its users.

Sometimes, logic may appear sound for one invocation of an operation but fail on subsequent calls, as in this example of a top-of-block swap tax that is erroneously applied to all other swaps and causes a DoS. There may also be edge cases in the math used to compute such taxes/prices/etc. that can cause some subset of calls to fail.

Pashov Audit Group’s review of Bunni V2 identifies H-05 as an example of DoS due to a failure to account for multiple pool IDs. Certain operations such as rebalancing, may be possible for multiple pools associated with a given hook within a given block; however, the logic may be such that this is overlooked, restricting the functionality to only a single pool if a distinct nonce is not used.

In examples [1, 2, 3, 4] permissionless rewards distribution and range creation can be abused to trigger reverts due to overflow and excessive gas usage. This DoS vector is also demonstrated in rebalancing and incentives logic due to differences between chains.

More examples: [1].

Heuristic: Are there any calls in custom logic within hook function implementations that could revert? Are there any peripheral accounting mechanisms that could revert, perhaps due to excessive gas usage or under/overflow? Are these potentially problematic cases explicitly handled to prevent unexpected reverts?

Dynamic fees

Incorrect dynamic fee accounting

Uniswap v4 pools can support dynamic swap fees beyond the typical static options if the pool key specifies it using LPFeeLibrary.DYNAMIC_FEE_FLAG to signal support. This is queried in Hooks::beforeSwap:

// dynamic fee pools that want to override the cache fee, return a valid fee with the override flag. If override flag
// is set but an invalid fee is returned, the transaction will revert. Otherwise the current LP fee will be used
if (key.fee.isDynamicFee()) lpFeeOverride = result.parseFee();

The dynamic fee can be updated by either:

  • Having the hook contract call PoolManager::updateDynamicLPFee.
  • Returning fee | LPFeeLibrary.OVERRIDE_FEE_FLAG from the beforeSwap() hook.

Dynamic fee pools initialize with a 0% default fee, which should be overridden with a call to the updateDynamicLPFee() function in the afterInitialize() hook.

The per-swap override allows the dynamic fee to change on every swap, avoiding repeated calls to the PoolManager, and is handled by Pool::swap:

// if the beforeSwap hook returned a valid fee override, use that as the LP fee, otherwise load from storage
// lpFee, swapFee, and protocolFee are all in pips
{
    uint24 lpFee = params.lpFeeOverride.isOverride()
    ? params.lpFeeOverride.removeOverrideFlagAndValidate()
    : slot0Start.lpFee();

    swapFee = protocolFee == 0 ? lpFee : uint16(protocolFee).calculateSwapFee(lpFee);
}

If either of LPFeeLibrary.DYNAMIC_FEE_FLAG or LPFeeLibrary.OVERRIDE_FEE_FLAG are omitted, the intended fee override will not be applied and the default fee will be used.

Even with the dynamic fee correctly specified, the accounting logic could be implemented incorrectly resulting in a miscalculation of fees assigned to the intended recipient(s) or unrecoverable balances becoming locked in the hook.

Additional examples: [C-02, M-03, M-4, M-11, 5, 6, 7, 8].

Heuristic: Does the hook intend to support dynamic swap fees and, if so, does the pool key signal support? Is functionality exposed with sufficient access control to update the dynamic fee directly on the PoolManager? Is the default dynamic fee overridden in the afterInitialize() hook? Are dynamic fees returned correctly from the beforeSwap() hook with the LP fee override flag applied?

Abuse of dynamic fees

Depending on how the protocol is configured, there are instances where privileged callers can obtain the right to set and adjust dynamic swap fees. For example, Bunni v2 implements the auction-managed AMM mechanism to run censorship-resistant, on-chain auctions for the right to temporarily set the swap fee rate and receive accrued fees from swaps.

TOB-BUNNI-11 details a scenario in which a malicious manager can manipulate the TWAP (time-weighted average price) price while avoiding arbitrage. If the oracle is used to determine asset prices in an external lending protocol then it is possible for the manager to exploit this by borrowing against overvalued collateral.

H-02 demonstrates how a malicious manager could capture fees at the expense of the protocol.

More examples: [H-02].

Heuristic: Can dynamic swap fees be set manually or adjusted by an actor other than the protocol administrator? Are reasonable bounds applied to prevent manipulation and loss to other fee recipients?

Custom accounting

Custom accounting is a powerful feature of Uniswap v4. At the same time, it can easily put entire protocol liquidity at risk. Unlike vanilla hooks, those that leverage custom accounting take control of the underlying liquidity and any bug in the business logic of these hooks, or other related contracts that store or handle ERC-6909 claim tokens, is likely to be catastrophic. It is imperative that this accounting is water tight. Even if it is seemingly well-implemented, developers and auditors should be extremely paranoid of all sources and sinks of value, as will be demonstrated below.

Insufficient input validation

Insufficient input validation can give rise to multiple categories of bugs and is often a precursor to some of the highest severity exploits. In the context of custom accounting, more complex hook protocol architecture can have its accounting abused or broken by a caller invoking certain functions with arbitrary inputs.

Consider TOB-BUNNI-6 in which anyone can call the rebalance order pre/post hooks to trigger the rebalance logic outside of legitimate order fulfilment, resulting in funds being pulled from the central BunniHub contract. TOB-BUNNI-7 details a similar issue with the rebalance order fulfilment mechanism. In this instance, insufficient validation of the order fulfilment inputs allows for asymmetric execution of the rebalance order pre/post hooks which violates the intended symmetric execution.

More examples: [TOB-BUNNI-8].

Heuristic: Are inputs of all functions that touch custom accounting logic sufficiently validated? Are there specific permissioned addresses that should be required? Are there any combinations of inputs that allow the intended, possibly symmetric, execution to be bypassed or otherwise violated?

Incorrect segregation of funds

As with typical smart contract accounting, there may be token balances designated to different purposes and beneficial owners. Incorrect segregation of funds is a common issue in which one of potentially multiple entities can perform some action with access to an asset/amount to which they should not be entitled.

The severity of such issues depends on the specific business logic. For example, perhaps the unintended access is limited to elevated admin control allowing an attacker to extract value in higher-severity occurrences. Custom Uniswap v4 accounting is no different. Recursive LP tokens, fees, donations, and other incentive balances are often sources of this type of issue that are easy to overlook.

On the other hand, high impact issues such as C-05 demonstrates how a mismatch between raw ERC-20 and ERC-6909 accounting can be abused to drain the underlying pool currency. Here, an exploit is predicated on utilizing the LP token of one pool stored in the target contract as rent payments, as a currency of the recursive malicious pool.

A similar example of abusing recursive LP tokens to drain reward balances can be found here. In this case, deltas are lost to the PoolManager upon liquidity provision. The underlying tokenized incentives that are encapsulated by the wrapped liquidity tokens can be stolen by leveraging flash accounting to sync, claim on behalf of the PoolManager, settle, and finally take the extracted tokens.

Pashov Audit Group’s finding in their Bunni V2 review found H-04, showing how dust balances accumulated by either the fee or donation mechanism can result in the hook reverting due to the issue of unsettled deltas from uncleared or unsynchronised dust as explained above.

Additional examples: [H-02, 2, 3, 4, 5, 6].

Heuristic: Are there any assets or addresses with multiple accounting designations? Is there a mixture of ERC-20 and ERC-6909 accounting? If so, does the accounting work in tandem correctly? Are fees, donations, or any other reserved assets explicitly considered? Should recursive LP tokens be supported and can any underlying yield be stolen when provided as liquidity?

Incorrect swap logic

A key aspect of the design space unlocked by custom accounting is the ability to override the swap logic of the underlying concentrated liquidity model. While this pioneering feature allows for innovative AMM designs to be built on top of the primitive Uniswap v4 hook architecture, it greatly increases the attack surface and increases the potential for subtle arithmetic bugs that can completely break the core functionality.

Consider TOB-BUNNI-15/16/17/18 and M-21 in which it was possible to:

  • Execute free swaps, providing zero input tokens but receive a non-zero amount of output tokens.
  • Gain a net positive amount of tokens from round trip swaps.
  • Receive a different amount of input/output tokens depending on the exact input/output configuration.
  • Trigger unhandled panic reverts due to arithmetic errors.
  • Underestimate the liquidity available for a swap.

A separate example, M-13, details a DoS vector for swaps erroneously using the specified swap price limit instead of computing the price target for the next swap step.

Heuristic: Does the hook override the underlying concentrated liquidity model? Do swaps behave as expected? Can any of the AMM invariants be broken?

Incorrect delta accounting

Another aspect of custom accounting that goes along with modifying swap logic is the ability for hooks to override deltas that represent the net token movement between the pool, the hook, and the caller. If these deltas are computed or applied incorrectly, it can result in silent but severe accounting discrepancies that leak value at the expense of the protocol or its users, depending on the nature of the oversight.

A common mistake to make is misinterpreting the sign convention of swapDelta, hookDelta, or callerDelta. If the caller delta of the specified token is underestimated in beforeSwap(),the hook may underpay its users. Whereas if the hook delta is overestimated in afterSwap(), it will overcharge users . In the worst case, value can be extracted from the pool if tokens are transferred in the wrong direction at the expense of the hook.

Note that the core Hooks::beforeSwap logic prevents the semantics of a swap from changing with application of the hook delta:

// Update the swap amount according to the hook's return, and check that the swap type doesn't change (exact input/output)
if (hookDeltaSpecified != 0) {
    bool exactInput = amountToSwap < 0;
    amountToSwap += hookDeltaSpecified;
    if (exactInput ? amountToSwap > 0 : amountToSwap < 0) {
        HookDeltaExceedsSwapAmount.selector.revertWith();
    }
}

In other words, exact input swaps are explicitly forbidden from becoming exact output swaps and vice versa. However, if afterSwap() returns a hookDelta with the incorrect sign, the magnitude and direction of payment can become inverted.

More examples: [M-02, 2].

Heuristic: Is the direction of net value flow consistent with the original intent? Can users be under/overcharged? Can value be extracted directly from the hook? Are deltas ever added/incremented when they should be subtracted/decremented, or vice versa?

Reentrancy

As mentioned in the introduction, hooks can suffer from well-known smart contract vulnerabilities. This includes reentrancy which may arise when chaining together a number of lower-severity issues within more complex hook protocol architecture. This is exactly what happened with the live critical vulnerability in Bunni v2, reported by Cyfrin, where the total protocol TVL was at risk.

In short, through deployBunniToken() it was possible to deploy malicious hooks with no validation which could subsequently be used to unlock the global reentrancy guard. Since the hook of a Bunni pool was not constrained to be the canonical BunniHook implementation, and given that unlockForRebalance() simply required the hook of a given pool to be the caller, anyone could create a malicious hook that invoked this function directly to unlock the reentrancy guard protecting the core BunniHub contract. 

This, combined with the caching of pool state before unsafe external calls to rehypothecation vaults, which could be freely specified as arbitrary malicious addresses by an attacker, meant that reentrancy from within both hookHandleSwap() and withdraw() was in fact possible to recursively extract both raw balances and vault reserves accounted across all pools.

C-03 details a similar, less impactful variation of this issue, in which execution of deposits over the intermediate state between pre/post hooks could be abused. This is also the finding that introduced the problematic function and allowed a chain of lower severity vulnerabilities to be assembled into a full-drain critical exploit. Similar to the $197 million Euler exploit, this highlights the challenge of full-coverage mitigation review and importance of recognizing the potential downstream effects of subtle, seemingly trivial, logic changes.

Even if direct reentrancy on the target contracts is not possible, read-only re-entrancy may still affect integrating contracts relying on quoter functions due to missing re-entrancy guards, as noted in L-13.

Heuristic: Does the canonical hook or any of its associated contracts perform unsafe external calls? Can this be influenced by caller-defined inputs that may specify malicious addresses? Is a reentrancy guard applied to all critical functions? Can it be bypassed, perhaps by a malicious hook implementation?

Rounding and precision loss

Another well-known albeit tricky smart contract vulnerability is precision loss due to rounding. While it may be relatively straightforward to identify the instances in which this behaviour occurs, it is significantly more challenging to land upon a specific scenario that can be leveraged in an attack.

In the case of the $8.4 million Bunni v2 exploit, the above disclosure was, unfortunately, not sufficient to prevent a ruinous loss of funds. At its core, this exploit was caused by floor rounding which allowed for the construction of an atomic liquidity increase by the attacker. To summarize, the exploit steps included:

  1. Swapping almost all of the pool’s reserves to minimize the active balance to a point where rounding errors become significant.
  2. Repeatedly withdrawing a small number of shares to abuse the rounding behaviour of the idle balance, effectively creating share price inflation while disproportionately decreasing the liquidity calculation based on the active and idle balances.
  3. Swapping back to lock in the atomic liquidity increase and then drain the pool at the inflated price.


While these points seem simple, this was a complex exploit against a complex protocol. The sad irony is the precision with which the attacker crafted the inputs to pull this off. A full post-mortem writeup can be found here, along with a more detailed analysis here. It is strongly recommended to read both to understand the full complexity and nuance of the attack.

More examples: [1, 2].

Heuristic: Is any hook logic susceptible to rounding errors? If so, is it obvious how it might be abused? If not, consider which other calculations may depend on it (e.g. share price, liquidity, etc) and how execution may proceed at the extremes.

Tick Trouble

Misaligned ticks

Depending on the tick spacing of a given pool, there is a small region of tick space between the minimum/maximum usable ticks and the actual minimum/maximum ticks defined by Uniswap v3 math that should be carefully avoided. For example, assuming a tick spacing of 60, the usable tick range is [-887220, 887220] and it should not be possible for the square root price tick to enter range [-887272, -887220) or (887220, 887272].

Uniswap will revert when the tick of a liquidity position is not an exact multiple of the pool’s tick spacing. This can result in DoS [1, 2] and/or unusable positions if not correctly validated by the hook.

function flipTick(mapping(int16 => uint256) storage self, int24 tick, int24 tickSpacing) internal {
    // Equivalent to the following Solidity:
    //     if (tick % tickSpacing != 0) revert TickMisaligned(tick, tickSpacing);
    //     (int16 wordPos, uint8 bitPos) = position(tick / tickSpacing);
    //     uint256 mask = 1 << bitPos;
    //     self[wordPos] ^= mask;
    assembly ("memory-safe") {
        tick := signextend(2, tick)
        tickSpacing := signextend(2, tickSpacing)
        // ensure that the tick is spaced
        if smod(tick, tickSpacing) {
            let fmp := mload(0x40)
            mstore(fmp, 0xd4d8f3e6) // selector for TickMisaligned(int24,int24)
            mstore(add(fmp, 0x20), tick)
            mstore(add(fmp, 0x40), tickSpacing)
            revert(add(fmp, 0x1c), 0x44)
        }
    ...
}

More examples: [1].

Heuristic: Are caller-specified ticks validated and aligned to the tick spacing? Are the range edge cases validated against the minimum and maximum usable ticks?

Operations over zero liquidity

If out-of-range initialization of the square root price is not prevented, and swaps against zero liquidity are not explicitly handled, the price can be freely manipulated and moved outside the intended tick range. Even If there is a small, non-zero amount of liquidity in a pool, various issues can arise from tick manipulation to an extreme value.

In some circumstances, this can result in losses to honest users through arbitrage, as in C-03. In others, swaps and the addition of single-sided liquidity to these end ranges can result in complete DoS of core functionality.

For protocols that implement additional incentives, care should be taken to avoid performing computations and depositing tokens when there is no pool liquidity.

More examples: [H-01, L-26].

Heuristic: Can ticks/ranges be pushed to extreme values or otherwise manipulated? Are they validated against the minimum/maximum usable ticks for the given tick spacing? Are there any additional mechanisms that will behave incorrectly when liquidity is exhausted?

Incorrect tick crossings

Uniswap v3’s concentrated liquidity tick crossing logic defines where liquidity is considered in range and active (i.e., price is between upper/lower ticks). Active liquidity is utilized by swaps and earns fees, so fee growth state updates are also triggered when liquidity positions become active/inactive when the price crosses a tick boundary.

Miscalculation of the active liquidity can have disastrous consequences, as evidenced in multiple examples. Notably, the KyberSwap Elastic critical vulnerability disclosure, the $56 million KyberSwap Elastic exploit (very well-explained here), and the $4.4 million Raydium exploit. This can happen due to rounding or incorrect state updates when crossing ticks during swaps, modifying liquidity (resulting in double counting, for example) or other discrepancies that can be leveraged by an attacker.

In this example, active liquidity calculations appear to function as expected. However, in zero-for-one swaps where the current tick is an exact multiple of the tick spacing at the upper end of a liquidity range, the effect of the boundary tick is skipped and the liquidity is accounted for incorrectly. As a result, the net liquidity is significantly smaller than expected, inflating the computed fee growth, and allowing all rewards to be stolen by such an intentionally, or perhaps even inadvertently, malicious position.

More examples: [H-01, 2].

Heuristic: Are tick crossings handled correctly? Are there edge cases in which the logic breaks, such as starting/ending exactly on a tick/word boundary? Are there sufficient tests around these boundary conditions?

Miscellaneous

Native token handling

Unlike Uniswap v3, Uniswap v4 has support for native ETH abstracted via the custom Currency type. While this succeeds in providing a better user and developer experience, it is not without its dangers. Insufficient or otherwise incorrect handling of native tokens can give rise to some issues associated with (and should be expected when) dealing with native token support.

The first, and likely most impactful, issue is that native token transfers are a source of potential reentrancy. This may be easily overlooked when dealing with the Currency type and ERC-6909 representations, but it is nevertheless incredibly important to identify all sources of unsafe external calls that can be reentered.

A second, more subtle error is the absence of a receive() function. This can result in DoS depending on how the hook and its associated contracts are intended to transfer and receive value in the form of the native token. H-04 demonstrates how this can occur when the target contract is unable to receive dust balances redirected from a router contract.

In the worst case, if native token handling is implemented but the msg.value is insufficiently validated, it’s possible the native token balance can become locked, as in L-22. This can also result in the custom swap delta accounting breaking, as in M-19.

Note, it is not necessary to perform validation on currency1 because the native token will only ever be token0, since currencies are ordered by address and address(0) is used for the native currency [1, 2].

Heuristic: Does the hook have support for native ETH? If so, can it be used to reenter any critical functionality? Do contracts that expect to receive native tokens implement the receive() function? If not, have edge cases such as router integration been considered? Is msg.value sufficiently validated?

JIT liquidity

Just-in-time liquidity enables innovative, capital-efficient protocol designs (e.g. Euler) but can also be used maliciously to manipulate pricing or reward mechanisms. For example, temporarily inflating liquidity positions to capture fees, incentives, or alter other core protocol states before immediately withdrawing, as in M-1/2 which rely on front-running custom liquidity and rewards logic.

The scenario described by TOB-BUNNI-9 shows how an error in the hook withdrawal queue logic enabled a JIT liquidity provision that could be used to manipulate the pool rebalance order computation, potentially leaving it in a worse state than before.

More examples: [1].

Heuristic: Can JIT liquidity be provided? Does it represent net positive flow, or can it be malicious and should it be disincentivised/prevented?

Incorrect router parameters

While the Uniswap V4Router is, like all other router implementations, a peripheral contract, care must be taken to ensure that:

  1. All recipient and token addresses are correct.
  2. Amounts are not accidentally zero or reversed.

C-03 is an example of the former while C-04/H-07 are examples of the latter. Note, these errors can also occur in the hook logic itself.

Heuristic: Is the correct recipient address specified? It is not necessarily always msg.sender. Is amount0/token0 accidentally used in place of amount1/token1 or vice versa?

Custom incentives

The following is a list of various other interesting findings not directly related to Uniswap v4 hooks, rather to the implementation of custom LP fee handling and other incentives logic [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27].

The main takeaways here are: Be cautious of Uniswap v3 math that has been reimplemented for custom purposes, particularly when considering the swap math, tick crossings, and fee growth computations. For more complex hooks, full end-to-end tests should also validate the flow of tokens between multiple integrating contracts to ensure that there is no missing logic/transfers that could result in stuck funds.

Conclusion

Hooks are a very powerful innovation. Their design, Their design, along with other Uniswap v4 protocol innovations (flash accounting, Singleton architecture, PoolManager, etc.) considerably lowers the barrier for AMM experimentation. However, they equally raise the bar for sound and secure implementation, especially when customizing the underlying concentrated liquidity mechanism. The overall attack surface of hooks is large and fairly nuanced, as hopefully demonstrated in this article. If you found it to be helpful, amplification and feedback are greatly appreciated!

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.