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");
}
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:
- Attacker identifies users who granted unlimited approvals to SwapNet contracts
- Crafts calldata replacing the expected pool address with a token address (e.g., USDC)
- The
datapayload encodestransferFrom(victim, attacker, amount) - SwapNet's contract obligingly makes the call — the token contract sees the call coming from an approved spender
- 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");
}
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");
}
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);
}
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);
}
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
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");
}
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(())
}
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,transferselectors 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
transferFromfrom 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)