Uniswap V4 Hooks Security Deep Dive
Building on a previous article written for the Cyfrin blog (as 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.
As highlighted in this BlockSec threat model, hooks can either be benign but vulnerable or intentionally malicious. This article focuses on the former, in which there is some overlap between categories; however, it is equally important to account for the latter, as well as upgradeability risks, private key compromise, and other centralisation concerns, when interacting with unverified or otherwise unvetted hooks.
It is also worth noting 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 other issues that are specific to Uniswap v4 insofar as they relate to custom hooks taking custody of funds and managing critical application state.
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 then it effectively behaves as a no-op; however, if the hook address encodes a certain permission for which the corresponding function is not implemented then (without any fallback logic) this will result in a revert.
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
As mentioned above, if a hook lacks the necessary permissions for a given function then its execution will be skipped. Counter to the case in which the permission is granted without implementing the necessary functionality, failing to encode the necessary permissions for implemented functions will most likely result in unexpected and undesired behaviour as 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 will result in different hook addresses. Again, use of one of the base hook implementations 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 with all hook permissions correctly encoded for the functions that are expected to be exposed, there can be instances in which unimplemented functions should have actually been implemented (and the 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, it may be intended to restrict modifying liquidity to happen exclusively through the hook, but be overlooked that it would still be possible to do so directly through the PoolManager contract. Unless the relevant liquidity modification hooks are implemented to revert in this case, 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 first increasing the liquidity to collect all the fees accrued from swaps. This resets the fee state and hence bypasses the penalty mechanism. In this case, the solution is to extend the hook permissions to additionally track fee collection that occurs during liquidity additions within the beforeAddLiquidity() function.
Another simpler instance is when it may 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 that are 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 in these cases?
Insufficient pool key validation
Uniswap v4 does not restrict who can create new liquidity pools, nor 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 using and abusing the hook through attack vectors such as reentrancy or 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, so hooks that are designed for specific pools should validate token pairs specified within the pool key during initialization. Hook functions should in general 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.
C-01 details a variation of this vulnerability in which 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 types of callback, such as those invoked by flash loans providers, the v4 unlockCallback() and other hook function callbacks should in general be callable only by the singleton PoolManager contract. These details may differ slightly depending on the specifics of a given protocol design, in which case careful management of access control 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 in which is insufficient access control allowed any address to call
beforeInitialize()directly to overwrite the stored pool key.C-06 in which is insufficient access control allowed any address to call
beforeSwap()andafterSwap(), 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 at least be 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 the 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();
Hooks::beforeSwap additionally 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();
If the length of return data is not as expected, either because the incorrect types are encoded or otherwise, then execution will revert.
Heuristic: does the hook inherit an abstract BaseHook implementation? If not, are the hook function signatures and return data lengths as expected by the Hooks library?
Before vs after hooks
It may not seem important whether some functionality is implemented in a hook that executes before an operation versus one that executes afterwards, and indeed in certain circumstances this may be perfectly fine; however, hook developers and auditors should be clear on the assumptions that are being made about the placement of such logic as this can give rise to a category of very subtle bugs.
Consider the example of some custom incentives logic. Say, perhaps, that it 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() then at the point of execution the liquidity will remain unchanged and the state will never be updated, the consequence being that incentives will be computed for liquidity positions that no longer exist. While this seems obvious in hindsight, it can be challenging to spot as complexity grows.
This could also play out in the reverse scenario, as in this example in which tick initialization logic present in the afterAddLiquidity() hook should have been placed in beforeAddLiquidity(). Executing after the core liquidity modification logic has already run means the tick will already have been initialized once execution returns to the hook and so the conditional logic will never be triggered.
More examples: [1].
Heuristic: is hook logic dependent on whether it is executed before or after the core Uniswap v4 logic? Will liquidity, tick initialization state, etc differ depending on the choice of before/after hook? Will a mistake here 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 also based on liquidity modification/swaps.
Sync before unlock
PoolManager::sync has the onlyWhenUnlocked modifier to ensure that the currency and reserves transient storage can only be synchronised after PoolManager::unlock has already 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()?
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 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 in H-01.
In similar fashion, L-13 details a DoS scenario in which tokens are donated but not synchronised 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, since unlike Uniswap v3 the delta due to 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 will still cause severe disruption to the protocol.
Other examples include H-8 in which the initial LP cannot withdraw due to protocol-specific initialization, which as additionally noted by M-6 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, such as custom incentives and rebalancing, should be treated with care to avoid DoS and potential loss of funds due to unhandled reverts. The severity of such reverts depends on which functionality is disabled and whether it 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 due to an 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 would need to be redeployed with the fix which could cause severe disruption to the protocol and its users.
Sometimes, the logic may appear sound for one invocation of an operation but then 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.
H-05 is 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 the 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 the fee using LPFeeLibrary.DYNAMIC_FEE_FLAG to signal support. This is queried in Hooks::beforeSwap like so:
// 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_FLAGfrom thebeforeSwap()hook.
Dynamic fee pools initialize with a 0% default fee, so this 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 like so:
// 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 then the intended fee override will not be applied and default fee will be used.
Even with the dynamic fee correctly specified, the actual accounting logic may be implemented incorrectly. This could result in miscalculation of the fees assigned to the intended recipient(s) or even unrecoverable balances becoming locked in the hook.
More 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 in which 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 onchain 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 price while avoiding arbitrage. If the oracle is used 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 manually set 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 very powerful feature of Uniswap v4, but by the same token can easily put entire protocol liquidity at risk. Unlike vanilla hooks, those leveraging custom accounting take control of the underlying liquidity and so any bug in the business logic of these hooks, or any other related contracts that store or otherwise 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 alike should be very 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 be abused or otherwise 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 which results 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 themselves, allowing for asymmetric execution of the rebalance order pre/post hooks which violates the intended symmetric execution.
More examples: [TOB-BUNNI-8].
Heuristic: are the inputs of all functions touching logic associated with custom accounting sufficiently validated? Are there specific permissioned addresses that should be required? Are there any combinations of inputs that allows 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, perhaps limited to elevated admin control in the less impactful case but potentially 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.
C-05 demonstrates how a mismatch between raw ERC-20 and ERC-6909 accounting could be abused to drain the underlying currency of a pool. In this example, such 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 rewards balances can be found here. In this case, deltas are lost to the PoolManager upon liquidity provision. The underlying tokenised 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.
H-04 shows how dust balances accumulated by either fee/donation mechanism can result in the hook reverting due to the issue of unsettled deltas from uncleared or unsynchronised dust as explained above.
More 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 is it work correctly in tandem? 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, this greatly increases the attack surface and 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 receiving a non-zero amount of output tokens.
Gain a net positive amount 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 to a swap.
A separate example M-13 additionally 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 in hand with modifying the swap logic is the ability for hooks to override the deltas that represent the net token movement between the pool, hook, and caller. If these deltas are computed or applied incorrectly, this can result in silent but severe accounting discrepancies that leak value at the expense of either the protocol or its users depending on the nature of the oversight.
An easy mistake to make here is misinterpreting the sign convention of swapDelta, hookDelta, or callerDelta. This may, for instance, result in the hook underpaying its users if the caller delta of the specified token is underestimated in beforeSwap() or overcharging users if the hook delta is overestimated in afterSwap(). In the worst case, value could 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 considered exact output swaps and vice versa. However, if afterSwap() returns a hookDelta with the incorrect sign, thinking it is owed then the magnitude and direction of payment can become inverted.
Heuristic: is the direction of net value flow consistent with the original intent? Can users be under/overcharged or extract value 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, including reentrancy which may arise from 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, in which 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 again 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 intermediate state between pre/post hooks could be abused. This also happened to be the finding that introduced the problematic function allowing the chain of lower severity vulnerabilities to be assembled into a full-drain critical exploit. In a manner similar to the $197 million Euler exploit, this highlights the challenge of full-coverage mitigation review and importance of recognising 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 caller-defined inputs which 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 will occur, it is significantly more challenging to land upon the specific scenario that may be leveraged in an attack.
In the case of the $8.4 million Bunni v2 exploit, the above disclosure was very 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 summarise, the exploit steps included:
Swapping almost all of the pool’s reserves to minimise the active balance to the point where rounding errors become significant.
Repeatedly withdrawing a small number of shares to abuse the rounding behaviour of the idle balance, effectively creating a share price inflation while disproportionately decreasing the liquidity calculation based on the active and idle balances.
Swapping back to lock in the atomic liquidity increase and then drain the pool at the inflated price.
While the above points may seem simple enough, 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 of these understand the full nuance of the issue.
Heuristic: is any of the 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 the Uniswap v3 math that should be carefully avoided. For example, assuming a tick spacing of 60, the usable tick range is [-887220, 887220]. Thus, it should not be possible for the sqrt price tick to enter either 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 to be 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 then this can allow the price to be freely manipulated to outside the intended tick range. Even If there is a small non-zero amount of liquidity in a pool, various issues can arise from the tick being manipulated 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.
Heuristic: are ticks/ranges free to be pushed to or otherwise specified as extreme values? Are they validated against the the minimum/maximum usable ticks for the given tick spacing? Are there any additional mechanisms that will behave incorrectly when there is no liquidity?
Incorrect tick crossings
The Uniswap v3 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 for the liquidity positions becoming active/inactive whenever the price crosses a tick boundary.
Miscalculation of the active liquidity can have disastrous consequences, as evidenced by the KyberSwap Elastic critical vulnerability disclosure, $56 million KyberSwap Elastic exploit (very well-explained here), and $4.4 million Raydium exploit. This could be due to rounding or incorrect state updates when crossing ticks during swaps, or modifying liquidity, for example resulting in double counting or other discrepancies that can be leveraged by an attacker.
In this example, active liquidity calculations appear to function as expected; however, for 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 incorrectly. As a result, the net liquidity is significantly smaller than expected which ultimately inflates the computed fee growth and allows for all rewards to be stolen by such an intentionally, or perhaps even inadvertently, malicious position.
Heuristic: are tick crossings handled correctly? Are there any edge cases, such as starting/ending exactly on a tick/word boundary, in which the logic breaks? 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 typical issues that are associated with and should be expected when dealing with native token support.
The first and likely most impactful point is that native token transfers are a source of potential reentrancy. This may be more 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 could 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 could occur when unable to receive dust balances redirected from a router contract.
In the worst case, if the capability is implemented but the msg.value is insufficiently validated then this could result in the native token balance becoming locked, as in L-22. This could also result in breaking the custom swap delta accounting, as in M-19.
Also note that it is not necessary to perform validation on currency1 as the native token can 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. This could include temporarily inflating liquidity positions to capture fees, incentives, or alter other core state before withdrawing immediately after, 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 JIT liquidity provision which 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:
All recipient and token addresses are correct.
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, though these errors can of course 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 and vice versa?
Custom incentives
The following is a list of various other interesting findings not directly related to Uniswap v4 hooks but rather 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 to 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. This design considerably lowers the barrier to AMM experimentation but equally raises the bar for sound and secure implementation, especially when customising the underlying concentrated liquidity mechanism. The overall attack surface is large and can be fairly nuanced, as hopefully demonstrated in this article. If you found it to be helpful, amplification and all feedback would be greatly appreciated!
