DEV Community

ohmygod
ohmygod

Posted on

The $17M Arbitrary External Call Exploit: How Unchecked call() Targets Drained SwapNet and Aperture Finance

DeFi Security Research — Vulnerability Analysis


It's the simplest vulnerability class in smart contracts, and it just caused $17 million in losses across four chains in a single day.

On January 25, 2026, attackers exploited SwapNet ($13.4M) and Aperture Finance ($3.67M) using the same fundamental flaw: arbitrary external calls with user-controlled targets. Both protocols allowed users to specify call targets and calldata in swap functions — without validating that those targets were actually swap routers.

The result? Attackers simply pointed the call() at token contracts and invoked transferFrom(victim, attacker, amount), draining every token that users had approved to the protocol.

Let's break down exactly how this happened, why existing checks failed, and how to build contracts that are immune to this pattern.

The Vulnerability Pattern

At its core, the arbitrary external call vulnerability is deceptively simple:

// ❌ VULNERABLE: User controls both target and calldata
function swap(address target, bytes calldata data) external {
    // No validation on 'target' — could be ANY contract
    (bool success,) = target.call(data);
    require(success, "Call failed");
}
Enter fullscreen mode Exit fullscreen mode

When a contract holds token approvals (which DEX aggregators and liquidity managers always do), this becomes a weapon. The attacker doesn't need to find a complex exploit chain — they just call:

target = USDC_ADDRESS
data   = abi.encodeWithSelector(IERC20.transferFrom.selector, victim, attacker, amount)
Enter fullscreen mode Exit fullscreen mode

The contract dutifully executes USDC.transferFrom(victim, attacker, amount) using its own approvals, and the tokens are gone.

SwapNet: $13.4M Across Four Chains

SwapNet is a DEX aggregator that finds optimal swap routes across multiple AMMs and private market makers. It also lets users specify custom routers — a feature that became its downfall.

The Vulnerable Function

Based on BlockSec's bytecode analysis, SwapNet's function 0x87395540() accepted user-supplied parameters that controlled:

  1. The call target — intended to be a DEX router, but never validated
  2. The calldata — intended to be a swap instruction, but never checked

The attack was trivially simple:

// Pseudocode of the attack
swapNet.0x87395540(
    target: USDC_ADDRESS,          // Should be a router, but it's USDC
    calldata: transferFrom(victim, attacker, amount),
    ...other params...
)
Enter fullscreen mode Exit fullscreen mode

A key internal variable (v51 in decompiled bytecode) was set to the USDC address, bypassing the intended routing logic entirely. The contract then executed a low-level call with the attacker's calldata, draining all approved USDC.

Damage

Chain Loss
Ethereum ~$5.2M
Arbitrum ~$4.1M
Base ~$2.8M
BSC ~$1.3M
Total ~$13.4M

Aperture Finance: $3.67M via Liquidity Management

Aperture Finance manages concentrated liquidity positions (Uniswap V3 LPs) on behalf of users. Its minting workflow had three steps:

  1. Wrap native tokens (ETH → WETH)
  2. Swap tokens via internal function 0x1d33()vulnerable
  3. Mint Uniswap V3 position

The Double Failure

The internal swap function 0x1d33() had two critical flaws:

Flaw 1: Unconstrained call target and calldata

// Decompiled logic (simplified)
function _swap(address target, bytes calldata data, uint256 expectedOutput) internal {
    uint256 balBefore = token.balanceOf(address(this));

    // ❌ No validation on target or data
    (bool success,) = target.call(data);
    require(success);

    uint256 balAfter = token.balanceOf(address(this));
    require(balAfter - balBefore == expectedOutput);  // ← Flaw 2
}
Enter fullscreen mode Exit fullscreen mode

Flaw 2: Attacker-controlled validation

The expectedOutput parameter was supposed to verify the swap result. But since the attacker controlled this value too, they simply set it to match the balance change from their malicious transfer. The "safety check" was completely meaningless.

Attack Transaction Walkthrough

Using tx 0x8f28a...:

  1. Attacker calls 0x67b34120() on victim contract
  2. Victim executes WETH.deposit() — wraps 100 wei (trivial amount)
  3. Victim invokes 0x1d33() with attacker's tuple: (target=TOKEN, calldata=transferFrom(...), expectedOutput=AMOUNT)
  4. Low-level call() executes TOKEN.transferFrom(victim, attacker, amount)
  5. Balance delta check passes (attacker set expectedOutput to match)
  6. Victim continues to mint() — attacker gets position, victims lose tokens

Users who had enabled "Instant Liquidity Management" were especially vulnerable — the feature required broad token approvals that the attacker exploited.

Stolen Funds

The ~1,242 ETH ($2.4M) stolen from Ethereum was laundered through Tornado Cash. Cross-chain losses brought the total to $3.67M.

Why This Keeps Happening

The arbitrary external call vulnerability isn't new. It's OWASP SC06:2025 (Unchecked External Calls) in the Smart Contract Top 10. Yet DEX aggregators and liquidity managers keep making the same mistake because:

  1. Flexibility vs. Security tradeoff: Aggregators want to call arbitrary routers — that's their value proposition
  2. Closed-source contracts: Both SwapNet and Aperture were closed-source, reducing community review
  3. Approval accumulation: These contracts hold approvals from thousands of users, creating massive honeypots
  4. Bypassed checks: Validation logic that relies on user-supplied expected values provides zero security

Defense Patterns

1. Whitelist Call Targets (Essential)

// ✅ SECURE: Only allow calls to approved routers
mapping(address => bool) public approvedRouters;

function swap(address target, bytes calldata data) external {
    require(approvedRouters[target], "Router not approved");
    require(!_isTokenContract(target), "Cannot call token contracts");
    (bool success,) = target.call(data);
    require(success, "Swap failed");
}

function _isTokenContract(address target) internal view returns (bool) {
    // Check if target implements ERC20 interface
    try IERC20(target).totalSupply() returns (uint256) {
        return true;
    } catch {
        return false;
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Restrict Function Selectors

// ✅ SECURE: Only allow known swap function selectors
mapping(bytes4 => bool) public allowedSelectors;

function swap(address target, bytes calldata data) external {
    require(approvedRouters[target], "Router not approved");

    bytes4 selector = bytes4(data[:4]);
    require(allowedSelectors[selector], "Selector not allowed");

    // Block transferFrom, transfer, approve selectors explicitly
    require(selector != IERC20.transferFrom.selector, "Blocked");
    require(selector != IERC20.transfer.selector, "Blocked");
    require(selector != IERC20.approve.selector, "Blocked");

    (bool success,) = target.call(data);
    require(success);
}
Enter fullscreen mode Exit fullscreen mode

3. Use Scoped Approvals (Permit2 Pattern)

// ✅ SECURE: Per-swap scoped approvals instead of infinite
function swapWithPermit(
    address tokenIn,
    uint256 amountIn,
    bytes calldata swapData,
    // Permit2 signature
    IPermit2.PermitSingle calldata permit,
    bytes calldata signature
) external {
    // Pull exact amount via Permit2 — no standing approvals
    PERMIT2.permitTransferFrom(permit, 
        IPermit2.SignatureTransferDetails({
            to: address(this),
            requestedAmount: amountIn
        }), 
        msg.sender, 
        signature
    );

    // Execute swap with validated router
    _executeSwap(tokenIn, amountIn, swapData);

    // Reset any residual approvals
    IERC20(tokenIn).approve(router, 0);
}
Enter fullscreen mode Exit fullscreen mode

4. Independent Validation (Don't Trust User Inputs)

// ✅ SECURE: Compute expected outputs independently
function _validateSwapResult(
    address tokenOut,
    uint256 balanceBefore,
    uint256 minAmountOut  // From oracle or slippage calc, NOT user input
) internal view {
    uint256 balanceAfter = IERC20(tokenOut).balanceOf(address(this));
    uint256 received = balanceAfter - balanceBefore;

    // Use oracle price for validation, not user-supplied expected output
    uint256 oracleMinimum = _getOracleMinimum(tokenOut, minAmountOut);
    require(received >= oracleMinimum, "Insufficient output");
}
Enter fullscreen mode Exit fullscreen mode

5. Solana/Anchor Equivalent Defense

The same pattern applies to Solana programs that invoke arbitrary CPIs:

// ✅ SECURE: Validate CPI target program
use anchor_lang::prelude::*;

pub fn execute_swap(ctx: Context<ExecuteSwap>, data: Vec<u8>) -> Result<()> {
    let target_program = &ctx.accounts.target_program;

    // Whitelist check — only allow known DEX programs
    require!(
        APPROVED_PROGRAMS.contains(&target_program.key()),
        ErrorCode::UnauthorizedProgram
    );

    // Validate the instruction discriminator
    require!(data.len() >= 8, ErrorCode::InvalidData);
    let discriminator = &data[..8];
    require!(
        ALLOWED_DISCRIMINATORS.contains(discriminator),
        ErrorCode::BlockedInstruction
    );

    // Execute CPI with validated target
    invoke(&Instruction {
        program_id: target_program.key(),
        accounts: ctx.remaining_accounts.iter()
            .map(|a| AccountMeta {
                pubkey: a.key(),
                is_signer: a.is_signer,
                is_writable: a.is_writable,
            })
            .collect(),
        data,
    }, &ctx.accounts.to_account_infos())?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Audit Checklist: Arbitrary External Call Safety

Use this before every deployment:

  • [ ] Call target validation: Is every call(), delegatecall(), and staticcall() target restricted to a whitelist?
  • [ ] Selector blocking: Are transfer(), transferFrom(), and approve() selectors explicitly blocked on external calls?
  • [ ] Token contract exclusion: Can call targets resolve to ERC20/ERC721 contracts? If yes, block them.
  • [ ] Independent validation: Are swap results validated against oracle prices or router return values (NOT user-supplied expected outputs)?
  • [ ] Approval hygiene: Does the contract hold infinite approvals? Switch to Permit2 or per-tx approvals.
  • [ ] Approval reset: Are residual approvals reset to zero after each operation?
  • [ ] Closed-source risk: Is the contract verified and open-source? Closed-source contracts with approval authority are extreme risk.
  • [ ] CPI validation (Solana): Are cross-program invocation targets validated against a program whitelist?

Key Takeaway

The SwapNet/Aperture Finance exploits weren't sophisticated. They didn't require flash loans, oracle manipulation, or economic modeling. They required one thing: a call() instruction where the attacker controlled the target address.

If your contract holds token approvals and makes external calls with any user-controlled parameter — you're one transaction away from losing everything. Whitelist your targets. Block dangerous selectors. Use scoped approvals. And for the love of DeFi, don't validate swap results with user-supplied expected values.


This analysis is based on BlockSec's technical report and CredShields' analysis. All code examples are for educational purposes.

Follow @ohmygod for weekly DeFi security research.

Top comments (0)