Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
The ZeroXSwapVerifier contract is supposed to validate 0x swap calldata before execution, ensuring only authorized transactions are processed. However, it fails to verify that the from address in the calldata matches the intended owner. This allows anyone controlling the calldata (relayers, aggregators, or malicious frontends) to craft payloads that drain tokens from any user who has approved the 0x router, even if that user never initiated the transaction. The result is direct theft of funds without any special privileges.
Vulnerability Details
The verifySwapCalldata() function validates that the target token matches expectations but completely ignores who the tokens are being transferred from:
functionverifySwapCalldata(bytescalldataswapCalldata,addressowner,addresstargetToken,uint256amount)externalviewreturns(bool){// Decodes the calldata to extract transfer details(address token,address from,address to,uint256 value)=decodeAction(swapCalldata);// Checks token matchesrequire(token == targetToken,"invalid token");// ❌ MISSING: require(from == owner, "unauthorized source");returntrue;}
The function receives an owner parameter that should represent the authorized token source, but this parameter is never compared against the from address decoded from the calldata.
Here's the problem flow:
Victim approves 0x router (standard practice for any dApp using 0x swaps - via approve() or Permit2)
Attacker crafts malicious calldata with from = victim, to = attacker, amount = 500 ETH
Verifier checks token only and returns true because target token matches
0x executor processes the calldata and calls transferFrom(victim, attacker, 500)
Transfer succeeds because victim has existing approval
Critical note: This attack requires no admin or governance permissions. The only prerequisite is that the victim has granted an allowance (extremely common), and the attacker controls the calldata submitted to verifySwapCalldata (trivial for any relayer, aggregator, or malicious frontend).
The validator provides a false sense of security. It appears to gate the swap execution, but in reality allows arbitrary token sources as long as the token address matches.
Key insight: The owner parameter is passed in but never used in validation. This is the smoking gun - there's no point passing owner unless it's meant to be checked, but the check is simply missing.
Impact Details
This vulnerability enables:
Direct theft from approved users: Anyone who approved 0x for legitimate swaps can have their tokens stolen
No user interaction required: Victims don't need to sign any transaction or interact with the protocol
Scalable attacks: Attacker can drain all approved users systematically
Frontrunning protection bypass: Even if users think they're protected by the verifier, they're not
The financial impact is severe:
Scope: Every user who has approved 0x or Permit2 (extremely common)
Amount: Up to full approved balance per victim
Frequency: Can be executed repeatedly across all approved users
TVL at risk: Potentially millions if integrated into production
Attack requirements:
No admin or governance permissions required
Single condition: Victim must have previously granted allowance (via approve or Permit2) to the 0x router/executor - this is extremely common for any user interacting with 0x swaps
Attacker capability: Control over the calldata submitted to verifySwapCalldata (achievable by any relayer, aggregator, or malicious frontend)
No special privileges, governance, or admin access needed
For a protocol with 10,000 users who each approved $10,000 worth of tokens, total exposure is $100M. The attack is executable in a single transaction per victim.
References
Contract: src/utils/ZeroXSwapVerifier.sol
Commit: a192ab313c81ba3ab621d9ca1ee000110fbdd1e9
Test: test/ZeroXSwapVerifierBypass.t.sol
Proof of Concept
Proof of Concept
Save as test/ZeroXSwapVerifierBypass.t.sol:
Run:
Test Result:
Key observations from traces:
Verifier check passes: ZeroXSwapVerifier::verifySwapCalldata() returns true even though:
The owner parameter is 0xCAFE (passed to function)
The actual from address in calldata is 0xBEEF (victim)
These addresses don't match, but no validation occurs
Action selector whitelist to prevent unexpected function calls
The fix is straightforward, but the vulnerability's impact is severe. Without this check, the verifier provides zero protection against unauthorized token transfers.