技术方法
基于历史交易重放的任意外部调用漏洞扫描系统:方法与实证
以历史交易重放、调用参数溯源与变异验证检测任意外部调用风险,并分析以太坊与 BSC 的漏洞案例及扫描结果。
前言
任意外部调用漏洞——合约把调用的目标与数据交给调用者填写——是链上授权资金面临的一类风险。我们在归档节点数据上重放 21 亿笔历史交易,扫描出超过一百个漏洞合约,涉及多个代码族。本文先介绍检测方法,再分析漏洞构成与链间差异,结语给出防御建议。
1.1 任意外部调用漏洞
存在任意外部调用漏洞的合约,提供一个公开入口:调用的目标地址和数据由任何提交者填写,合约以自己的身份执行。正常合约不是这样——要调用谁、传什么数据,写在代码里或限定在白名单内。
这种入口有时是刻意的设计取舍(批量工具、跨链桥为了通用故意开放),有时是设计缺陷(本想限制调用范围,却漏了校验)。无论是刻意开放还是校验遗漏,只要合约持有用户授权或滞留资金,缺少调用者限制的入口就可能被非预期调用者利用。
1.2 一个目标限制不足的案例
BSC 上的 MetaAggregatorSwapContract(0xc17b234fad8e40642662f1d2a6cbd1d609e7b9b5)存在目标限制不足的任意外部调用风险。它的功能是代用户执行兑换:接收代币,调用外部聚合器完成交易。
// 入口:任何人可调用,全部参数来自 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; // 任意地址
bytes memory swapData = params.swapData; // 任意字节
// ……
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); // CALL 或 DELEGATECALL 的目标和代码路径由参数影响
// ……
}aggregator 和 swapData 都来自调用者,合约没有白名单校验。非 delegate 模式下,合约通过 CALL 调用 aggregator;delegate 模式下,代码在 SWAP_TARGET 的上下文中执行。目标限制不足,可能扩大合约可调用的范围;合约名下还挂着用户为兑换授权的额度。
aggregator 和 swapData 可控,但这本身不能推出任意 token.transferFrom 攻击。minAmountOut 在源码中要求大于零;非 delegate 模式下,MetaAggregator 通过 CALL 调用 aggregator,代币看到的调用者还要由实际调用链确定。因此,本案例只有在给出完整 trace、spender 和资金路径后,才能判定为授权转移漏洞。
要把该入口判定为授权转移漏洞,还需列出实际 spender、受害者授权和资金流;现有示例只支持滞留余额路径。两条路径应分开列出。
2.1 漏洞扫描系统设计
把历史交易一笔一笔重新执行,拿到完整的调用树——每一层调用、每个参数都有记录。然后在树里找可操控的点:内部调用的某个参数(转账给谁、转多少、调用哪个合约、带什么数据),如果能一路追溯到交易顶层 calldata 里的原始字节,只能说明这个参数可能受外部提交者影响,还要通过变异重放确认。把这些点逐个替换后重放,验证参数是否确实可控,以及操控能否传导成真实的资金流动。
2.2 扫描执行流程
一笔交易在代码里的完整执行流程见图 1。
2.3 调用参数溯源
扫描器先检查内层参数是否能在顶层 calldata 中找到原始字节。找到时,只能说明该参数在当前编码路径上可由提交者影响,仍需通过变异重放确认。
扫描器做字节级匹配:参数的 32 字节直接出现在顶层对齐位置,或整段调用 calldata 作为连续字节串嵌在顶层,即登记为可由外部参数改变的内部参数并记下偏移;匹配不到时标记为“无法建立参数来源”,不据此断定该值由合约内部算出。变异时改顶层偏移,重放验证内层是否跟随。
当前实现不做语义分析,只做 EVM 层的字节匹配,因此开源和闭源合约都能处理。已知缺口是:合约在内存里重组 calldata 时,参数来源可能无法继续追踪。
2.4 实现
整套系统只有一个核心设计:每笔历史交易,都在它发生前一瞬的世界状态上执行两遍(图 2)。
一笔交易的执行结果,取决于它之前所有交易留下的状态——区块里第 5 笔交易看到的余额,是前 4 笔执行完的余额。重放第 5 笔之前,先把前 4 笔逐笔执行到一个临时状态层(PreState)里,停在第 5 笔开始前。
然后在这份状态上,把这笔交易跑两遍。第一遍原样执行,记录完整过程:调用了哪些合约、转了哪些钱、改了哪些存储。第二遍换掉可操控的参数——发起人、收款人、金额——再执行,看结果有什么不同。两遍都不改这份状态,所以换不同参数可以反复试。
试完之后,把第一遍的结果写进状态层,继续处理下一笔交易。这样区块里的每一笔,都在它当年看到的那个世界上执行。
2.5 排除误报
重放产出的待审核记录以万计,人工逐条看完不可能,绝大多数在复核之前就被流水线筛掉了。图 1 的每个 ✕ 标记一类被筛除的原因,这里展开其中几种。
地址投毒批次 钓鱼者用假币、零值或极小额转账污染地址记录。扫描器会看到多个受害者、官方代币名称和极小额转账等相似形态,因此需要结合代币代码、金额和调用批次排除。
// 批量转账入口(同一代码族的两个 selector)
transfer(address[] from, address[] to, address token, uint256[] amount) // 0xe19c2253
transfer_7211834412884((uint8,address,address,address,uint256)[]) // 0x12514bba
// 受害者列表直接写在 calldata 里,一笔交易打几十个地址实例:0x184aaf087ca6dd17eb1200dbfbee9f671bd906fc。该家族在同轮扫描中产生数百条记录,包含几十个 helper,未发现官方稳定币的非零余额。
仿冒币名 假币攻击的升级——代币名字里混进西里尔字母或零宽字符,肉眼看是 USDT,字节上不是。一律标记。
USDT → 55 53 44 54 (ASCII)
USDТ → 55 53 44 d0a2 (末位是西里尔字母 Т,U+0422)实例:0x9257cf49…f4253、0x6b1b80b4…1ca6cc,同一批地址投毒记录中转出的仿冒 USDT/USDC。
EIP-7702 重放限制 在这组 type-4 重放中,原交易由原发起人携带 authorizationList 执行;换成测试地址并去掉该列表后,交易回滚。这个结果只说明该重放依赖原授权状态,不能推广为所有 EIP-7702 交易都必须携带 authorizationList。扫描器保留这类记录,再用 caller_only 和 dual 两种变体区分真实可执行路径与重放条件导致的回滚。
原始交易 type-4,携带 authorizationList(账户本人签名)→ 执行成功
换人重放 同一 calldata,发起人换成测试地址,无授权列表 → 整笔 revert使用相同字节码的账户组 多个地址运行同一段字节码、使用同一个发起人和归集地址,待审记录来自一组同特征账户。
// 一组账户的共同特征:字节码全部指向同一个委托目标
0xef0100 + 631af7731569e10b22a0430051cfd5c8930a1656 // 多个账户共用
发起人相同 · 归集地址相同实例:0x001100bd…3ff16、0x043d01a9…669d,两个成员的字节码完全相同。
3.1 判定条件
扫描器筛出的漏洞记录按图 3 依次判定。条件一、二用于确认参数可控性和模拟执行是否成功;条件三只区分资金来源:付款方是授权者时标为授权滥用,资金来自公开流动性时标为公开套利。两类记录的后续复核不同,不能直接等同于真实漏洞。
3.2 AI 审核
扫描器产出的待审核记录由 AI 自动复核(图 4)。AI 读取原始交易的调用和资金流,分别做换发起人和换收款人重放。模拟成功只证明替换参数后交易可执行;资金来源、授权关系或调用链证据不足时,记录标为“证据不足,待人工复核”,不自动判为漏洞。
AI 复核结果回写到面板(图 5)。图 5 展示单条记录的五阶段证据链、标签、备注和模拟编号;它不展示全局标签计数。
4.1 已知攻击交易检测
Seneca Protocol Chamber 用于验证扫描器检出的漏洞:该事件有公开分析,相关调用模式也出现在扫描结果中。
Seneca Protocol 于 2024 年 2 月 28 日遭到攻击,损失达数百万美元。BlockSec、SlowMist 和 Rekt 分别分析了这起事件。受影响合约地址为 0x65c210c5…91f06,其 Chamber.sol 源码已在 Etherscan 验证。图 6、图 7 对应的 PT-weETH 重放交易为 0x6da8f7cb…e820d,调用目标为 clone 0xbc83f271…7c05。0x23fcf9d4…01286 是另一笔 PT-rsETH 交易,不与图混用。
漏洞代码如下,省略无关部分:
// 入口:任何人可调用,三个数组参数全部来自 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) {
// 调用者提供目标地址和 calldata,合约解码后直接使用
(address callee, bytes memory callData, ...) = abi.decode(data, (address, bytes, ...));
// callee 只经过黑名单检查
require(!blacklisted[callee], "Chamber: can't call");
// 通过 callee.call 执行外部调用;代币看到的调用者取决于当前调用帧
(bool success, bytes memory returnData) = callee.call{value: value}(callData);
require(success, "Chamber: call failed");
return (returnData, returnValues);
}这与第 1.2 节的 MetaAggregatorSwapContract 属于同一类外部调用风险:调用者把 callee 和 callData 编入顶层 calldata,Chamber 解码后直接使用。在该历史状态中,Chamber 名下保留着用户为管理仓位授予的 ERC-20 授权,因此需要进一步核对实际 spender、受害者授权对象和资金路径,才能判定为授权转移漏洞。
扫描器重放这笔交易,用 call_tracer 记录完整调用树。在 Chamber 的内部调用帧中,它定位到 callee.call(callData),并确认 callee 和 callData 都能追溯到顶层 datas 参数。系统将它们登记为可操控点,再把顶层 calldata 中对应的收款人地址替换为测试地址,在同一历史状态上重放。交易执行成功,测试地址收到 PT-weETH;资金来源和调用链证据另行记录。
图 6 展示这笔 PT-weETH 重放交易的 Invocation Flow Diff,图 7 展示对应的 Fund Flow Diff;两图共同呈现替换收款人前后的调用和资金差异。
thirdweb Pay UniversalGatewayV1(2025 年,以太坊)——公开事件交叉验证
thirdweb 的支付网关 0x81d57dd01a15bc1c19563693902df621500d4d2a 也出现在扫描结果中。thirdweb 的公开报告确认该旧合约存在授权余额风险并已停用。
该历史实现没有可直接核对的验证源码。签名绑定、假代币地址和 callData 的具体关系,需要与对应区块的代理实现和交易 trace 对齐;当前参考源码不能替代历史实现作为结论依据。
该案例的历史调用包含代币地址、转发数据和授权余额之间的组合关系。当前材料不足以把“假代币地址绕过扣款”确定为该历史实现的根因;在补充对应区块的实现代码和完整 trace 前,不将其作为已确认机制。
5.1 扫描器检出的风险路径
以下案例的证据类型不同:部分来自历史交易重放,部分还有源码、审计或事故报告支持。每节只对已列出的证据作判断,并区分扫描结果和外部资料。
案例状态以所引交易的区块状态或明确的查询时间为准;余额、授权和合约是否仍可调用,不从历史交易直接推导当前状态。
一、Maker Multicall2:没有调用者白名单的批量执行器
0x5ba1e12693dc8f9c48aad8770482f4739beed696
// 官方合约,2021 年部署,源码公开
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); // 没有调用者白名单
require(success, "Multicall aggregate: call failed");
returnData[i] = ret;
}
}target 和 callData 全部来自调用者,合约没有调用者白名单校验,但仍检查底层调用是否成功。它是通用批量执行器,风险取决于用户是否把代币授权给了这个执行器;本文样本中的授权和资金路径应按具体交易逐笔核对。
二、THORSwap TSAggregatorGeneric:目标限制不完整
0x0ccd5dd5bcf1af77dc358d1e2f06ee880ef63c3c
function swapIn(
address router, address vault, string calldata memo,
address token, uint amount,
address swapRouter, // 调用目标由参数提供
bytes calldata data, // 调用数据由参数提供
uint deadline
) public nonReentrant {
// 只禁止调用 TTP(THOR 的代币代理)
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); // 其他目标仍可调用
require(success, "failed to swap");
// ……
}当前源码只排除了 TTP,没有把 swapRouter 限制到白名单,因此代币合约仍可作为目标。历史状态重放显示,调用者可以把 swapRouter 和 data 指向代币转账路径;是否构成授权滥用,还需同时核对 spender、授权人和资金来源。
三、Symbiosis MetaRouter:跨链路由的任意 calldata
0xf621fb08bbe51af70e7e0f4ea63496894166ff7f
metaRoute 从网关提取用户授权的代币,并检查 relayRecipient 不是网关且目标地址有代码。随后,源码直接使用调用者提供的 otherSideCalldata 调用 relayRecipient;这里没有按函数选择器建立白名单。
otherSideCalldata 的字节内容没有按函数选择器做白名单校验,目标地址只受网关地址和代码存在性检查约束。扫描器检出的交易中,该参数包含 USDT.transferFrom;如要认定为授权滥用,还需把交易中的 spender、授权人和资金接收方逐项列出。
四、LayerZero LZMultiCall:授权错位与无签名执行入口
0x5528cf58feb8fbfce94f43b33240fffb1312bde3
// 合约同时提供带签名和不带签名的两个 execute 重载
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);
}
}
}原始复核交易走的是两参数的无签名入口,不是带 _signature 的重载。该入口把 msg.sender 作为 signer;只有目标是 TransferDelegate 时,才检查 transfer 的 from 是否等于 signer。若调用列表直接把代币合约作为 target 并放入 token.transferFrom(受害者, 自己, 金额),代码会走通用的 _handleCall,由 LZMultiCall 作为 spender 发起转账。
LayerZero 的正常流程应把授权交给 TransferDelegate;如果用户把授权直接给了 LZMultiCall,公开的无签名 execute 就可能消耗这份额度。这里的风险来自授权对象与通用转发路径的错位,不涉及带签名入口。
五、thirdweb Pay UniversalGatewayV1:证据范围
0x81d57dd01a15bc1c19563693902df621500d4d2a
该案例已在 4.1 作为公开事件交叉验证。公开报告确认旧合约存在授权余额风险并已停用;历史实现的签名绑定、代币地址和 callData 的具体关系,需要与对应区块的代理实现和交易 trace 对齐。本文不使用当前 UniversalBridgeV1 参考源码替代历史实现下定论。
六、BSC BnbSettler:BASIC 任意调用消耗误授权
0x4c6f446dd88fd1be8b80d2940806002777dc12a2
这是 0x Protocol Settler 的 BSC 部署。风险入口由公开的 execute 和 BASIC action 组成,转账路径通过 action 进入代币合约:
// 示意代码,省略 action dispatch 与结算,不能替代完整源码
function execute(
AllowedSlippage calldata slippage,
bytes[] calldata actions,
bytes32
) public payable takerSubmitted returns (bool) {
// actions 由调用者提供,BASIC 在这里分派
// takerSubmitted 只记录 payer,不做调用者认证
}
function basicSellToPool(
IERC20 sellToken, uint256 bps, address pool,
uint256 offset, bytes memory data
) internal {
if (_isRestrictedTarget(pool)) revert ConfusedDeputy(); // 只限制 Permit2
(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(); // 目标和 calldata 可控
}用户若直接把 ERC-20 授权给 Settler,调用者就可以把 pool 填成代币合约,把 data 编成 token.transferFrom(受害者, 自己, 金额)。Settler 以自身身份发起调用,代币会按这份授权执行。历史重放还显示该合约存在可改变收款人的滞留余额;这两条路径应分开计。
表中按主要利用前提归类;“扫描检出的漏洞”“历史重放确认”和“公开事件交叉验证”不是同一个证据等级:
| 形态 | 案例 | 链 | 特征 |
|---|---|---|---|
| 设计取舍 | Multicall2 | ETH | 官方合约,无恶意代码,用户误授权 |
| 补丁不完整 | THORSwap | ETH | 只禁 TTP,其他外部调用目标仍开放 |
| 跨链任意指令 | Symbiosis | ETH | calldata 内容缺少语义白名单,目标有代码存在性检查 |
| 授权错位与无签名转发 | LayerZero | ETH | 误授权给外壳,可绕过 TransferDelegate 的 from 校验 |
| 历史授权余额风险 | thirdweb | ETH | 公开报告确认旧合约已停用,具体历史调用路径待实现和 trace 对齐 |
| BASIC 任意调用 | BnbSettler | BSC | 直接 approve 本体后,BASIC 可调用代币 transferFrom |
这些案例的证据类型不同:部分有历史交易重放,部分只有源码或公开报告。扫描器能直接验证的是:在对应历史状态下,改变顶层可控参数是否仍能把资金导向测试地址;其余结论需要单独标注来源和证据范围。
5.2 漏洞记录汇总
扫描器在以太坊和 BSC 上汇总了超过四百条漏洞记录,涉及三百多个地址。同一地址可能对应多条检测记录,地址中也包括 EIP-7702 委托账户。
以太坊的记录主要集中在 Maker Multicall2;BSC 的记录分布在更多地址,单个地址对应的记录较少。其他记录还涉及 CallDataExecutor、UniswapInterfaceMulticall、SquidMulticall 和 0x Settler 等合约。完整地址清单见附录 A、B。
| 链 | 合约或账户 | 说明 |
|---|---|---|
| 以太坊 | 0x5ba1e126…ed696 | Maker Multicall2(第 5.1 节案例一) |
| 以太坊 | 0xbc83f271…7c05 | Seneca Chamber(第 4.1 节案例) |
| 以太坊 | 0x5724b5bc…81a1 | Swap0x 转发器 |
| 以太坊 | 0x65c210c5…91f06 | Seneca Chamber(另一实例) |
| 以太坊 | 0xd791fcf4…d001 | 废弃合约 |
| 以太坊 | 0x0ccd5dd5…3c3c | THORSwap(第 5.1 节案例二) |
| 以太坊 | 0x81d57dd0…4d2a | thirdweb Pay(第 4.1 节案例) |
| 以太坊 | 0x5528cf58…bde3 | LayerZero LZMultiCall(第 5.1 节案例四) |
| 以太坊 | 0xf621fb08…ff7f | Symbiosis MetaRouter(第 5.1 节案例三) |
| BSC | 0xc17b234f…b9b5 | swapERC20(第 1.2 节案例) |
| BSC | 0x62ccef0b…27dc | smartSwapByOrderId |
| BSC | 0xca11daf5…4ca11 | — |
5.3 Lynx:交易分析与模拟平台
Lynx 是我们自研的交易分析与模拟平台,支持以太坊和 BSC,接口与标准 JSON-RPC 兼容。本文的重放验证和资金流对比通过 Lynx 完成。平台支持基于归档节点历史状态的 eth_call 模拟,可替换发起人、修改 calldata 或覆盖账户余额。
Lynx 通过 debug_traceCall 及相应的 tracer 获取调用树,记录调用目标、calldata 和返回值,供扫描器追踪参数来源;界面同时展示存储变化。平台还分别生成原始交易和模拟交易的资金流图,对比资产的来源、去向和金额。第 4.1 节图 6、图 7 分别展示调用流程和资金流的差异,图 8 展示调用树界面。
每次模拟结果按唯一编号保存,可按编号复查。
结语
本文在 21 亿笔历史交易中扫描出超过一百个漏洞合约,涉及基础设施、跨链路由器、支付网关和工具合约等形态。其中一部分漏洞经历史状态重放确认;Seneca 和 thirdweb 提供了独立的公开事件作为交叉验证。
在已复现的案例中,授权额度在余额变化后仍可能存在,因此监控授权存量可以补充余额事件监控。这个结论不外推到所有案例。
防御建议:
用户:用完就撤销授权,或改用支持过期签名的授权方案(如 Permit2)。授权前确认 spender 对应的是协议预期的执行入口;如果协议通过代理或 Permit2 转发,还要核对实际 spender、目标合约和额度。
协议开发者:任意外部调用入口应限制目标和 selector/data;签名转发入口应把签名内容绑定到调用者和接收者。打补丁时审计所有调用路径;THORSwap 的历史补丁只排除了一个目标,其他目标仍需检查。
行业:可将授权存量纳入持续监控,记录 spender、授权人、区块状态和余额变化。本文展示的是历史重放;持续追踪不在本文范围。