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:
- The target contract address — where the call goes
- The calldata — what function to invoke and with what arguments
- 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
The exploit flow:
Attacker → SwapNet.swap(USDC_ADDRESS, transferFrom_calldata, -, -)
→ SwapNet calls USDC.transferFrom(victim, attacker, amount)
→ Attacker drains victim's approved USDC
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");
}
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");
}
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");
}
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);
}
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...
}
Detection: Auditing for Arbitrary Calls
Static Analysis Signatures
If you're auditing a contract (or building detection rules), flag any function that:
- Accepts an
addressparameter and uses it as a.call()target - Accepts
bytes calldatathat's passed directly to.call() -
Holds token approvals from users (check for
transferFrompatterns) - 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
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);
}
}
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)