UniswapV4Router04 Calldata Offset Manipulation

On March 3, 2026 (block 24,575,085), the UniswapV4Router04 contract at 0x00000000000044a361ae3cac094c9d1b14eece97 on Ethereum mainnet was exploited via an authorization bypass vulnerability in its swap(bytes,uint256) function. The root cause is a hardcoded calldata offset in an inline assembly authorization check that assumes a canonical ABI encoding layout for the bytes parameter. By crafting calldata with a non-standard offset, an attacker bypassed the payer authorization check and drained 42,606.96 USDC (~21.198 ETH, ~$42,607 USD) from a victim address that had previously approved the router for USDC spending.

Root Cause

Vulnerable Contract

  • Name: UniswapV4Router04
  • Address: 0x00000000000044a361ae3cac094c9d1b14eece97
  • Proxy: No
  • Source type: Verified (Etherscan)
  • Compiler: Solidity 0.8.26 (solc-j, optimization 9999999 runs, cancun EVM)

Vulnerable Function

  • Name: swap
  • Signature: swap(bytes calldata data, uint256 deadline)
  • Selector: 0xaf2b4aba
  • File: src/UniswapV4Router04.sol

Vulnerable Code

// File: src/UniswapV4Router04.sol (lines 235-252)

/// @inheritdoc IUniswapV4Router04
function swap(bytes calldata data, uint256 deadline)
    public
    payable
    virtual
    override(IUniswapV4Router04)
    checkDeadline(deadline)
    setMsgSender
    returns (BalanceDelta)
{
    // equivalent to `require(abi.decode(data, (BaseData)).payer == msg.sender, Unauthorized())`
    assembly ("memory-safe") {
        if iszero(eq(calldataload(164), caller())) {  // <-- VULNERABILITY
            mstore(0x00, 0x82b42900) // `Unauthorized()`.
            revert(0x1c, 0x04)
        }
    }
    return _unlockAndDecode(data);
}

Why It’s Vulnerable

Expected behavior: The authorization check SHOULD verify that the payer field inside the BaseData struct (encoded within the bytes data parameter) equals msg.sender. This ensures that only the caller can spend their own tokens through the router.

Actual behavior: The assembly instruction calldataload(164) reads 32 bytes at the fixed absolute calldata position 164. This position only corresponds to BaseData.payer when the bytes data parameter begins at the standard ABI offset of 0x40 (64). However, ABI encoding permits the dynamic bytes parameter to start at any valid offset. The function passes data directly to _unlockAndDecode(), which decodes BaseData from wherever the offset actually points.

Why this matters: An attacker can craft calldata where:

  1. The bytes data offset is set to 0xc0 (192) instead of the standard 0x40 (64), shifting where the actual BaseData struct is located.
  2. The attacker’s own address is placed at absolute calldata position 164 (in the gap between the standard and actual offset), satisfying calldataload(164) == caller().
  3. The actual BaseData.payer field (now at calldata position 292) is set to the victim’s address.

The authorization check passes because it reads the attacker’s address at position 164, but the swap logic uses the victim’s address from the real BaseData.payer at position 292.

Normal flow vs Attack flow:

StepNormal swapAttacker’s crafted swap
Calldata offset to bytes data0x40 (standard)0xc0 (shifted by 128 bytes)
Value at calldata position 164BaseData.payer (msg.sender)Attacker’s address (placed in gap)
Actual BaseData.payer in decoded bytesmsg.senderVictim address 0x65a8...
Auth check resultPASS (correct)PASS (reads wrong position)
Who pays for the swapmsg.sender (intended)Victim (unintended)
Who receives swap outputmsg.sender’s chosen receiverAttacker’s EOA

Attack Execution

High-Level Flow

  1. Attacker EOA (0xd6b7...) deploys attacker contract (0xded2...).
  2. Attacker identifies victim (0x65a8...) who has approved the UniswapV4Router04 for unlimited USDC spending.
  3. Attacker contract checks the victim’s USDC balance (42,606.96 USDC) and allowance to the Router (unlimited).
  4. Attacker contract calls swap(bytes,uint256) on the Router with crafted calldata: the bytes offset is 0xc0 instead of the standard 0x40, the attacker’s address is at position 164 to pass the auth check, and the actual BaseData.payer is the victim’s address.
  5. The Router’s authorization check passes (calldataload(164) == msg.sender), then calls _unlockAndDecode(data).
  6. Inside the callback, the Router executes a swap on the ETH/USDC Uniswap V4 pool, swapping the victim’s 42,606.96 USDC for 21.198 ETH.
  7. The Router calls USDC.transferFrom(victim, PoolManager, 42606959179) to settle the input side.
  8. The Router calls PoolManager.take(ETH, attacker_EOA, 21.198 ETH) to deliver the output to the attacker.
  9. PoolManager sends 21.198 ETH directly to the attacker EOA.

Detailed Call Trace

EOA (0xd6b7...e831) -> AttackerContract (0xded2...74a0)
  CALL 0x30f5d90e(router, victim, [USDC])                      [depth 0]
  |
  +-> STATICCALL USDC.balanceOf(victim)                          [depth 1]
  |   +-> DELEGATECALL FiatTokenV2_2.balanceOf(victim)           [depth 2]
  |       returns: 42,606,959,179 (42,606.96 USDC)
  |
  +-> STATICCALL USDC.allowance(victim, Router)                  [depth 1]
  |   +-> DELEGATECALL FiatTokenV2_2.allowance(victim, Router)   [depth 2]
  |       returns: ~unlimited
  |
  +-> CALL Router.swap(bytes,uint256)  [0xaf2b4aba]             [depth 1]
      |   Auth check: calldataload(164) == caller() -> PASS
      |
      +-> CALL PoolManager.unlock(bytes)  [0x48c89491]          [depth 2]
          |
          +-> CALL Router.unlockCallback(bytes) [0x91dd7346]     [depth 3]
              |
              +-> CALL PoolManager.swap(                          [depth 4]
              |       key=(ETH,USDC,500,10,0x0),
              |       params=(zeroForOne=false, amount=-42606959179, sqrtLimit=MAX),
              |       hookData=empty)  [0xf3cd914c]
              |   returns: delta=(+21.198 ETH, -42606.96 USDC)
              |
              +-> CALL PoolManager.sync(USDC)  [0xa5841194]      [depth 4]
              |   +-> STATICCALL USDC.balanceOf(PoolManager)      [depth 5]
              |       +-> DELEGATECALL FiatTokenV2_2.balanceOf()  [depth 6]
              |
              +-> CALL USDC.transferFrom(                         [depth 4]
              |       victim, PoolManager, 42606959179)
              |       [0x23b872dd]
              |   +-> DELEGATECALL FiatTokenV2_2.transferFrom()   [depth 5]
              |       returns: true
              |
              +-> CALL PoolManager.settle()  [0x11da60b4]        [depth 4]
              |   +-> STATICCALL USDC.balanceOf(PoolManager)      [depth 5]
              |       +-> DELEGATECALL FiatTokenV2_2.balanceOf()  [depth 6]
              |   returns: 42606959179 (confirmed settlement)
              |
              +-> CALL PoolManager.take(                          [depth 4]
                      ETH, attacker_EOA, 21197984596759249607)
                      [0x0b0d9c09]
                  +-> CALL attacker_EOA (value: 21.198 ETH)       [depth 5]

Financial Impact

  • Total loss: 42,606.959179 USDC swapped to 21.197984596759249607 ETH (~$42,607 USD at the implied pool price of ~$2,010/ETH).
  • Victim: Address 0x65a8f07bd9a8598e1b5b6c0a88f4779dbc077675 lost their entire USDC balance (42,606.96 USDC) held in their wallet.
  • Attacker profit: 21.198 ETH received by EOA 0xd6b7e831d64e573278f091aa7e68fbf2a8fa9916, minus negligible gas costs (~0.000009 ETH). Net profit: 21.198 ETH ($42,607 USD).
  • Who lost funds: The victim had previously granted an unlimited USDC approval to the UniswapV4Router04. The attacker exploited the router’s authorization flaw to spend the victim’s approved USDC and redirect the swap output to themselves.
  • Protocol impact: The Uniswap V4 PoolManager and pool liquidity are unaffected. The swap executed at fair market price. The loss falls entirely on the victim who had approved the vulnerable router. Any other address with outstanding USDC (or other ERC-20) approvals to this router remains at risk.

Evidence

Authorization bypass proof – The calldata to swap(bytes,uint256) uses offset 0xc0 (192) for the bytes parameter instead of the standard 0x40 (64):

  • Calldata position 4-35 (word 0, bytes 4..35 inclusive): 0x00...00c0 – offset to bytes data = 192 (non-standard)
  • Calldata position 164-195 (word 5, bytes 164..195 inclusive): 0x00...ded262d0a933b7bb4ed8c4b6cb2dce5b157b74a0 – attacker contract address (read by calldataload(164))
  • Calldata position 228 (bytes data start): BaseData begins here
  • Calldata position 292 (BaseData.payer): 0x00...65a8f07bd9a8598e1b5b6c0a88f4779dbc077675 – victim address

USDC Transfer event (log index 662):

  • Topic 0: 0xddf252ad... (Transfer)
  • From: 0x65a8f07bd9a8598e1b5b6c0a88f4779dbc077675 (victim)
  • To: 0x000000000004444c5dc75cb358380d2e3de08a90 (PoolManager)
  • Amount: 42,606,959,179 (42,606.96 USDC, 6 decimals)

ETH transfer (trace depth 5):

  • From: PoolManager 0x000000000004444c5dc75cb358380d2e3de08a90
  • To: Attacker EOA 0xd6b7e831d64e573278f091aa7e68fbf2a8fa9916
  • Value: 21,197,984,596,759,249,607 wei (21.198 ETH)

Swap event (PoolManager, log index 661):

  • Pool ID: 0x21c67e77068de97969ba93d4aab21826d33ca12bb9f565d8496e8fda8a82ca27 (ETH/USDC, fee=500, tickSpacing=10)
  • amount0: +21,197,984,596,759,249,607 (21.198 ETH out)
  • amount1: -42,606,959,179 (42,606.96 USDC in)
  • Post-swap tick: -200,189
  • Liquidity: 125,394,560,008,241,002

Receipt status: 0x1 (success)

Selector verification (all confirmed via cast sig):

  • 0xaf2b4aba = swap(bytes,uint256)
  • 0x48c89491 = unlock(bytes)
  • 0x91dd7346 = unlockCallback(bytes)
  • 0xf3cd914c = swap((address,address,uint24,int24,address),(bool,int256,uint160),bytes)
  • 0xa5841194 = sync(address)
  • 0x23b872dd = transferFrom(address,address,uint256)
  • 0x11da60b4 = settle()
  • 0x0b0d9c09 = take(address,address,uint256)