EIP-1014 Vulnerability Patterns
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
NapierRouteris prone to collision attacks (issue-111.md)v1-pool/src/NapierRouter.solBusiness 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, swapCallbackVulnerable 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 |
InfiniteSec