The Auditor’s Take is a weekly series by Jon Stephens, CEO of Veridise. This one covers arbitrary external call injection, where a contract performs an action on a caller’s behalf without checking what that action is. Each week’s take goes out at @FormallyJon.
Arbitrary external call injection is what happens when a contract builds an external call out of arguments its caller supplied, and executes it without validating that the resulting operation is one it meant to perform. It turns up most often in routers, bridges and aggregators, where a contract has to act through other contracts to do its job. It cost Socket Protocol roughly $3.3 million in January 2024.
TLDR:
- A contract acting through others must decide which operation it performs
- Taking the target and arguments from a caller hands that decision away
- The call carries the contract’s privileges, including approvals users granted
- Socket’s route trusted the caller’s encoding and validated nothing about it
- The fix is to build the call internally or validate what the caller supplied
What the Vulnerability Class Is

Contracts routinely perform actions through other contracts. A router performs a swap via a decentralized exchange. A bridge transfers assets as described by a message. A vault deposits into the strategy it was built around.
Sometimes the contract knows exactly which contract it needs to call, and the address can be fixed in code. Often it does not, because the protocol is built to work with a set of integrations that changes over time. In such cases, the contract must decide how such actions will be performed.
One common approach is to take it from the caller. The contract accepts an address, the calldata, or both, and passes them to a low-level call. Plenty of production systems work this way and most of them are fine. What the design does is let the caller choose the operation, which means the contract will perform whichever function the supplied calldata selects on whichever contract the supplied address points to.
That only becomes dangerous in proportion to what the calling contract is trusted with, since the call executes under its identity. Two things usually matter in cases similar to Socket. The first is any token balance the contract is holding, which for a router can include funds users left behind rather than sweeping out. The second, and usually the larger, is the set of permissions, such as token approvals, the contract has been granted.
Approvals are worth being precise about, because the exposure is a matter of expectation rather than amount. Someone granting an approval, particularly an unlimited one, is usually doing so because they expect to interact with that contract often and would rather not sign a fresh approval each time. What they are trusting is that the contract will move their funds only when they instruct it to, and at no other time. A contract that lets somebody else decide what it calls breaks that trust directly.
What This Vulnerability Class Costs
Socket lost about $3.3 million on 16 January 2024, with around 230 wallets drained across two transactions.
It recurs across blockchain security incidents that share no code. LI.FI, July 2024, $11.6 million, where a newly added facet performed swap calls without the address and selector allowlist its siblings enforced, per LI.FI’s incident report. SwapNet and Aperture Finance, January 2026, over $17 million between them, per BlockSec’s analysis. Each one ends the same way, with an attacker calling transferFrom through a contract users had approved.
Socket: A Route That Trusted the Encoding
Route 406 on the Socket gateway was deployed three days prior to the attack and intended to perform swaps on wrapped tokens via the performAction function. Its job was to turn a wrapped token into its native form, or the reverse.
Wrapping and unwrapping tokens can easily be performed by interacting with the wrapped token contract. Since Socket wanted to interact with arbitrary wrapped tokens that may not share an interface, it required the user supply calldata for the wrapping/unwrapping operation. Doing so provides flexibility while reducing the management costs that would be required to add support for individual tokens.
Here is the function, with the unwrap branch shown in full:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
function performAction( address fromToken, address toToken, uint256 amount, address receiverAddress, bytes32 metadata, bytes calldata swapExtraData ) external payable override returns (uint256) { // ... native-to-wrapped branch omitted ... } else { _initialBalanceTokenOut = address(socketGateway).balance; // Swap Wrapped Token To Native Token ERC20(fromToken).safeTransferFrom(msg.sender, socketGateway, amount); (bool success, ) = fromToken.call(swapExtraData); if (!success) { revert SwapFailed(); } _finalBalanceTokenOut = address(socketGateway).balance; require( (_finalBalanceTokenOut - _initialBalanceTokenOut) == amount, "Invalid wrapper contract" ); } } |
fromToken and swapExtraData are both parameters of the external function and neither is checked between arrival and use. fromToken becomes the address called and swapExtraData becomes the calldata sent to it. The contract trusts the caller’s encoding and performs no validation to check that it is correct.
An attacker supplied calldata that correctly interacted with the token, but for a different purpose. They passed USDC as fromToken and an encoded transferFrom as swapExtraData, naming a victim as the source and themselves as the recipient. USDC received what looked like an ordinary transferFrom from SocketGateway, an address that victim had approved, and moved the tokens.
The require at the end is worth a note. It checks that the gateway’s ETH balance rose by the amount that came in, which is conceptually similar to a slippage check: it confirms the caller got the result the swap promised. It says nothing about which operation produced that result. The attacker passed an amount of zero, which easily passed as no funds were sent to the gateway contract.
What the Contract Should Have Done
The missing step is a check that the encoded action was safe before performing it. The contract could decode swapExtraData and validate its parameters against the token being called, confirming the selector and arguments match what unwrapping that particular token should look like.
That validation is not free. Someone has to supply the expected shape for every supported token, which means a trusted admin maintaining that data as new tokens are added.
Building the calldata inside the contract runs into the same constraint. A contract can only encode a swap it already knows how to perform, so an admin still has to provide the encoding methodology per token. Either way the cost is a maintenance burden carried by an admin instead of left to the caller.
How a Senior Auditor Reads an External Call
Start with how users are expected to interact with the contract. That establishes what the contract is trusted with, and what could be compromised by an attacker. For a routing gateway, users approve it and call through it repeatedly, so it accumulates standing permission to move their funds.
Then, on each external call, establish what the caller controls. Read backward from the call to the function parameters and see what survives unchecked. For performAction that is both the target and the calldata.
The question that decides severity is what the contract has established about the resulting operation. performAction confirms the call did not revert, and confirms a balance moved. Neither tells it which function ran. That gap is what a smart contract audit reports, because the caller supplies the selector and the arguments while nothing in the function ties them to unwrapping a token.
Why Review Misses It, and What Automates
The reasoning above is not difficult, but it has to be repeated on every external call in the codebase, and a routing protocol accumulates them with every integration it supports.
Vanguard, the static analyzer Veridise auditors run, encodes part of that reasoning as a custom detector. It analyzes each low-level call and reports the ones whose target or calldata reach an external function argument with no conditional constraining them on the way.
That settles the structural question exhaustively. Report nothing and no low-level call in the codebase forwards unconstrained caller input. Report something and that call is a real instance of the pattern. Whether a flagged call can be driven somewhere harmful still depends on what surrounds it, and that stays manual. Access control is the usual complication. It lowers severity without removing the finding, because an arbitrary call behind a privileged key is still an arbitrary call the day that key leaks.
What to Take From This
Do not pass unvalidated caller input into the arguments of an external call unless there is a compelling argument for the safety of the call. Either the contract builds the call itself, or it validates what the caller supplied before making it.
This class persists because the flexibility behind it is usually deliberate. Socket wanted one route to serve many wrapped tokens, and LI.FI wanted a facet that could reach new integrations. Both are reasonable goals. The mistake is shipping that flexibility without deciding, somewhere in the contract, which operations it is allowed to produce.
Working on a similar protocol?
If any function in your contracts forwards caller-supplied arguments into an external call, the operations that function can perform are decided by whoever calls it. That is worth checking before your next integration ships, particularly where users hold approvals to the contract. talk to us.
The Takeaway
Arbitrary external call injection happens when a contract lets its caller decide the arguments to an external call. Socket’s swap route forwarded caller-supplied calldata to a caller-supplied token, so an encoded transferFrom drained wallets that had approved the gateway. Encode the operation internally, or validate what the caller sends.