Methods
Detecting Arbitrary External Call Vulnerabilities Through Historical Transaction Replay: Methods and Findings
Detecting arbitrary external call risks through historical transaction replay, call-parameter tracing and mutation-based validation, with case studies and findings from Ethereum and BSC.
Introduction
Arbitrary external call vulnerabilities arise when a contract lets callers specify the target and data of an external call. They can put funds covered by token approvals at risk. Using archive-node data, we replayed 2.1 billion historical transactions and identified more than 100 vulnerable contracts across several code families. This article presents the detection method, examines the findings and differences between chains, and concludes with defensive recommendations.
1.1 Arbitrary External Call Vulnerabilities
A contract with an arbitrary external call vulnerability exposes a public entry point through which anyone can specify a target address and call data. The contract then executes the call under its own identity. In contracts with restricted execution paths, targets and permitted operations are defined in code or constrained by an allowlist.
An open execution interface may be a deliberate design choice, as in general-purpose batch tools and cross-chain bridges, or the result of missing validation. In either case, an entry point without caller restrictions may be exploitable if the contract holds residual funds or has token allowances granted by users.
1.2 A Case of Insufficient Target Restrictions
MetaAggregatorSwapContract on BSC (0xc17b234fad8e40642662f1d2a6cbd1d609e7b9b5) exposes an arbitrary external call risk because its target restrictions are insufficient. The contract swaps tokens on behalf of users: it receives tokens and calls an external aggregator to execute the trade.
// Public entry point: all parameters come from calldata.
function swapERC20(SwapERC20Params calldata params) external nonReentrant {
(uint256 amountOut, uint256 fee) = _swapERC20(params);
// ... emit TokenSwapped(...)
}
function _swapERC20(SwapERC20Params memory params) internal returns (uint256, uint256) {
address aggregator = params.aggregator; // Caller-supplied address
bytes memory swapData = params.swapData; // Caller-supplied bytes
// ...
uint256 balanceBefore = tokenOut.balanceOf(address(this));
_executeAggregatorCall(swapData, isDelegate, aggregator, 0);
uint256 amountOut = tokenOut.balanceOf(address(this)) - balanceBefore;
if (amountOut < minAmountOut) revert InsufficientTokenOutAmount();
// ...
}
function _executeAggregatorCall(
bytes memory swapData,
bool isDelegate,
address aggregator,
uint256 value
) internal {
(bool success, bytes memory returnData) = isDelegate
? SWAP_TARGET.delegatecall(swapData)
: aggregator.call{value: value}(swapData); // Parameters affect the target and execution path.
// ...
}Both aggregator and swapData come from the caller, with no allowlist check. In non-delegate mode, the contract uses CALL to invoke aggregator; in delegate mode, execution follows the SWAP_TARGET delegatecall path. Insufficient target restrictions may broaden the contract's callable surface. Users have also granted the contract allowances for swaps.
Control over aggregator and swapData does not, by itself, establish an arbitrary token.transferFrom attack. The source requires minAmountOut to be greater than zero. In non-delegate mode, MetaAggregator calls aggregator through CALL, so the caller seen by the token must be determined from the actual call chain. Classifying this case as an allowance-abuse vulnerability therefore requires a complete trace, the actual spender, and the fund flow.
That classification also requires evidence of the victim's approval. The available example supports only a residual-balance path. These two paths must be documented separately.
2.1 Scanner Design
The scanner re-executes historical transactions one by one and records the complete call tree, including each call and its parameters. It then looks for potentially controllable values: a recipient, an amount, a target contract, or call data. If an internal parameter can be traced to the raw bytes of the transaction's top-level calldata, this establishes only that the submitter may influence it. The scanner must still replace that value and replay the transaction to verify both control over the parameter and its effect on actual fund flows.
2.2 Scanning Workflow
Figure 1 shows the complete processing flow for a single transaction.
2.3 Tracing Call Parameters
The scanner first checks whether an internal parameter's raw bytes appear in the top-level calldata. A match indicates potential submitter control along the observed encoding path; mutation replay is still required to confirm it.
The scanner uses byte-level matching. It registers a parameter as potentially input-controlled and records its offset when either its 32-byte word appears at an aligned top-level position or the complete internal call data appears as a contiguous byte sequence in the top-level input. If no match is found, the result is marked as “parameter provenance not established.” The scanner does not infer that the value was computed internally. During mutation, it changes the bytes at the recorded top-level offset and checks whether the internal parameter follows.
The current implementation performs EVM-level byte matching rather than semantic analysis, so it can process contracts with or without published source code. A known limitation is that provenance may be lost when a contract reconstructs calldata in memory.
2.4 Implementation
The system rests on one core design: execute each historical transaction twice against the world state immediately before it occurred (Figure 2).
A transaction's result depends on the state left by all preceding transactions. For example, the fifth transaction in a block sees the balances left by the first four. Before replaying it, the scanner executes those four transactions into a temporary state overlay, PreState, and stops just before the fifth begins.
It then executes the transaction twice against that same state. The first run reproduces the original transaction and records contract calls, fund transfers, and storage changes. The second replaces controllable parameters, such as the sender, recipient, or amount, and measures the difference. Neither run commits changes to the baseline state, allowing repeated trials with different parameters.
After testing, the scanner commits the original execution result to the overlay and moves to the next transaction. Each transaction therefore runs against the state it actually encountered on-chain.
2.5 Filtering False Positives
Replay produces tens of thousands of candidate records, far too many for individual manual review. The pipeline filters out most of them before review. Each ✕ in Figure 1 marks a rejection category; several are described below.
Address-poisoning batches. Phishers pollute address histories with counterfeit tokens and zero-value or dust transfers. These batches can resemble genuine findings: they contain multiple apparent victims, familiar token names, and tiny transfers. Filtering therefore considers token code, amounts, and batch structure together.
// Batch-transfer entry points: two selectors from the same code family.
transfer(address[] from, address[] to, address token, uint256[] amount) // 0xe19c2253
transfer_7211834412884((uint8,address,address,address,uint256)[]) // 0x12514bba
// Victim lists are embedded in calldata; one transaction targets dozens of addresses.Example: 0x184aaf087ca6dd17eb1200dbfbee9f671bd906fc. This family produced hundreds of records involving dozens of helper contracts in the same scan. No nonzero balances of official stablecoins were found.
Token-name spoofing. Counterfeit tokens may use Cyrillic letters or zero-width characters to imitate familiar symbols. A name can look like USDT while containing different bytes. All such cases are flagged.
USDT → 55 53 44 54 (ASCII)
USDТ → 55 53 44 d0a2 (Final character: Cyrillic Т, U+0422)Examples: 0x9257cf49…f4253 and 0x6b1b80b4…1ca6cc, counterfeit USDT/USDC transferred in the same address-poisoning batch.
EIP-7702 replay constraints. In this set of type-4 replays, the original sender submitted the transaction with an authorizationList. Replacing the sender with the test address and removing that list caused a revert. This shows a dependency on the original authorization state in these replays; it does not mean that every EIP-7702 transaction must include an authorizationList. The scanner retains these records and uses caller_only and dual variants to distinguish executable paths from reverts caused by replay conditions.
Original transaction: type-4, with authorizationList signed by the account owner → Success
Changed-sender replay: same calldata, test address as sender, no authorization list → Full revertGroups of accounts with identical bytecode. Some records come from groups of addresses that share bytecode, a transaction sender, and a collection address.
// Shared feature: all accounts' bytecode points to the same delegation target.
0xef0100 + 631af7731569e10b22a0430051cfd5c8930a1656 // Shared across multiple accounts
Same transaction sender · Same collection addressExamples: 0x001100bd…3ff16 and 0x043d01a9…669d, two accounts with identical bytecode.
3.1 Assessment Criteria
Scanner findings are assessed in the sequence shown in Figure 3. The first two checks assess parameter control and successful simulation. The third classifies the funding source: payments from an allowance owner are labeled allowance abuse, while funds from public liquidity are labeled public arbitrage. These categories require different follow-up reviews; neither label alone establishes a confirmed vulnerability.
3.2 AI Review
An AI reviewer checks the scanner's candidate records (Figure 4). It reads the original transaction's calls and fund flows, then replays the transaction with a changed sender and recipient. A successful simulation establishes only that the transaction remains executable after parameter replacement. If evidence of the funding source, approval relationship, or call chain is insufficient, the record is marked “insufficient evidence; manual review required,” rather than automatically classified as a vulnerability.
Review results are written back to the dashboard (Figure 5). The figure shows one record's five-stage evidence chain, label, notes, and simulation identifiers. It does not show aggregate label counts.
4.1 Detecting Known Attack Transactions
The Seneca Protocol Chamber case serves as a validation example: the incident has public analyses, and the same call pattern appears in the scan results.
Seneca Protocol was attacked on February 28, 2024, with losses of several million dollars. BlockSec, SlowMist, and Rekt published analyses of the incident. The affected contract is 0x65c210c5…91f06, whose Chamber.sol source is verified on Etherscan. Figures 6 and 7 show the replay of the PT-weETH transaction 0x6da8f7cb…e820d, which calls clone 0xbc83f271…7c05. 0x23fcf9d4…01286 is a separate PT-rsETH transaction and is not the transaction shown in these figures.
The relevant code is shown below, with unrelated portions omitted:
// Public entry point: all three arrays come from calldata.
function performOperations(
uint8[] calldata actions,
uint256[] calldata values,
bytes[] calldata datas
) whenNotPaused external payable returns (uint256 value1, uint256 value2) {
// ...
} else if (action == Constants.OPERATION_CALL) {
(bytes memory returnData, uint8 returnValues)
= _call(values[i], datas[i], value1, value2);
// ...
}
function _call(uint256 value, bytes memory data, ...) internal returns (bytes memory, uint8) {
// Decode and directly use the caller-supplied target and calldata.
(address callee, bytes memory callData, ...) = abi.decode(data, (address, bytes, ...));
// The target is checked only against a denylist.
require(!blacklisted[callee], "Chamber: can't call");
// Execute the external call; the token's caller depends on the active call frame.
(bool success, bytes memory returnData) = callee.call{value: value}(callData);
require(success, "Chamber: call failed");
return (returnData, returnValues);
}This exposes the same class of external call risk as MetaAggregatorSwapContract in Section 1.2. The caller encodes callee and callData in the top-level calldata, and Chamber decodes and uses them directly. In the historical state, Chamber retained ERC-20 allowances granted by users to manage their positions. Establishing an allowance-abuse vulnerability still requires checking the actual spender, the victim's approval target, and the fund flow.
The scanner replays this transaction and records the complete call tree with call_tracer. Within Chamber's call frames, it identifies callee.call(callData) and confirms that both callee and callData map to the top-level datas parameter. It registers them as controllable inputs, replaces the corresponding recipient address in the top-level calldata with the test address, and replays against the same historical state. The transaction succeeds, and the test address receives PT-weETH. Funding-source and call-chain evidence are recorded separately.
Figure 6 shows the Invocation Flow Diff for this PT-weETH replay. Figure 7 shows the corresponding Fund Flow Diff. Together, they show the changes in calls and fund flows after recipient replacement.
thirdweb Pay UniversalGatewayV1 (2025, Ethereum): corroboration from a public incident report
thirdweb's payment gateway, 0x81d57dd01a15bc1c19563693902df621500d4d2a, also appears in the scan results. thirdweb's public report confirms that the legacy contract exposed balances covered by token allowances and has been deactivated.
Verified source code for the historical implementation is not available for direct comparison. The relationships between signature binding, counterfeit token addresses, and callData must be checked against the proxy implementation and transaction trace at the relevant block. Current reference code cannot substitute for that historical implementation.
The historical calls involve a combination of token addresses, forwarded data, and token allowances. The available evidence does not establish “bypassing the debit with a counterfeit token address” as the root cause in that implementation. We do not treat it as a confirmed mechanism without the implementation code and complete trace for the relevant block.
5.1 Risk Paths Identified by the Scanner
The following cases have different types of supporting evidence. Some come from historical replay; others also have source code, audits, or incident reports. Each assessment is limited to the evidence presented and distinguishes scanner findings from external sources.
Case status refers to the cited transaction's block state or an explicitly stated query time. Historical transactions do not establish current balances, allowances, or whether a contract remains callable.
1. Maker Multicall2: a batch executor without a caller allowlist
0x5ba1e12693dc8f9c48aad8770482f4739beed696
// Official contract, deployed in 2021, with published source code.
function aggregate(Call[] memory calls)
public returns (uint256 blockNumber, bytes[] memory returnData)
{
blockNumber = block.number;
returnData = new bytes[](calls.length);
for (uint256 i = 0; i < calls.length; i++) {
(bool success, bytes memory ret)
= calls[i].target.call(calls[i].callData); // No caller allowlist
require(success, "Multicall aggregate: call failed");
returnData[i] = ret;
}
}Both target and callData are caller-supplied. The contract has no caller allowlist, although it checks whether each underlying call succeeds. It is a general-purpose batch executor; the risk depends on whether users have approved it to spend their tokens. Approvals and fund flows in this study's sample must be verified transaction by transaction.
2. THORSwap TSAggregatorGeneric: incomplete target restrictions
0x0ccd5dd5bcf1af77dc358d1e2f06ee880ef63c3c
function swapIn(
address router, address vault, string calldata memo,
address token, uint amount,
address swapRouter, // Caller-supplied call target
bytes calldata data, // Caller-supplied call data
uint deadline
) public nonReentrant {
// Only calls to TTP (THOR's token transfer proxy) are prohibited.
require(swapRouter != address(tokenTransferProxy), "no calling ttp");
tokenTransferProxy.transferTokens(token, msg.sender, address(this), amount);
token.safeApprove(address(swapRouter), amount);
(bool success,) = swapRouter.call(data); // Other targets remain callable.
require(success, "failed to swap");
// ...
}The current source excludes only TTP rather than restricting swapRouter to an allowlist, so token contracts remain valid targets. Historical replay shows that a caller can direct swapRouter and data to a token-transfer path. Classifying it as allowance abuse still requires checking the spender, allowance owner, and funding source together.
3. Symbiosis MetaRouter: arbitrary calldata in a cross-chain router
0xf621fb08bbe51af70e7e0f4ea63496894166ff7f
metaRoute pulls user-approved tokens through the gateway and checks that relayRecipient is not the gateway and that the target has code. It then calls relayRecipient directly with the caller-supplied otherSideCalldata, without a function-selector allowlist.
The contents of otherSideCalldata are not checked against a selector allowlist. Target validation is limited to excluding the gateway and requiring deployed code. In a transaction identified by the scanner, this parameter contains a USDT.transferFrom call. Establishing allowance abuse requires identifying the transaction's spender, allowance owner, and fund recipient individually.
4. LayerZero LZMultiCall: approval to the wrong contract and an unsigned execution entry point
0x5528cf58feb8fbfce94f43b33240fffb1312bde3
// The contract exposes both signed and unsigned execute overloads.
function execute(
Call[] calldata _calls,
bytes32 _quoteId
) public payable virtual {
uint256 nonce = nonces[msg.sender]++;
_executeCalls(_calls, msg.sender);
emit Executed(msg.sender, _quoteId, nonce);
}
function _executeCalls(Call[] calldata _calls, address signer) internal {
for (uint256 i = 0; i < _calls.length; ++i) {
if (_calls[i].target == address(TRANSFER_DELEGATE)) {
_handleTransfer(_calls[i].data, signer);
} else {
_handleCall(_calls[i].target, _calls[i].data, _calls[i].value);
}
}
}The reviewed transaction uses the two-argument unsigned entry point, rather than the overload with _signature. This entry point treats msg.sender as the signer. It checks whether the transfer's from equals the signer only when the target is TransferDelegate. If the call list instead targets the token contract directly and supplies token.transferFrom(victim, caller, amount), execution takes the generic _handleCall path, with LZMultiCall acting as the spender.
LayerZero's intended flow grants approval to TransferDelegate. If a user instead approves LZMultiCall directly, the public unsigned execute entry point may consume that allowance. The risk arises from approval to the wrong contract combined with the generic forwarding path; it does not involve the signed entry point.
5. thirdweb Pay UniversalGatewayV1: scope of the evidence
0x81d57dd01a15bc1c19563693902df621500d4d2a
Section 4.1 discusses this case as corroboration from a public incident report. That report confirms the allowance-related balance risk in the legacy contract and its deactivation. The relationships between signature binding, token addresses, and callData in the historical implementation must still be matched to the proxy implementation and trace at the relevant block. We do not use the current UniversalBridgeV1 reference source as a substitute for that implementation.
6. BSC BnbSettler: arbitrary BASIC calls consume misdirected allowances
0x4c6f446dd88fd1be8b80d2940806002777dc12a2
This is the BSC deployment of 0x Protocol Settler. The public execute entry point and BASIC action expose the relevant path, which reaches the token contract through action dispatch:
// Illustrative excerpt: action dispatch and settlement are omitted.
// This excerpt does not replace the complete source.
function execute(
AllowedSlippage calldata slippage,
bytes[] calldata actions,
bytes32
) public payable takerSubmitted returns (bool) {
// Caller-supplied actions are dispatched here, including BASIC.
// takerSubmitted records the payer; it does not authenticate the caller.
}
function basicSellToPool(
IERC20 sellToken, uint256 bps, address pool,
uint256 offset, bytes memory data
) internal {
if (_isRestrictedTarget(pool)) revert ConfusedDeputy(); // Only Permit2 is restricted.
(bool success, bytes memory returnData) = payable(pool).call(data);
if (!success) {
assembly { revert(add(returnData, 32), mload(returnData)) }
}
if (returnData.length == 0 && pool.code.length == 0) revert InvalidTarget(); // Target and calldata are controllable.
}If a user approves Settler directly to spend ERC-20 tokens, a caller can set pool to the token contract and encode token.transferFrom(victim, caller, amount) in data. Settler calls under its own identity, and the token executes the transfer against that allowance. Historical replay also shows a residual-balance path with a replaceable recipient. These paths must be counted separately.
The table groups cases by their primary exploitation prerequisite. Scanner identification, confirmation through historical replay, and corroboration from a public incident report represent different levels of evidence.
| Pattern | Case | Chain | Key feature |
|---|---|---|---|
| Design trade-off | Multicall2 | ETH | Official contract with no malicious code; users approved the wrong spender |
| Incomplete patch | THORSwap | ETH | Only TTP is excluded; other external call targets remain available |
| Arbitrary cross-chain instructions | Symbiosis | ETH | No semantic allowlist for calldata; the target is checked for deployed code |
| Misdirected approval and unsigned forwarding | LayerZero | ETH | Approval to the wrapper exposes a path outside TransferDelegate's from check |
| Historical allowance-related balance risk | thirdweb | ETH | A public report confirms deactivation of the legacy contract; the historical path still requires implementation and trace alignment |
| Arbitrary BASIC calls | BnbSettler | BSC | Direct approval to Settler allows BASIC to call the token's transferFrom |
The supporting evidence varies: some cases have historical replay, while others rely on source code or public reports. The scanner directly verifies whether changing controllable top-level parameters can redirect funds to the test address at the relevant historical state. Other conclusions require separate attribution and a clear statement of their evidentiary limits.
5.2 Findings Overview
Across Ethereum and BSC, the scanner collected more than 400 vulnerability records involving more than 300 addresses. A single address can have multiple detection records, and the address set includes EIP-7702 delegated accounts.
Ethereum records are concentrated in Maker Multicall2. BSC records are spread across more addresses, with fewer records per address. Other findings involve CallDataExecutor, UniswapInterfaceMulticall, SquidMulticall, and 0x Settler. Appendices A and B contain the complete address lists.
| Chain | Contract or account | Description |
|---|---|---|
| Ethereum | 0x5ba1e126…ed696 | Maker Multicall2 (Section 5.1, Case 1) |
| Ethereum | 0xbc83f271…7c05 | Seneca Chamber (Section 4.1) |
| Ethereum | 0x5724b5bc…81a1 | Swap0x forwarder |
| Ethereum | 0x65c210c5…91f06 | Seneca Chamber (another instance) |
| Ethereum | 0xd791fcf4…d001 | Abandoned contract |
| Ethereum | 0x0ccd5dd5…3c3c | THORSwap (Section 5.1, Case 2) |
| Ethereum | 0x81d57dd0…4d2a | thirdweb Pay (Section 4.1) |
| Ethereum | 0x5528cf58…bde3 | LayerZero LZMultiCall (Section 5.1, Case 4) |
| Ethereum | 0xf621fb08…ff7f | Symbiosis MetaRouter (Section 5.1, Case 3) |
| BSC | 0xc17b234f…b9b5 | swapERC20 (Section 1.2) |
| BSC | 0x62ccef0b…27dc | smartSwapByOrderId |
| BSC | 0xca11daf5…4ca11 | — |
5.3 Lynx: Transaction Analysis and Simulation
Lynx is our transaction analysis and simulation platform for Ethereum and BSC. It provides an interface compatible with standard JSON-RPC. The replay validation and fund-flow comparisons in this article were performed with Lynx. The platform supports eth_call simulations against archive-node historical state, with sender replacement, calldata modification, and account-balance overrides.
Lynx uses debug_traceCall and the relevant tracers to capture call trees, including targets, calldata, and return values, for parameter-provenance analysis. The interface also shows storage changes. It generates separate fund-flow graphs for the original and simulated transactions, allowing comparison of asset sources, recipients, and amounts. Figures 6 and 7 show the invocation and fund-flow differences, respectively; Figure 8 shows the call-tree interface.
Each simulation is saved under a unique identifier for later review.
Conclusion
Scanning 2.1 billion historical transactions identified more than 100 vulnerable contracts across infrastructure, cross-chain routers, payment gateways, and utility contracts. Historical-state replay confirmed a subset of these findings. Seneca and thirdweb provide independent public incidents for corroboration.
In the reproduced cases, token allowances could persist after balances changed. Monitoring outstanding allowances can therefore complement balance-event monitoring. This conclusion is limited to those cases.
Defensive recommendations:
Users. Revoke approvals after use, or use an authorization scheme with expiring signatures, such as Permit2. Before approving a spender, check that it is the execution entry point intended by the protocol. If the protocol forwards calls through a proxy or Permit2, also verify the actual spender, target contract, and allowance.
Protocol developers. Restrict the targets and selectors/data accepted by arbitrary external call entry points. Signed forwarding entry points should bind the signed content to the caller and recipient. Audit every call path when applying a patch. THORSwap's historical patch excluded only one target; other targets still required review.
Industry. Include outstanding allowances in continuous monitoring, recording the spender, allowance owner, block state, and balance changes. This article examines historical replay; continuous monitoring is outside its scope.