DEV Community

ohmygod
ohmygod

Posted on

Arbitrary External Calls: The $17M DEX Aggregator Attack Pattern That's Still Lurking in 90% of Swap Routers

In January 2026, two DEX aggregators — SwapNet and Aperture Finance — were drained for a combined $17 million in a single day. The root cause? Both let users control where and what their contracts called externally, without validating that the target was actually a DEX router.

This isn't a novel attack. Arbitrary external calls have been a known vulnerability class since 2020. Yet in Q1 2026 alone, this pattern has surfaced in at least four separate protocols. Here's exactly how it works, why it keeps happening, and the concrete patterns that prevent it.


The Anatomy of an Arbitrary Call Exploit

What SwapNet Got Wrong

SwapNet exposed a public function (0x87395540()) that accepted user-supplied parameters for:

  1. The target contract address — where the call goes
  2. The calldata — what function to invoke and with what arguments
  3. The token and amount — pulled from the user's approved balance

The intended flow:

User → SwapNet.swap(targetDEX, swapCalldata, tokenIn, amount)
     → SwapNet calls targetDEX.swap(...)
     → User gets tokenOut back
Enter fullscreen mode Exit fullscreen mode

The exploit flow:

Attacker → SwapNet.swap(USDC_ADDRESS, transferFrom_calldata, -, -)
         → SwapNet calls USDC.transferFrom(victim, attacker, amount)
         → Attacker drains victim's approved USDC
Enter fullscreen mode Exit fullscreen mode

Because SwapNet held transferFrom approvals from users (who had previously swapped through it), the attacker simply redirected the external call to the token contract itself, crafting calldata that triggered transferFrom to move tokens from victims to the attacker's address.

The Blast Radius

  • 20+ users drained across Ethereum, Arbitrum, Base, and BSC
  • $13.34 million lost by a single whale who had granted unlimited approvals
  • Aperture Finance, exploited the same day with the same pattern, lost another $3.67 million
  • Zero recovery — funds were bridged and mixed within hours

Why This Keeps Happening

1. The Flexibility Trap

DEX aggregators need to call arbitrary contracts — that's their job. They route swaps through Uniswap, Curve, 1inch, and dozens of other pools. Hardcoding every possible target defeats the purpose.

The temptation: accept the target address as a parameter and trust the frontend to provide legitimate ones.

2. Closed-Source Contracts

Both SwapNet and Aperture had closed-source contracts. The vulnerability wasn't caught by community auditors, security researchers, or automated scanning tools that rely on source code analysis. By the time someone reverse-engineered the bytecode, the damage was done.

3. The Approval Time Bomb

Most users grant unlimited approvals to save gas on future transactions. This means a single vulnerability in a contract you approved months ago can drain your entire balance — even if you haven't interacted with it recently.


The Five Defense Patterns

Pattern 1: Target Address Allowlisting

The most reliable defense. Maintain an on-chain registry of approved DEX router addresses.

mapping(address => bool) public approvedTargets;

modifier onlyApprovedTarget(address target) {
    require(approvedTargets[target], "Target not approved");
    _;
}

function swap(
    address target,
    bytes calldata data,
    address tokenIn,
    uint256 amount
) external onlyApprovedTarget(target) {
    // Safe: target is a known DEX router
    IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amount);
    (bool success,) = target.call(data);
    require(success, "Swap failed");
}
Enter fullscreen mode Exit fullscreen mode

Trade-off: Requires governance or admin action to add new DEX routers. This is acceptable — the alternative is letting attackers choose the target.

Pattern 2: Function Selector Blocklisting

If you can't restrict targets, at minimum block dangerous function selectors:

bytes4 private constant TRANSFER_SELECTOR = 0xa9059cbb;
bytes4 private constant TRANSFER_FROM_SELECTOR = 0x23b872dd;
bytes4 private constant APPROVE_SELECTOR = 0x095ea7b3;

function _validateCalldata(bytes calldata data) internal pure {
    bytes4 selector = bytes4(data[:4]);
    require(selector != TRANSFER_SELECTOR, "transfer blocked");
    require(selector != TRANSFER_FROM_SELECTOR, "transferFrom blocked");
    require(selector != APPROVE_SELECTOR, "approve blocked");
}
Enter fullscreen mode Exit fullscreen mode

Warning: This is defense-in-depth, not a primary defense. Proxy contracts, multicall wrappers, and fallback functions can bypass selector checks.

Pattern 3: No Held Approvals

The nuclear option: never hold user approvals. Use a pull-based pattern where users transfer tokens in the same transaction:

function swap(
    address target,
    bytes calldata data,
    address tokenIn,
    uint256 amount
) external {
    // User must transfer tokens directly (no approval needed)
    IERC20(tokenIn).safeTransferFrom(msg.sender, target, amount);

    // Execute the swap — contract never holds the approval
    (bool success,) = target.call(data);
    require(success, "Swap failed");
}
Enter fullscreen mode Exit fullscreen mode

Or use Permit2 (Uniswap's signature-based approval system) with time-limited, amount-limited approvals that expire after each use.

Pattern 4: Balance Invariant Checks

Verify that after the external call, the contract hasn't lost tokens it shouldn't have:

function swap(
    address target,
    bytes calldata data,
    address tokenIn,
    uint256 amountIn,
    address tokenOut,
    uint256 minAmountOut
) external {
    uint256 balBefore = IERC20(tokenOut).balanceOf(address(this));

    IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn);
    IERC20(tokenIn).safeApprove(target, amountIn);

    (bool success,) = target.call(data);
    require(success, "Call failed");

    uint256 balAfter = IERC20(tokenOut).balanceOf(address(this));
    uint256 received = balAfter - balBefore;
    require(received >= minAmountOut, "Insufficient output");

    // Reset approval to 0
    IERC20(tokenIn).safeApprove(target, 0);

    IERC20(tokenOut).safeTransfer(msg.sender, received);
}
Enter fullscreen mode Exit fullscreen mode

Pattern 5: Target ≠ Token Validation

The simplest check that would have prevented the SwapNet exploit entirely:

function swap(
    address target,
    bytes calldata data,
    address tokenIn,
    address tokenOut,
    uint256 amount
) external {
    require(target != tokenIn, "Target cannot be input token");
    require(target != tokenOut, "Target cannot be output token");
    require(!_isKnownToken(target), "Target cannot be a token");

    // Proceed with swap...
}
Enter fullscreen mode Exit fullscreen mode

Detection: Auditing for Arbitrary Calls

Static Analysis Signatures

If you're auditing a contract (or building detection rules), flag any function that:

  1. Accepts an address parameter and uses it as a .call() target
  2. Accepts bytes calldata that's passed directly to .call()
  3. Holds token approvals from users (check for transferFrom patterns)
  4. Has no allowlist or blocklist on the call target

Slither Custom Detector

from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification

class ArbitraryCallDetector(AbstractDetector):
    ARGUMENT = "arbitrary-external-call"
    HELP = "Detects user-controlled external call 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:
                for node in function.nodes:
                    for ir in node.irs:
                        if hasattr(ir, 'destination'):
                            if any(
                                ir.destination == param
                                for param in function.parameters
                            ):
                                info = [
                                    function, " makes external call to ",
                                    "user-controlled address\n"
                                ]
                                results.append(
                                    self.generate_result(info)
                                )
        return results
Enter fullscreen mode Exit fullscreen mode

Foundry Fuzz Test

function testFuzz_cannotCallTokenContracts(
    address target,
    bytes calldata data
) public {
    vm.assume(target != address(0));

    if (target == address(usdc) || target == address(weth)) {
        vm.expectRevert("Target not approved");
        router.swap(target, data, address(usdc), 1000e6);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Approval Hygiene Imperative

Even with perfect contract security, unlimited approvals remain a systemic risk. The SwapNet exploit's $13.34M single-user loss happened because one user had approved MAX_UINT256 to a contract months earlier.

For Users

  • Use Revoke.cash or Etherscan Token Approvals to audit and revoke stale approvals
  • Prefer exact-amount approvals over unlimited ones
  • Use Permit2-compatible frontends that auto-expire approvals

For Protocols

  • Implement approval expiry (ERC-20 doesn't support this natively, but Permit2 does)
  • Reset approvals to 0 after each operation
  • Display approval warnings in your UI
  • Consider gasless approval patterns (EIP-2612 permits) that don't persist on-chain

Key Takeaways

Risk Factor SwapNet Aperture Mitigation
User-controlled call target Address allowlisting
User-controlled calldata Selector blocklist + allowlist
Held token approvals Permit2 / per-tx approvals
Closed-source contracts Open source + verified on-chain
No balance checks Pre/post balance invariants

The SwapNet/Aperture exploit pattern is fully preventable with standard defensive coding practices. The fact that it keeps recurring — and draining millions — suggests the industry needs to treat arbitrary external calls with the same severity as reentrancy: a known-dangerous pattern that requires explicit safeguards every single time.


This research is part of the DeFi Security Research series by DreamWork Security. Follow for weekly deep dives into smart contract vulnerabilities, audit techniques, and defensive patterns.

Top comments (0)