Solidity's Hidden Flexibility: How ABI Encoding Assumptions Led to an Exploit
UniswapV4Router04 Exploit
On March 3, 2026, a vulnerability was exploited in the UniswapV4Router04 contract deployed by z0r0z.eth on Ethereum, resulting in an approximate loss of $42.1K. The attacker exploited a hardcoded calldata offset in an inline assembly authorization check, bypassing the payer verification and draining USDC from a victim who had previously approved the router.
Overview
Vulnerable Contract: 0x00000000000044a361ae3cac094c9d1b14eece97
Exploit TX: 0xfe34c4beee447de536bbd3d613aa0e3aa7eeb63832e9453e4ef3999924ab466a
Exploit Analysis
The UniswapV4Router04 provides multiple swap functions. Most of these functions safely hardcode payer: msg.sender into the BaseData struct, ensuring only the caller’s tokens can be spent. However, one overload - swap(bytes calldata data, uint256 deadline) - accepts pre-encoded data directly from the caller and uses inline assembly for authorization. This is where the vulnerability lies.
The Vulnerable Function
The swap(bytes,uint256) function contains an inline assembly authorization check intended to verify that the payer field inside the encoded BaseData struct equals msg.sender:
The comment says it is “equivalent to require(abi.decode(data, (BaseData)).payer == msg.sender, Unauthorized())“ - but it is not.
Why calldataload(164)?
The developer assumed the standard (canonical) ABI encoding layout for swap(bytes,uint256). Under that assumption, the calldata is structured as:
| Byte Offset | Size | Content |
|---|---|---|
| `0–3` | 4 bytes | Function selector |
| `4–35` | 32 bytes | Offset to `bytes data` (assumed `0x40` = 64) |
| `36–67` | 32 bytes | `uint256 deadline` |
| `68–99` | 32 bytes | Length of `bytes data` |
| `100–131` | 32 bytes | `BaseData.amount` |
| `132–163` | 32 bytes | `BaseData.amountLimit` |
| **`164–195`** | **32 bytes** | **`BaseData.payer`** ← `calldataload(164)` reads here |So calldataload(164) reads exactly BaseData.payer - but only when the bytes data parameter starts at the standard offset of 0x40 (64).
ABI Encoding Permits Non-Standard Offsets
According to the Solidity ABI Specification, dynamic types like bytes are not encoded in-place. Instead, the head part contains an offset pointer to where the data actually begins:
“For dynamic types, head(X(i)) is the offset of the beginning of tail(X(i)) relative to the start of enc(X).”
The Solidity ABI decoder follows this offset pointer to locate the data. Critically, the ABI specification does not require the offset to be the minimum possible value. From the Strict Encoding Mode section:
“Strict encoding mode is the mode that leads to exactly the same encoding as defined in the formal specification above. This means that offsets have to be as small as possible while still not creating overlaps in the data areas, and thus no gaps are allowed.”
“Usually, ABI decoders are written in a straightforward way by just following offset pointers, but some decoders might enforce strict mode. The Solidity ABI decoder currently does not enforce strict mode, but the encoder always creates data in strict mode.”
This means the Solidity decoder happily accepts calldata where the bytes data parameter starts at any valid offset - not just 0x40. An attacker can set the offset to a larger value, shifting where the actual BaseData struct is located in the calldata, while placing their own address at the fixed position 164.
The Disconnect
After the assembly check passes, the function calls _unlockAndDecode(data):
Inside the unlock callback, the data is decoded using Solidity’s standard abi.decode:
This abi.decode correctly follows the offset pointer and reads BaseData from wherever data actually points - which, in the attacker’s crafted calldata, is at a different location than position 164.
Attack Flow
The attacker constructs calldata for swap(bytes,uint256) with a non-standard offset for the bytes data parameter:
Position 164: Contains the attacker’s own address → passes the
calldataload(164) == caller()assembly check.Actual
bytes data: Located at a different offset, contains aBaseDatastruct withpayerset to the victim’s address.
When the swap executes, the victim’s tokens are used for the input settlement. Since the victim had previously approved the router contract for USDC spending, the router successfully transfers USDC from the victim to complete the swap, sending the output tokens to the attacker’s chosen receiver.
Conclusion
This exploit demonstrates the danger of using hardcoded calldata offsets in inline assembly for authorization checks. The EVM’s ABI encoding is more flexible than developers often assume - dynamic types like bytes can have their data placed at arbitrary offsets, and the Solidity ABI decoder does not enforce strict encoding mode. Any security check that relies on a fixed calldata position will fail when confronted with non-standard but perfectly valid ABI encoding.
Furthermore, conducting a security audit is strongly recommended for all projects, even though they are smart contracts, backends, wallets, or dapps.




