EIP-1014 Vulnerability Patterns

December 17, 2025

EIP-1014 introduces the CREATE2 opcode, enabling deterministic contract address computation before deployment. Unlike CREATE (which depends on deployer nonce), CREATE2 addresses are derived purely from deployment parameters, allowing pre-computation and enabling patterns like counterfactual instantiation.

This article analyzes 1 EIP-1014 vulnerability pattern with a real audit case to help you understand the security implications of CREATE2 address computation.

EIP-1014 Overview

Address Computation Formula

address = keccak256(0xff ++ deployer ++ salt ++ keccak256(init_code))[12:]
Component Description
0xff Fixed prefix to distinguish from CREATE
deployer Address of the contract calling CREATE2
salt 32-byte value chosen by deployer
init_code Contract creation bytecode
[12:] Take last 20 bytes (160 bits) of 256-bit hash

The Truncation Problem

+------------------------------------------------------------------+
|                    Address Truncation                             |
+------------------------------------------------------------------+

keccak256 output:    256 bits
                     ████████████████████████████████
                     │                              │
                     │  Discarded (96 bits)         │  Kept (160 bits)
                     │                              │
                     └──────────────┬───────────────┘
                                    │
                                    ▼
Ethereum address:                  160 bits
                                   ████████████████████

Collision Space: 2^96 different inputs can produce the same address!
                 ≈ 79,228,162,514,264,337,593,543,950,336 possibilities

CREATE vs CREATE2

┌─────────────────────────────────────────────────────────────────────┐
│                         CREATE (0xf0)                               │
├─────────────────────────────────────────────────────────────────────┤
│  address = keccak256(rlp([deployer, nonce]))[12:]                   │
│                                                                     │
│  • Depends on deployer's nonce (transaction count)                  │
│  • Address changes if any prior transaction changes                 │
│  • Cannot pre-compute address reliably                              │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│                        CREATE2 (0xf5)                               │
├─────────────────────────────────────────────────────────────────────┤
│  address = keccak256(0xff ++ deployer ++ salt ++ keccak256(code))[12:]│
│                                                                     │
│  • Deterministic: same inputs → same address                        │
│  • Can pre-compute address before deployment                        │
│  • Enables counterfactual instantiation & state channels            │
│  • ⚠️ 160-bit truncation creates collision risk                     │
└─────────────────────────────────────────────────────────────────────┘

Common Use Cases

Use Case Description
Counterfactual Instantiation Reference contract address before it exists
State Channels Pre-compute adjudicator contract addresses
Factory Patterns Deterministic pool/pair addresses (Uniswap V2/V3)
Upgradeable Proxies Predictable proxy addresses

Vulnerability Patterns

1. CREATE2 Address Collision Attack

Author’s Note: Although this vulnerability pattern has been accepted as valid in some audit competitions, the economic cost of executing such an attack is prohibitively expensive. I find it difficult to consider this a practical threat.

Prerequisite Knowledge:

EIP-1014 (CREATE2) specifies that contract addresses are calculated as keccak256(0xff ++ address ++ salt ++ keccak256(init_code))[12:], which truncates a 256-bit hash to 160 bits. This truncation creates a theoretical collision space where 2^96 different pre-images can hash to the same 160-bit address.

Root Cause:

The router assumes that a CREATE2 address computed from specific parameters is unique and can be used as a reliable identifier. This assumption is violated because address truncation from 256 to 160 bits creates a large collision space, allowing attackers to find different parameters that compute to the same address as a legitimate pool.

Attack Vector:

An attacker can find a malicious (basePool, underlying) pair that computes to the same address as a legitimate pool. By calling the callback functions with these parameters, they can pass the verification check and steal all token allowances approved to the router contract.

Source:

  • [sherlock/2024-01-napier] The pool verification in NapierRouter is prone to collision attacks (issue-111.md)
    • v1-pool/src/NapierRouter.sol

      Business Context: Napier Protocol Principal Token Trading

      ┌─────────────────────────────────────────────────────────────────────────┐
      │                    WHAT IS NAPIER PROTOCOL?                             │
      └─────────────────────────────────────────────────────────────────────────┘
      
      Napier is a fixed-income DeFi protocol (similar to Pendle) that splits
      yield-bearing assets into Principal Tokens (PT) and Yield Tokens (YT).
      
      Example: Splitting 100 stETH (1-year maturity)
      ┌─────────────────────────────────────────────────────────────────────────┐
      │  100 stETH  ───split───►  100 PT-stETH  +  100 YT-stETH                 │
      │                                                                         │
      │  PT (Principal Token):                                                  │
      │      • Redeemable for 100 stETH principal at maturity                   │
      │      • Trades at discount (you wait for maturity)                       │
      │                                                                         │
      │  YT (Yield Token):                                                      │
      │      • Receives all yield generated during the period                   │
      │      • Worth zero after maturity                                        │
      └─────────────────────────────────────────────────────────────────────────┘
      
      ┌─────────────────────────────────────────────────────────────────────────┐
      │                    WHY NEED A ROUTER?                                   │
      └─────────────────────────────────────────────────────────────────────────┘
      
      Direct Pool interaction is complex. Router simplifies user operations:
      
      Without Router (complex):
      ┌──────────┐                                    ┌──────────┐
      │   User   │  ─── complex multi-step ops ────►  │   Pool   │
      └──────────┘                                    └──────────┘
      
      With Router (simple):
      ┌──────────┐     simple call      ┌──────────┐    handles    ┌──────────┐
      │   User   │ ─────────────────►   │  Router  │ ───────────►  │   Pool   │
      └──────────┘                      └──────────┘   complexity  └──────────┘
      
      ┌─────────────────────────────────────────────────────────────────────────┐
      │                    WHAT IS NAPIERPOOL?                                  │
      └─────────────────────────────────────────────────────────────────────────┘
      
      NapierPool is an AMM (Automated Market Maker) liquidity pool that stores
      two types of assets and enables trading between them:
      
      ┌─────────────────────────────────────────────────────────────────────────┐
      │                         NapierPool                                      │
      │  ┌─────────────────────────┐    ┌─────────────────────────┐             │
      │  │       1,000,000         │    │        1,100,000        │             │
      │  │         USDC            │    │        PT-stETH         │             │
      │  │     (underlying)        │    │    (principal token)    │             │
      │  └─────────────────────────┘    └─────────────────────────┘             │
      │                                                                         │
      │  Pool Assets:                                                           │
      │    • Underlying: The base asset (USDC, WETH, etc.)                      │
      │    • PT: Principal Token representing future principal                  │
      │                                                                         │
      │  Pool Function:                                                         │
      │    • Swap underlying ↔ PT (like Uniswap swaps token A ↔ token B)        │
      │    • Price determined by constant product formula (x * y = k)           │
      │    • Liquidity providers earn trading fees                              │
      └─────────────────────────────────────────────────────────────────────────┘
      
      Why need a Pool?
      
      Users want to:
        1. Buy PT at discount → Lock in fixed yield (buy 95 USDC worth of PT,
           redeem 100 USDC at maturity = 5.26% fixed return)
        2. Sell PT before maturity → Exit position early
        3. Arbitrage price differences
      
      Pool provides the market for these trades:
      ┌──────────┐                                      ┌──────────────┐
      │  Trader  │  ── 95 USDC ──────────────────────►  │              │
      │          │                                      │   NapierPool │
      │          │  ◄── 100 PT-stETH ─────────────────  │              │
      └──────────┘                                      └──────────────┘
      
      ┌─────────────────────────────────────────────────────────────────────────┐
      │                    FLASH-SWAP CALLBACK MECHANISM                        │
      └─────────────────────────────────────────────────────────────────────────┘
      
      Pool uses flash-swap pattern (like Uniswap V3):
      
      User calls: Router.swapUnderlyingForPt(pool, 100 USDC, ...)
      
      Step 1: Router calls Pool
      ┌──────────┐                    ┌──────────┐
      │  Router  │ ── pool.swap() ──► │   Pool   │
      └──────────┘                    └──────────┘
      
      Step 2: Pool sends PT to Router FIRST (before receiving payment!)
      ┌──────────┐                    ┌──────────┐
      │  Router  │ ◄── 100 PT ──────  │   Pool   │
      └──────────┘                    └──────────┘
      
      Step 3: Pool immediately calls back Router, demanding payment
      ┌──────────┐                    ┌──────────┐
      │  Router  │ ◄─ swapCallback()─ │   Pool   │
      │          │   "Pay me 100 USDC"│          │
      └──────────┘                    └──────────┘
      
      Step 4: Router transfers USDC from user to Pool inside callback
      ┌──────────┐                    ┌──────────┐
      │  Router  │ ── 100 USDC ────►  │   Pool   │
      │          │  (from user's      │          │
      │          │   approved balance)│          │
      └──────────┘                    └──────────┘
      
      ┌─────────────────────────────────────────────────────────────────────────┐
      │                    THE CALLBACK VERIFICATION PROBLEM                    │
      └─────────────────────────────────────────────────────────────────────────┘
      
      Problem: ANYONE can call swapCallback()!
      
      ┌─────────────┐
      │  Malicious  │ ─── swapCallback("give me user's money") ───► Router
      │  Contract   │
      └─────────────┘
      
      Router's verification logic:
      function swapCallback(data) {
          // Decode basePool and underlying from data
          (basePool, underlying) = decode(data);
      
          // Compute "which Pool address should this be?"
          expectedPool = computeAddress(basePool, underlying);
      
          // Check if caller equals computed address
          require(msg.sender == expectedPool);  // ← VULNERABILITY HERE!
      
          // If passed, execute transfer
          transferFrom(victim, msg.sender, amount);
      }
      
      ┌─────────────────────────────────────────────────────────────────────────┐
      │                         THE ATTACK                                      │
      └─────────────────────────────────────────────────────────────────────────┘
      
      Prerequisite: Alice approved 1000 USDC to Router (normal usage requires approval)
      
      Attacker's Goal:
        Find (fakeBasePool, fakeUnderlying) such that
        computeAddress(fakeBasePool, fakeUnderlying) == attacker's contract address
      
      Why is this possible?
        computeAddress returns 160-bit address
        But input space is 256 bits
        So 2^96 different inputs can map to the same address (collision)
      
      Attack Steps:
      ┌─────────────────┐                           ┌──────────────┐
      │  Attacker       │                           │    Router    │
      │  Contract       │                           │              │
      │  addr: 0xABC    │                           │              │
      └────────┬────────┘                           └──────┬───────┘
               │                                           │
               │  swapCallback(data = {                    │
               │    basePool: fakeBasePool,               │
               │    underlying: fakeUnderlying,           │
               │    payer: Alice,     ← VICTIM!           │
               │    amount: 1000 USDC                     │
               │  })                                      │
               │ ─────────────────────────────────────────►
               │                                          │
               │                     computeAddress(fakeBasePool, fakeUnderlying)
               │                     = 0xABC == msg.sender ✓ Verification passes!
               │                                          │
               │                     transferFrom(Alice, 0xABC, 1000 USDC)
               │                     Alice's funds stolen!│
               │ ◄─────────────────────────────────────────
               │                                          │
      ┌────────▼────────┐                                 │
      │  Attacker gets  │                                 │
      │  1000 USDC      │                                 │
      └─────────────────┘                                 │
      
      ┌─────────────────────────────────────────────────────────────────────────┐
      │                         ROOT CAUSE                                      │
      └─────────────────────────────────────────────────────────────────────────┘
      
      Router only verifies: computeAddress(params) == msg.sender
      
      Problem: params are provided by the caller! Attacker can:
        1. Choose their own contract address first
        2. Find params that produce that address (collision)
        3. Call callback with those params
      
      Correct approach should be:
        require(factory.isRegisteredPool(msg.sender))
        Check if msg.sender is in factory's registry
      
      ┌─────────────────────────────────────────────────────────────────────────┐
      │                         IMPACT                                          │
      └─────────────────────────────────────────────────────────────────────────┘
      
      • All users who approved tokens to NapierRouter are at risk
      • Attacker can drain ANY token that users approved to the router
      • No user interaction required after initial approval
      • Affects ALL callback functions: mintCallback, swapCallback

      Vulnerable Code:

      // PoolAddress.sol - Address computation
      library PoolAddress {
          function computeAddress(address basePool, address underlying, bytes32 initHash, address factory)
              internal pure returns (INapierPool pool)
          {
              bytes32 salt;
              assembly {
                  mstore(0x00, shr(96, shl(96, basePool)))
                  mstore(0x20, shr(96, shl(96, underlying)))
                  salt := keccak256(0x00, 0x40)  // salt = keccak256(basePool, underlying)
              }
              pool = INapierPool(Create2.computeAddress(salt, initHash, factory));
          }
      }
      
      // NapierRouter.sol - Vulnerable verification
      function _verifyCallback(address basePool, address underlying) internal view {
          if (
              PoolAddress.computeAddress(basePool, underlying, POOL_CREATION_HASH, address(factory))
                  != INapierPool(msg.sender)  // VULNERABILITY: Only checks computed address matches caller
          ) revert Errors.RouterCallbackNotNapierPool();
      }
      
      function swapCallback(int256 underlyingDelta, int256 ptDelta, bytes calldata data) external override {
          (address underlying, address basePool) = abi.decode(data[0x20:0x60], (address, address));
          _verifyCallback(basePool, underlying);  // Attacker controls basePool & underlying!
      
          CallbackType _type = CallbackDataTypes.getCallbackType(data);
          if (_type == CallbackType.SwapUnderlyingForPt) {
              CallbackDataTypes.SwapUnderlyingForPtData memory params =
                  abi.decode(data[0x60:], (CallbackDataTypes.SwapUnderlyingForPtData));
              // VULNERABILITY: Attacker can set params.payer to victim's address
              _pay(underlying, params.payer, msg.sender, uint256(-underlyingDelta));
          }
          // ...
      }
      

EIP-1014 Audit Checklist

When auditing contracts that use CREATE2 address computation for verification, ensure you check these items:

# Check Item Related Pattern
Is callback caller verified via registry? Use factory.isPool(msg.sender) instead of computed address comparison Pattern 1
Are user-controlled parameters used in address computation? If attacker controls inputs to computeAddress(), they may find collisions Pattern 1
Is the verification checking the right thing? Verify the caller IS a registered pool, not that caller COULD BE a pool Pattern 1