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:
- The
bytes dataoffset is set to0xc0(192) instead of the standard0x40(64), shifting where the actualBaseDatastruct is located. - 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(). - The actual
BaseData.payerfield (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:
| Step | Normal swap | Attacker’s crafted swap |
|---|---|---|
Calldata offset to bytes data | 0x40 (standard) | 0xc0 (shifted by 128 bytes) |
| Value at calldata position 164 | BaseData.payer (msg.sender) | Attacker’s address (placed in gap) |
Actual BaseData.payer in decoded bytes | msg.sender | Victim address 0x65a8... |
| Auth check result | PASS (correct) | PASS (reads wrong position) |
| Who pays for the swap | msg.sender (intended) | Victim (unintended) |
| Who receives swap output | msg.sender’s chosen receiver | Attacker’s EOA |
Attack Execution
High-Level Flow
- Attacker EOA (
0xd6b7...) deploys attacker contract (0xded2...). - Attacker identifies victim (
0x65a8...) who has approved the UniswapV4Router04 for unlimited USDC spending. - Attacker contract checks the victim’s USDC balance (42,606.96 USDC) and allowance to the Router (unlimited).
- Attacker contract calls
swap(bytes,uint256)on the Router with crafted calldata: thebytesoffset is0xc0instead of the standard0x40, the attacker’s address is at position 164 to pass the auth check, and the actualBaseData.payeris the victim’s address. - The Router’s authorization check passes (
calldataload(164) == msg.sender), then calls_unlockAndDecode(data). - 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.
- The Router calls
USDC.transferFrom(victim, PoolManager, 42606959179)to settle the input side. - The Router calls
PoolManager.take(ETH, attacker_EOA, 21.198 ETH)to deliver the output to the attacker. - 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
0x65a8f07bd9a8598e1b5b6c0a88f4779dbc077675lost 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 bycalldataload(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)