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");
}
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)
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:
- The call target — intended to be a DEX router, but never validated
- 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...
)
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:
- Wrap native tokens (ETH → WETH)
- Swap tokens via internal function
0x1d33()← vulnerable - 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
}
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...:
- Attacker calls
0x67b34120()on victim contract - Victim executes
WETH.deposit()— wraps 100 wei (trivial amount) - Victim invokes
0x1d33()with attacker's tuple:(target=TOKEN, calldata=transferFrom(...), expectedOutput=AMOUNT) - Low-level
call()executesTOKEN.transferFrom(victim, attacker, amount) - Balance delta check passes (attacker set
expectedOutputto match) - 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:
- Flexibility vs. Security tradeoff: Aggregators want to call arbitrary routers — that's their value proposition
- Closed-source contracts: Both SwapNet and Aperture were closed-source, reducing community review
- Approval accumulation: These contracts hold approvals from thousands of users, creating massive honeypots
- 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;
}
}
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);
}
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);
}
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");
}
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(())
}
Audit Checklist: Arbitrary External Call Safety
Use this before every deployment:
- [ ] Call target validation: Is every
call(),delegatecall(), andstaticcall()target restricted to a whitelist? - [ ] Selector blocking: Are
transfer(),transferFrom(), andapprove()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)