58348 sc low zeroxswapverifier accepts malicious 0x calldata recipient not bound minout ignored transferfrom misused attacker can route strategy vault funds to self direct theft

Submitted on Nov 1st 2025 at 12:53:46 UTC by @manvi for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #58348

  • 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

While analysing ZeroXSwapVerifier, I observed that top-level fields (recipient, buyToken, minAmountOut) are not enforced and per-action recipients are not pinned to a safe sink (e.g., the owner/strategy).

I also observed that the verifier allows a raw transferFrom(token, owner, attacker, amount) action as long as token == targetToken.

I analysed three adversarial cases and the verifier returned true for all:

UniswapV3 VIP with recipient = attacker

TRANSFER_FROM with from = owner, to = attacker

basicSellToPool with recipient = attacker and minOut = 0

These behaviours collectively permit an attacker to craft calldata that passes verification and then steals funds when executed by the 0x Settler/Permit2 that holds the owner's allowance.

Vulnerability Details

In the top-level execute verification path (e.g., _verifyExecuteCalldata / _verifyExecuteMetaTxnCalldata), the verifier does not check:

saa.recipient == owner (or other allowed sink)

saa.buyToken == targetToken

saa.minAmountOut > 0

In action verifiers (e.g., _verifyUniswapV3VIP, _verifyTransferFrom, _verifySellToLiquidityProvider, etc.), the code does not bind the actio's recipient/to to owner and does not enforce a safe output target or meaningful slippage bound beyond a BPS cap.

For TRANSFER_FROM, the check effectively reduces to token == targetToken, allowing from = owner and to = attacker to pass.

Affected Code

src/utils/ZeroXSwapVerifier.sol: execute decoders and per-action verifiers (VIP/TRANSFER_FROM/etc.) do not bind recipients or enforce minAmountOut / buyToken invariants.

Attack Path / Scenario

Strategy/vault grants allowance to a 0x Settler or Permit2 (standard for swaps).

Attacker crafts 0x calldata that sets recipient = attacker (or encodes a raw transferFrom(owner, attacker, …)), and uses minOut = 0.

Calldata is passed through ZeroXSwapVerifier.verifySwapCalldata(...) and returns true.

The system proceeds to call the 0x Settler/Permit2 with that calldata; funds move to the attacker or are swapped with unlimited slippage.

Preconditions

The component that moves funds relies on ZeroXSwapVerifier to gate 0x calls.

The strategy/vault (or owner) has token allowance set for the 0x Settler/Permit2 (typical in these designs).

No additional external checks that bind recipient/minOut/buyToken at the integration layer.

Impact Details

Direct loss of user funds from strategies/vaults.

Depending on balances and automation, this could escalate towards protocol insolvency or widespread losses if repeatedly exploited.

References

Contract : src/utils/ZeroXSwapVerifier.sol (branch immunefi_audit)

Proof of Concept

I created a Foundry test file ZeroXSwapVerifier_Drain.t.sol.

My PoC builds malicious 0x actions and wraps them with an EXECUTE_SELECTOR payload. It then calls:

and asserts ok == true. All three adversarial tests PASS, proving the verifier accepts payloads that:

Direct outputs to attacker (arbitrary recipient),

Perform transferFrom(owner, attacker, amount), and

Use minOut = 0 with ignored recipient/buyToken at the top level.

What my POC does :

_wrapExecute uses new bytes for a single action and new bytes trailing array.

Tests that pass (vulnerable behaviour):

test_UniVIP_AllowsArbitraryRecipient_BUG()

test_TransferFrom_ToAttacker_BUG()

test_BasicSellToPool_IgnoresRecipientAndMinOut_BUG()

Content of my POC file :

run my POC file :

My console output :

what my POC proved :

The verifier accepts malicious calldata.

My tests call ZeroXSwapVerifier.verifySwapCalldata(evil, owner, address(token), 1000) and it returns true for payloads that should be rejected. That's the core bug.

Arbitrary recipient is allowed (UniswapV3 VIP).

test_UniVIP_AllowsArbitraryRecipient_BUG() shows I can set recipient = attacker inside the VIP action and the verifier still returns true. -> Proves: recipient isn't bound/pinned to the owner or another safe sink.

Direct transfer to attacker is allowed (TRANSFER_FROM).

test_TransferFrom_ToAttacker_BUG() encodes transferFrom(token, from=owner, to=attacker, amount) and wraps it in an accepted execute payload. Verifier returns true. -> Proves: no check on to, and no requirement that from == owner be the only allowed flow to a safe destination.

Slippage/minOut and outputs aren't enforced (basicSellToPool).

test_BasicSellToPool_IgnoresRecipientAndMinOut_BUG() uses minOut = 0 and recipient = attacker; verifier still returns true. -> Proves: minAmountOut / buyToken aren't enforced at the top level and action outputs aren't bound.

In a real integration, once a vault/strategy has given allowance to the 0x Settler/Permit2, any calldata that passes this verifier will be executed. Since my PoC shows the verifier accepts attacker-chosen recipients and raw transferFrom(owner->attacker), an attacker can steal funds or force unbounded slippage.

Was this helpful?