DEV Community

ohmygod
ohmygod

Posted on

Calldata Injection: The $17M Vulnerability Pattern Hiding in Every DeFi Router

In January 2026, two DeFi protocols — SwapNet and Aperture Finance — lost a combined $17 million to the same vulnerability class: arbitrary calldata injection. Two months later, the z0r0z V4 Router (a community Uniswap V4 router) disclosed a similar flaw. The pattern is consistent: a contract holds user approvals and exposes a function that makes low-level calls with attacker-controlled data.

This isn't a novel attack. It's a well-known anti-pattern that keeps shipping to production because teams don't validate call targets and selectors in aggregator and router contracts.

Let's break down exactly how it works, why it's so deadly, and how to build contracts that are immune to it.


The Anatomy of a Calldata Injection Attack

The Setup

DeFi routers and aggregators need broad permissions to function. Users approve tokens via ERC20.approve() or Permit2, granting the router contract the ability to move their tokens. This is by design — the router needs to pull tokens, route them through pools, and return the output.

The problem starts when a contract exposes a function like this:

// ❌ VULNERABLE: User-controlled low-level call
function swap(address target, bytes calldata data) external {
    // Some minimal validation...
    (bool success, ) = target.call(data);
    require(success, "Call failed");
}
Enter fullscreen mode Exit fullscreen mode

The contract intends target to be a DEX pool or another router. But nothing enforces that. An attacker can set target to any ERC20 token address and data to a transferFrom() call that moves the victim's approved tokens to the attacker.

The SwapNet Attack (January 25, 2026)

SwapNet's vulnerable function 0x87395540() accepted user-supplied parameters that were passed directly into low-level calls. The attack flow:

  1. Attacker identifies users who granted unlimited approvals to SwapNet contracts
  2. Crafts calldata replacing the expected pool address with a token address (e.g., USDC)
  3. The data payload encodes transferFrom(victim, attacker, amount)
  4. SwapNet's contract obligingly makes the call — the token contract sees the call coming from an approved spender
  5. Tokens flow directly from victim to attacker

One user lost $13.34 million in a single transaction. Total SwapNet losses: $16.8M across Ethereum, Arbitrum, Base, and BNB Chain.

The Aperture Finance Attack (January 26, 2026)

Aperture Finance's function 0x67b34120() had the same flaw — executing low-level calls with user-supplied calldata without constraining the target or function selector. The attacker drained $3.67M in ERC20 tokens and even siphoned approved Uniswap V3 position NFTs.

The z0r0z V4 Router Disclosure (March 2026)

The deprecated z0r0z/v4-router deployment at 0x00000000000044a361Ae3cAc094c9D1b14Eece97 included Permit2 integration with low-level encoded swap methods (swap(bytes,uint256) and fallback()) that exposed "potential allowance-penetration vectors." The fixed version removed these entry points entirely.


Why This Pattern Is So Dangerous

1. Approvals Are Persistent

Most DeFi users grant unlimited approvals (type(uint256).max) to save gas on repeated interactions. These approvals persist indefinitely. An approval granted a year ago to a now-vulnerable contract is still exploitable today.

2. The Attack Is Silent

Unlike flash loan attacks that require capital, calldata injection requires zero upfront cost. The attacker just needs to find one victim with an active approval and craft the right calldata. No MEV competition, no front-running risk — just a clean transferFrom.

3. Closed-Source Contracts Hide the Flaw

Both SwapNet and Aperture Finance were closed-source. The vulnerability wasn't caught by public auditors or bug bounty hunters because the code wasn't visible. By the time BlockSec reverse-engineered the contracts post-exploit, $17M was already gone.


The Fix: Defense-in-Depth for Router Contracts

Layer 1: Whitelist Call Targets

Never allow arbitrary call targets. Maintain an explicit registry of approved targets:

// ✅ SAFE: Whitelisted targets only
mapping(address => bool) public approvedTargets;

function swap(address target, bytes calldata data) external {
    require(approvedTargets[target], "Target not whitelisted");
    (bool success, ) = target.call(data);
    require(success, "Call failed");
}
Enter fullscreen mode Exit fullscreen mode

Layer 2: Restrict Function Selectors

Even with whitelisted targets, restrict which functions can be called:

// ✅ SAFER: Whitelisted targets + allowed selectors
mapping(address => mapping(bytes4 => bool)) public allowedCalls;

function swap(address target, bytes calldata data) external {
    bytes4 selector = bytes4(data[:4]);
    require(allowedCalls[target][selector], "Call not allowed");
    (bool success, ) = target.call(data);
    require(success, "Call failed");
}
Enter fullscreen mode Exit fullscreen mode

Layer 3: Never Hold Long-Lived Approvals

The best router contracts never hold approvals at all. Instead, use pull-and-push in one transaction:

// ✅ BEST: Ephemeral approval via Permit2 + immediate execution
function swapWithPermit(
    ISignatureTransfer.PermitTransferFrom calldata permit,
    ISignatureTransfer.SignatureTransferDetails calldata transferDetails,
    bytes calldata signature,
    SwapParams calldata params
) external {
    // Pull tokens via single-use permit (auto-expires)
    PERMIT2.permitTransferFrom(permit, transferDetails, msg.sender, signature);

    // Execute swap with validated parameters
    _executeSwap(params);
}
Enter fullscreen mode Exit fullscreen mode

Layer 4: Block Token-Like Selectors on Low-Level Calls

If your contract must make arbitrary calls (e.g., a meta-transaction relayer), explicitly block dangerous ERC20 selectors:

bytes4 constant TRANSFER = 0xa9059cbb;
bytes4 constant TRANSFER_FROM = 0x23b872dd;
bytes4 constant APPROVE = 0x095ea7b3;

function execute(address target, bytes calldata data) external {
    bytes4 selector = bytes4(data[:4]);
    require(
        selector != TRANSFER &&
        selector != TRANSFER_FROM &&
        selector != APPROVE,
        "Blocked selector"
    );
    (bool success, ) = target.call(data);
    require(success);
}
Enter fullscreen mode Exit fullscreen mode

Detection: Finding This in the Wild

Static Analysis with Slither

Create a custom Slither detector to flag arbitrary external calls:

from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification
from slither.slithir.operations import LowLevelCall

class ArbitraryCallDetector(AbstractDetector):
    ARGUMENT = "arbitrary-call-injection"
    HELP = "Detects low-level calls with user-controlled targets"
    IMPACT = DetectorClassification.HIGH
    CONFIDENCE = DetectorClassification.MEDIUM
    WIKI = "https://example.com/arbitrary-call"

    def _detect(self):
        results = []
        for contract in self.compilation_unit.contracts_derived:
            for function in contract.functions:
                if function.visibility in ["public", "external"]:
                    for node in function.nodes:
                        for ir in node.irs:
                            if isinstance(ir, LowLevelCall):
                                if self._is_tainted_by_params(ir.destination, function):
                                    info = [
                                        function, " makes a low-level call to ",
                                        "user-controlled target at ", node, "\n"
                                    ]
                                    results.append(self.generate_result(info))
        return results

    def _is_tainted_by_params(self, var, function):
        from slither.analyses.data_dependency.data_dependency import is_dependent
        for param in function.parameters:
            if is_dependent(var, param, function):
                return True
        return False
Enter fullscreen mode Exit fullscreen mode

Foundry Invariant Test

function invariant_noApprovalDrain() public {
    uint256 balanceBefore = token.balanceOf(victim);
    // Fuzzer tries arbitrary targets and calldata
    uint256 balanceAfter = token.balanceOf(victim);
    assertGe(balanceAfter, balanceBefore, "Approval drain detected");
}
Enter fullscreen mode Exit fullscreen mode

The Solana Parallel: CPI Target Validation

Solana programs face the same risk through Cross-Program Invocation (CPI). If a program makes a CPI to a user-supplied program ID without validation, attackers can redirect the call:

// ❌ VULNERABLE: Unvalidated CPI target
pub fn process_swap(ctx: Context<Swap>) -> Result<()> {
    let cpi_program = ctx.accounts.target_program.to_account_info();
    invoke(&instruction, &[/* accounts */])?;
    Ok(())
}

// ✅ SAFE: Validate CPI target against known program IDs
pub fn process_swap(ctx: Context<Swap>) -> Result<()> {
    require!(
        ctx.accounts.target_program.key() == RAYDIUM_AMM_V4
            || ctx.accounts.target_program.key() == ORCA_WHIRLPOOL,
        ErrorCode::InvalidSwapTarget
    );
    invoke(&instruction, &[/* accounts */])?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Anchor's Program<'info, T> type provides this check automatically by constraining the account to a specific program ID. Always prefer typed program accounts over unchecked AccountInfo.


The Checklist: Shipping Router Contracts Safely

  • [Critical] All low-level call targets validated against whitelist
  • [Critical] Function selectors restricted on external calls
  • [High] No persistent ERC20 approvals held — use Permit2 single-use
  • [High] transferFrom, approve, transfer selectors blocklisted on arbitrary calls
  • [Medium] Custom Slither detector deployed for arbitrary call patterns
  • [Medium] Foundry invariant tests covering approval drain scenarios
  • [Medium] Forta monitoring bot for anomalous transferFrom from routers
  • [High] Emergency pause mechanism on all external-facing call functions
  • [Medium] Time-locked admin functions for updating target whitelists
  • [High] Source code published and verified — closed-source routers are red flags

Key Takeaway

The calldata injection pattern is depressingly simple: a contract that holds approvals and makes calls with user-controlled data. The fix is equally simple: validate targets, restrict selectors, minimize approvals. Yet $17M+ was drained in January 2026 alone because these basics were skipped.

If your protocol routes swaps, manages liquidity, or aggregates DEX trades, audit every low-level call path. The attacker doesn't need a flash loan, doesn't need to manipulate an oracle, and doesn't need to front-run anyone. They just need one transferFrom and your users' forgotten approvals.


This is part of the DeFi Security Research series covering real-world exploits, detection techniques, and defense patterns across EVM and Solana.

Top comments (0)