DEV Community

ohmygod
ohmygod

Posted on

Uniswap V4 Hook Security: 7 Attack Vectors That Already Cost DeFi $11M — and How to Defend Against Them

Uniswap V4's hook architecture is the most significant change to AMM design since concentrated liquidity. It's also the most dangerous. In May 2025, Cork Protocol lost $11 million because their hook's beforeSwap function lacked a single modifier. In March 2026, the z0r0z V4 Router lost $42K because inline assembly trusted a fixed calldata offset.

These aren't edge cases. They're the opening chapter of a new exploit category that will define DeFi security for years.

This article maps the seven most critical Uniswap V4 hook attack vectors, dissects real exploits, and provides concrete defense patterns every hook developer and auditor must know.


How V4 Hooks Change the Security Model

Uniswap V3's security model was simple: trust the protocol. The Router, Factory, and Pool contracts were audited monoliths. If Uniswap was secure, your integration was (mostly) secure.

V4 shatters this assumption. The new Singleton PoolManager delegates execution to arbitrary hook contracts at 14 different lifecycle points — before/after initialize, swap, add/remove liquidity, and donate. Hooks can modify accounting deltas, take custody of assets, and inject custom logic into every pool operation.

The security model becomes a three-party trust problem:

  1. Uniswap Labs secures the PoolManager
  2. Hook developers secure their custom code
  3. Users decide which hooks to trust

History shows that responsibility gaps between parties are where exploits live.


Attack Vector 1: Missing Access Control (The $11M Lesson)

Severity: Critical | Exploited in the wild: Cork Protocol ($11M)

The most devastating V4 hook vulnerability is also the simplest. Hook callback functions like beforeSwap, afterSwap, beforeAddLiquidity, etc., are meant to be called exclusively by the PoolManager. But nothing in the EVM enforces this — the developer must add the check.

The Cork Protocol Exploit

Cork Protocol built a depeg insurance platform on V4. Their CorkHook contract inherited from BaseHook, which provides an onlyPoolManager modifier. But they only applied it to unlockCallback — not to beforeSwap.

// VULNERABLE: No access control on beforeSwap
function beforeSwap(
    address sender,
    PoolKey calldata key,
    IPoolManager.SwapParams calldata params,
    bytes calldata hookData
) external override returns (bytes4, BeforeSwapDelta delta, uint24) {
    // Complex deposit/mint logic that trusts the caller
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The attacker:

  1. Created a new market using Cork's DS tokens as the Redemption Asset (cross-market token confusion)
  2. Deployed a malicious contract that called beforeSwap directly with crafted hookData
  3. Fooled Cork into crediting 3,761 weETH-DS tokens without any actual deposit
  4. Redeemed the fraudulently minted tokens for $11M in wstETH

Defense Pattern

// SECURE: Apply onlyPoolManager to ALL hook callbacks
function beforeSwap(
    address sender,
    PoolKey calldata key,
    IPoolManager.SwapParams calldata params,
    bytes calldata hookData
) external override onlyPoolManager returns (bytes4, BeforeSwapDelta delta, uint24) {
    // Now only the PoolManager can invoke this
}
Enter fullscreen mode Exit fullscreen mode

Audit checklist item: Every hook callback function MUST have the onlyPoolManager modifier. No exceptions. Verify all 14 possible hook entry points.


Attack Vector 2: Calldata Manipulation in Custom Routers

Severity: High | Exploited in the wild: z0r0z V4 Router ($42K)

The z0r0z V4 Router used inline assembly to verify that the payer in a swap matched msg.sender. The check relied on a fixed calldata offset to locate the payer field.

The problem: Solidity's ABI encoding for dynamic types (bytes, arrays) doesn't guarantee fixed offsets. An attacker crafted valid but non-standard calldata that:

  • Passed the ABI decoder's validation
  • Placed a victim's address at the expected offset
  • Put the attacker's address as the output recipient

The authorization check passed, the victim paid, and the attacker received the output.

Defense Pattern

// VULNERABLE: Fixed offset assumption
assembly {
    let payer := calldataload(0x44) // Assumes payer is always at offset 0x44
    if iszero(eq(payer, caller())) { revert(0, 0) }
}

// SECURE: Use Solidity's ABI decoding
function swap(SwapParams calldata params) external {
    require(params.payer == msg.sender, "Unauthorized payer");
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Rule: Never use hardcoded calldata offsets for authorization checks. Solidity's ABI decoder handles dynamic type encoding correctly — use it.


Attack Vector 3: Reentrancy Through Hook Callbacks

Severity: High | Multiple audit findings

V4's architecture reintroduces reentrancy risk that previous Uniswap versions largely eliminated. Hooks perform external calls during pool operations, creating reentry points.

Consider a hook that updates a reward balance in afterSwap:

// VULNERABLE: State update after external call
function afterSwap(
    address sender,
    PoolKey calldata key,
    IPoolManager.SwapParams calldata params,
    BalanceDelta delta,
    bytes calldata hookData
) external override onlyPoolManager returns (bytes4, int128) {
    // External call to reward token
    rewardToken.transfer(sender, calculateReward(delta));
    // State update AFTER external call
    totalRewardsDistributed += calculateReward(delta);
    return (this.afterSwap.selector, 0);
}
Enter fullscreen mode Exit fullscreen mode

If rewardToken is a malicious ERC-777 or has a callback, the attacker can reenter afterSwap before totalRewardsDistributed is updated, claiming rewards multiple times.

Defense Pattern

// SECURE: Checks-Effects-Interactions + reentrancy guard
uint256 private _locked = 1;
modifier nonReentrant() {
    require(_locked == 1, "Reentrant");
    _locked = 2;
    _;
    _locked = 1;
}

function afterSwap(...) external override onlyPoolManager nonReentrant returns (bytes4, int128) {
    uint256 reward = calculateReward(delta);
    // Effect BEFORE interaction
    totalRewardsDistributed += reward;
    // Interaction AFTER effect
    rewardToken.transfer(sender, reward);
    return (this.afterSwap.selector, 0);
}
Enter fullscreen mode Exit fullscreen mode

Attack Vector 4: Cross-Pool State Contamination

Severity: High | Found in audits

When a single hook contract serves multiple pools, insufficient state isolation allows a malicious pool to corrupt the state of legitimate pools.

// VULNERABLE: Shared state without pool isolation
mapping(address => uint256) public userBalances; // Shared across ALL pools

function beforeSwap(...) external override onlyPoolManager returns (...) {
    userBalances[sender] += someDelta; // Which pool? ALL of them.
}
Enter fullscreen mode Exit fullscreen mode

An attacker creates a worthless pool using the same hook, performs operations to inflate userBalances, then claims those balances from the legitimate pool.

Defense Pattern

// SECURE: Pool-isolated state
mapping(PoolId => mapping(address => uint256)) public userBalances;

function beforeSwap(
    address sender,
    PoolKey calldata key,
    ...
) external override onlyPoolManager returns (...) {
    PoolId poolId = key.toId();
    // Optionally: whitelist allowed pools
    require(allowedPools[poolId], "Unauthorized pool");
    userBalances[poolId][sender] += someDelta;
}
Enter fullscreen mode Exit fullscreen mode

Key insight: Always scope state variables by PoolId. Better yet, maintain an allowlist of pools the hook is designed to serve.


Attack Vector 5: Permission Encoding Mismatches

Severity: Medium-High | Multiple audit findings

V4 hook permissions are encoded in the least significant bits of the hook's deployment address. A mismatch between encoded permissions and implemented functions creates two failure modes:

  1. Permission set, function not implemented: Calls to the hook revert, causing DoS for all pool operations that trigger that hook point
  2. Function implemented, permission not set: The hook's logic is silently skipped, creating a false sense of security

Example of the second case: A hook implements afterSwap to collect protocol fees but deploys without AFTER_SWAP_RETURNS_DELTA_FLAG:

// The afterSwap logic exists but is NEVER called because
// the hook address doesn't encode the AFTER_SWAP_RETURNS_DELTA_FLAG
function afterSwap(...) external override onlyPoolManager returns (bytes4, int128) {
    // This fee collection code never executes
    int128 fee = calculateFee(delta);
    return (this.afterSwap.selector, fee);
}
Enter fullscreen mode Exit fullscreen mode

Defense Pattern

Always inherit from BaseHook (Uniswap's or OpenZeppelin's) and override getHookPermissions():

function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
    return Hooks.Permissions({
        beforeInitialize: false,
        afterInitialize: false,
        beforeAddLiquidity: true,
        afterAddLiquidity: false,
        beforeRemoveLiquidity: true,
        afterRemoveLiquidity: false,
        beforeSwap: true,
        afterSwap: true,
        beforeDonate: false,
        afterDonate: false,
        beforeSwapReturnDelta: false,
        afterSwapReturnDelta: true, // Don't forget this!
        afterAddLiquidityReturnDelta: false,
        afterRemoveLiquidityReturnDelta: false
    });
}
Enter fullscreen mode Exit fullscreen mode

The BaseHook constructor calls validateHookPermissions, which reverts if the deployed address doesn't match the declared permissions.


Attack Vector 6: Custom Accounting Delta Manipulation

Severity: Critical | Theoretical + audit findings

Hooks with BEFORE_SWAP_RETURNS_DELTA or AFTER_SWAP_RETURNS_DELTA permissions can modify the accounting deltas of a swap. This is immensely powerful — and a single rounding error or logic bug can create "free swaps" where zero input yields non-zero output.

// DANGEROUS: Custom accounting that modifies swap deltas
function beforeSwap(
    address sender,
    PoolKey calldata key,
    IPoolManager.SwapParams calldata params,
    bytes calldata hookData
) external override onlyPoolManager returns (bytes4, BeforeSwapDelta delta, uint24) {
    // If this calculation is wrong, tokens can be created from nothing
    int128 hookDelta = calculateCustomDelta(params);
    return (
        this.beforeSwap.selector,
        toBeforeSwapDelta(hookDelta, 0),
        0
    );
}
Enter fullscreen mode Exit fullscreen mode

Defense Pattern

  1. Invariant testing: Write Foundry invariant tests that verify sum(inputs) >= sum(outputs) across all hook operations
  2. Conservative rounding: Always round against the user (up for debits, down for credits)
  3. Delta bounds checking: Enforce maximum delta magnitudes proportional to pool liquidity
// Verify the accounting is balanced
int128 hookDelta = calculateCustomDelta(params);
require(
    hookDelta <= MAX_HOOK_DELTA && hookDelta >= -MAX_HOOK_DELTA,
    "Delta out of bounds"
);
Enter fullscreen mode Exit fullscreen mode

Attack Vector 7: Gas Griefing and Unbounded Operations

Severity: Medium | Multiple audit findings

A hook containing unbounded loops or expensive computations can consume all available gas, causing every transaction involving the pool to revert. This effectively bricks the pool and traps all deposited liquidity.

// VULNERABLE: Unbounded iteration
function afterSwap(...) external override onlyPoolManager returns (...) {
    // If feeRecipients grows large, this reverts every swap
    for (uint i = 0; i < feeRecipients.length; i++) {
        IERC20(token).transfer(feeRecipients[i], shares[i]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Defense Pattern

  1. Bound all loops with a maximum iteration count
  2. Use pull-over-push for distributions (let users claim rather than pushing to them)
  3. Gas estimation tests in CI to catch regressions
// SECURE: Pull pattern instead of push
mapping(address => uint256) public pendingFees;

function afterSwap(...) external override onlyPoolManager returns (...) {
    // O(1) operation: just accumulate
    totalFees += fee;
}

function claimFees() external {
    uint256 owed = pendingFees[msg.sender];
    pendingFees[msg.sender] = 0;
    IERC20(token).transfer(msg.sender, owed);
}
Enter fullscreen mode Exit fullscreen mode

The Hook Security Checklist

Before deploying or interacting with a V4 hook, verify:

# Check Severity
1 onlyPoolManager modifier on ALL hook callbacks Critical
2 No hardcoded calldata offsets for auth checks High
3 Reentrancy guards on functions with external calls High
4 State variables scoped by PoolId High
5 Hook permissions match implemented functions (use BaseHook) Medium
6 Custom accounting deltas are bounded and invariant-tested Critical
7 No unbounded loops; pull-over-push for distributions Medium
8 No upgradeable proxy without timelock + multisig Medium
9 Rate limiting on sensitive operations Medium
10 Comprehensive Foundry fuzz/invariant test suite High

Conclusion

Uniswap V4 hooks represent a paradigm shift in AMM composability — but they also represent a paradigm shift in attack surface. The Cork Protocol exploit proved that even audited code can fall to a missing modifier. The z0r0z incident showed that low-level optimization creates authorization gaps.

The pattern is clear: every hook is a new smart contract with the trust level of the protocol it's attached to. Treat hook development with the same rigor you'd apply to writing a new lending protocol or bridge. The $11M Cork exploit was just the beginning.

The developers who internalize these patterns now will build the infrastructure that survives. The ones who don't will fund the next BlockSec post-mortem.


DreamWork Security researches smart contract vulnerabilities, audit methodologies, and DeFi exploit patterns. Follow us for weekly deep dives into blockchain security.

Tags: #security #blockchain #defi #ethereum #uniswap #smartcontracts #web3 #solidity

Top comments (0)