TL;DR
On January 25, 2026, attackers drained $13.4 million from SwapNet and $3.6 million from Aperture Finance across Ethereum, Arbitrum, Base, and BSC — all through the same vulnerability class: arbitrary external calls with insufficient input validation. Users who had granted token approvals to these aggregator contracts watched their funds vanish in minutes. The contracts were closed-source, making pre-exploit auditing nearly impossible for the community.
This article dissects the exploit mechanics, reconstructs the attack flow from decompiled bytecode, and provides four concrete defense patterns every aggregator and router contract must implement.
The Anatomy of an Arbitrary-Call Exploit
DEX aggregators like SwapNet find optimal swap routes by calling multiple on-chain liquidity sources. This requires dynamic external calls — the contract must invoke different routers, pools, and AMMs depending on the route.
The security assumption: the call target and calldata are constrained to legitimate swap operations.
The reality in SwapNet's case: neither the target address nor the calldata were validated.
How the Attack Worked
SwapNet's function 0x87395540() accepted user-supplied parameters that controlled:
- The target address for a low-level call
- The calldata passed to that call
The intended flow:
User -> SwapNet.swap(routerAddress, swapCalldata)
-> router.swap(tokenA, tokenB, amount) // legitimate DEX call
The exploit flow:
Attacker -> SwapNet.swap(USDC_ADDRESS, transferFromCalldata)
-> USDC.transferFrom(victim, attacker, allApprovedUSDC)
By replacing the router address with a token contract address (USDC), and crafting calldata that encodes transferFrom(victim, attacker, amount), the attacker hijacked the contract's low-level call to drain every user who had approved tokens to SwapNet.
The Devastating Chain Reaction
The attack was replicated across four chains simultaneously:
| Chain | Loss | Victim Contract |
|---|---|---|
| Base | $8.2M | 0x616000e384... |
| Ethereum | $3.1M | Multiple contracts |
| Arbitrum | $1.4M | Router contracts |
| BSC | $0.7M | Aggregator proxy |
| Total | $13.4M |
Aperture Finance, a concentrated liquidity manager, suffered the same vulnerability class through its 0x67b34120() minting function, losing an additional $3.6M.
Why Closed-Source Made It Worse
Both SwapNet and Aperture Finance deployed closed-source contracts. The decompiled bytecode expanded into thousands of lines with deeply nested branching logic. This created a dangerous asymmetry:
- Auditors and whitehats couldn't easily identify the vulnerability
- Attackers could still analyze decompiled bytecode and on-chain traces
- Users had no way to assess approval risk
BlockSec's post-mortem noted that the vulnerability was straightforward once identified — insufficient input validation on a low-level call — but the closed-source nature significantly increased analysis difficulty.
Lesson: Closed-source does not equal secure. It only delays discovery, and attackers are more motivated than defenders.
The 4 Defense Patterns
Pattern 1: Target Address Whitelist
The most critical defense. Never allow arbitrary addresses as call targets.
pragma solidity ^0.8.24;
contract SecureAggregator {
mapping(address => bool) public allowedTargets;
address public admin;
modifier onlyAdmin() {
require(msg.sender == admin, "Not admin");
_;
}
function addTarget(address target) external onlyAdmin {
allowedTargets[target] = true;
}
function removeTarget(address target) external onlyAdmin {
allowedTargets[target] = false;
}
function executeSwap(
address target,
bytes calldata data
) external {
require(allowedTargets[target], "Target not whitelisted");
(bool success, ) = target.call(data);
require(success, "Swap failed");
}
}
Pattern 2: Function Selector Validation
Even with whitelisted targets, validate the function being called.
contract SelectorGuard {
mapping(bytes4 => bool) public allowedSelectors;
// Explicitly blocked selectors (ERC20 transfer functions)
bytes4 constant TRANSFER_SELECTOR = 0xa9059cbb;
bytes4 constant TRANSFER_FROM_SELECTOR = 0x23b872dd;
bytes4 constant APPROVE_SELECTOR = 0x095ea7b3;
constructor() {
allowedSelectors[bytes4(keccak256(
"swap(address,address,uint256,uint256,bytes)"
))] = true;
allowedSelectors[bytes4(keccak256(
"exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))"
))] = true;
}
function _validateCalldata(bytes calldata data) internal view {
require(data.length >= 4, "Invalid calldata");
bytes4 selector = bytes4(data[:4]);
require(selector != TRANSFER_SELECTOR, "transfer blocked");
require(selector != TRANSFER_FROM_SELECTOR, "transferFrom blocked");
require(selector != APPROVE_SELECTOR, "approve blocked");
require(allowedSelectors[selector], "Unknown selector");
}
}
Pattern 3: Approval Scope Limitation (User-Side)
Protocols should encourage and enforce limited approvals.
contract ApprovalSafeAggregator {
function swapWithPermit(
address token,
uint256 amount,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s,
SwapParams calldata params
) external {
// Use permit for exact-amount approval
// No persistent approval = no residual risk
IERC20Permit(token).permit(
msg.sender, address(this), amount, deadline, v, r, s
);
IERC20(token).transferFrom(msg.sender, address(this), amount);
_executeValidatedSwap(params);
}
}
User action: Use revoke.cash or equivalent tools to audit and revoke approvals to aggregator contracts.
Pattern 4: Bytecode-Level Audit for Closed-Source Contracts
When source isn't available, use bytecode analysis tools.
# Scan for arbitrary-call patterns in decompiled bytecode
CONTRACT="0x616000e384Ef1C2B52f5f3A88D57a3B64F23757e"
CHAIN="base"
# 1. Fetch and decompile
cast code $CONTRACT --rpc-url $CHAIN | heimdall decompile --output decompiled/
# 2. Search for unconstrained CALL opcodes
grep -rn 'call(' decompiled/ | grep -v 'staticcall'
# 3. Check if user inputs flow to call targets
grep -rn 'calldataload' decompiled/ | head -20
# 4. Check for transferFrom patterns reachable from external calls
grep -B5 -A5 'transferFrom' decompiled/
Detection: Catching Arbitrary-Call Bugs Before Deployment
Slither Custom Detector
from slither.detectors.abstract_detector import (
AbstractDetector, DetectorClassification
)
from slither.slithir.operations import LowLevelCall
from slither.analyses.data_dependency.data_dependency import is_dependent
class ArbitraryCallDetector(AbstractDetector):
ARGUMENT = "arbitrary-call-target"
HELP = "Detects low-level calls where target depends on user input"
IMPACT = DetectorClassification.HIGH
CONFIDENCE = DetectorClassification.MEDIUM
WIKI = "https://github.com/crytic/slither/wiki"
WIKI_TITLE = "Arbitrary Call Target"
WIKI_DESCRIPTION = "Low-level call target from user-controlled input"
WIKI_EXPLOIT_SCENARIO = (
"Attacker replaces router address with token address"
)
WIKI_RECOMMENDATION = (
"Whitelist call targets and validate function selectors"
)
def _detect(self):
results = []
for contract in self.compilation_unit.contracts:
for function in contract.functions:
if not function.is_implemented:
continue
for node in function.nodes:
for ir in node.irs:
if isinstance(ir, LowLevelCall):
for param in function.parameters:
if is_dependent(
ir.destination, param, function
):
info = [
"Arbitrary call target in ",
function, "\n",
"\tCall target depends on: ",
param, "\n",
"\tAt node: ", node, "\n",
]
results.append(
self.generate_result(info)
)
return results
Foundry Invariant Test
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
contract AggregatorInvariantTest is Test {
Aggregator aggregator;
IERC20 usdc;
address victim;
address attacker;
function setUp() public {
aggregator = new Aggregator();
usdc = IERC20(USDC_ADDRESS);
victim = makeAddr("victim");
attacker = makeAddr("attacker");
vm.prank(victim);
usdc.approve(address(aggregator), type(uint256).max);
deal(address(usdc), victim, 1_000_000e6);
}
function invariant_noUnauthorizedTransfers() public view {
assertEq(
usdc.balanceOf(attacker), 0,
"CRITICAL: Attacker received tokens via aggregator"
);
}
function invariant_victimBalanceProtected() public view {
assertGe(
usdc.balanceOf(victim), 1_000_000e6,
"CRITICAL: Victim tokens drained through aggregator"
);
}
}
The Broader Pattern: Approval-Based Attack Surface
The SwapNet exploit is part of a growing pattern of approval-based attacks in DeFi:
| Incident | Date | Loss | Mechanism |
|---|---|---|---|
| SwapNet/Aperture | Jan 2026 | $17M | Arbitrary call -> transferFrom |
| Resolv Labs | Mar 2026 | $25M | Unbounded mint via logic flaw |
| Socket Gateway | Jan 2024 | $3.3M | Arbitrary call in bridge |
| Multichain | Jul 2023 | $126M | Compromised keys + approvals |
The common thread: persistent token approvals create a standing attack surface. Every unlimited approve() is a blank check waiting for the right exploit.
10-Point Arbitrary-Call Audit Checklist
- Map all low-level calls (call, delegatecall, staticcall) in the contract
- Trace call target origin — does it come from user input or storage?
- Verify target whitelist — are call targets restricted to known contracts?
- Validate function selectors — are dangerous selectors (transfer, transferFrom, approve) blocked?
- Check calldata construction — can users inject arbitrary calldata?
- Test cross-chain consistency — are the same validations applied on all deployed chains?
- Audit approval patterns — does the protocol require unlimited approvals?
- Verify closed-source contracts — if unverified, decompile and analyze bytecode
- Run invariant tests — fuzz with arbitrary addresses and calldata
- Monitor post-deployment — set up alerts for unexpected transferFrom calls from the contract
Conclusion
The SwapNet exploit is a textbook case of trusted intermediary abuse. Users trusted the aggregator with token approvals. The aggregator trusted user-supplied parameters. The attacker exploited that chain of trust to redirect transferFrom calls.
The fix is conceptually simple — whitelist targets, validate selectors, limit approvals — but the pattern keeps recurring because:
- Flexibility vs. security tradeoff: Aggregators need dynamic calls to function
- Closed-source opacity: Community can't audit what it can't read
- Approval inertia: Users rarely revoke old approvals
If you're building or auditing a DEX aggregator, treat every low-level call as a potential transferFrom. Because that's exactly how attackers see it.
Part of the DeFi Security Research series. Follow for weekly deep-dives into smart contract exploits, audit techniques, and defense patterns.
References:
Top comments (0)