扫描相似合约,表面上是在回答“这两个合约像不像”,实际上需要同时处理三个问题:代码是否相似、行为是否相似,以及它是否来自可信的部署来源。如果只比较一段字节码,结果很容易出现误报或漏报。
这篇文章面向 EVM 基础不深的读者,从合约如何被发现开始,逐步解释创建码、运行时代码、selector、代理合约、链重组和权重配置,最后给出一套可以直接用于项目设计的评分框架。
先明确:你想找的是哪一种“相似”
不同目标需要完全不同的判断标准。
- 完全克隆:代码基本没有变化,只是重新部署。
- 同项目新版本:大部分业务逻辑相同,但修复了漏洞或调整了功能。
- 功能仿制品:代码写法不同,但对外功能和行为相似。
- 钓鱼或高仿合约:名称、接口和页面表现接近官方项目,但部署者和权限配置不同。
- 模板生成的同类合约:来自同一个工厂或模板,但属于不同项目。
相似不等于可信
一个合约与官方合约越相似,并不代表它越安全。高相似度加上陌生部署者、陌生 owner,反而可能是需要重点关注的高仿合约。
因此,一个通用扫描器最好不要只输出一个总分,而应至少输出三个值:
- 相似度 S:代码、接口、控制流、行为和状态有多接近。
- 可信度 T:部署者、factory、owner、implementation 等身份信息是否可信。
- 证据覆盖率 C:本次扫描实际拿到了多少可用证据。
| 相似度 | 可信度 | 可能含义 |
|---|---|---|
| 高 | 高 | 官方重新部署或可信升级 |
| 高 | 低 | 高仿、钓鱼或未经授权的克隆 |
| 低 | 高 | 官方大版本升级,需要人工检查 |
| 低 | 低 | 通常与目标项目无关 |
合约是怎样部署出来的
理解部署方式,是理解扫描方式的第一步。
顶层 CREATE
用户直接发送一笔部署交易,这类交易的 to 字段为空。合约地址由发送者地址和 nonce 推导。
这是最容易扫描的部署方式。获取完整区块后,筛选 transaction.to == null 即可找到候选交易。
内部 CREATE
用户调用一个 factory,factory 在执行过程中使用 CREATE 创建新合约。此时交易的 to 是 factory 地址,不为空。
只扫描顶层交易会漏掉这种部署。通常必须使用节点的执行 trace 才能看见内部 CREATE。
CREATE2
CREATE2 根据 factory 地址、salt 和 init code hash 计算合约地址,因此可以在部署前预测地址。它经常用于确定性部署、智能账户和各种 factory。
只检查 to == null 同样会漏掉这类合约。可以通过 trace、已知 factory 的调用参数或部署事件发现它们。
获取新合约的常见方式
扫描新区块
订阅 newHeads 或轮询最新区块,再通过 eth_getBlockByNumber 获取完整交易。
优点是普通 RPC 就能使用,数据可靠性较高,也能取得顶层部署交易的创建码。缺点是只能直接发现顶层部署,并且必须处理链重组。
监听 pending 交易
通过 newPendingTransactions 订阅交易池,可以在交易打包前发现部署请求。
它的延迟最低,但 pending 交易可能回滚、被替换或永远不上链;私有交易也可能根本不会进入公共 mempool。因此 pending 结果只能作为预警,不能直接作为最终结论。
使用执行 trace
常见方法包括:
debug_traceBlockByNumberdebug_traceTransactiontrace_blocktrace 能找到执行过程中出现的 CREATE 和 CREATE2,也能看到调用路径和 init code,是发现内部部署的重要手段。
它的缺点是请求较重、公共 RPC 经常不开放,而且不同节点客户端的返回格式可能不同。
监听已知 factory
如果已经知道项目使用哪个 factory,可以监听 factory 的事件、调用交易或 CREATE2 参数。
这种方式速度快、请求少、身份置信度也比较高,但只能覆盖已知 factory。EVM 本身不存在一个所有合约都必须发送的“合约已创建”标准事件。
使用区块浏览器或索引器
区块浏览器 API、Subgraph 或链上数据服务通常能直接提供合约地址、创建者、ABI 和验证源码。
这对历史查询很方便,但可能有延迟、限流或数据缺失,不适合作为自动执行交易前的唯一数据源。
自建归档节点和状态索引
大型扫描系统可以分析每个区块执行前后的状态变化,找出新出现代码的账户。
它的覆盖率很高,但需要归档节点、数据库和持续索引,建设成本也是最高的。
找到地址后,需要获取哪些材料
| 数据 | 常用方法 | 用途 |
|---|---|---|
| 部署后代码 | eth_getCode | 获取真正执行的 runtime bytecode |
| 顶层部署交易 | eth_getTransactionByHash | 获取创建码和构造参数 |
| 交易结果 | eth_getTransactionReceipt | 确认成功、区块和 contractAddress |
| 合约 getter | eth_call | 读取名称、价格、owner、开关等 |
| 存储槽 | eth_getStorageAt | 读取代理 implementation、admin 等 |
| 历史事件 | eth_getLogs | 获取业务行为和升级记录 |
| 内部部署 | trace 接口 | 获取内部 CREATE/CREATE2 |
建议每个候选都保存 chainId、地址、部署交易、区块号、区块 hash、创建者、init code hash、runtime code hash、代理 implementation 和各个维度的评分。
应该从哪些方面比较
部署后运行时代码
eth_getCode 得到的是合约真正执行的 runtime bytecode,通常是最重要的比较对象。
常见比较方法有三种:
- 原始字节码哈希:必须完全相同,适合寻找精确克隆。
- 去除 Solidity metadata 后比较:忽略编译器附加信息。
- opcode 序列比较:把字节码拆成 EVM 指令后计算编辑距离或序列相似度。
如果删除所有 PUSH1 到 PUSH32 后面的参数,可以忽略部署地址和普通常量变化,但也可能忽略手续费、管理员地址和外部调用地址等安全相关修改。
推荐同时计算两个分数:
- 保留 PUSH 参数的严格分数。
- 忽略 PUSH 参数的结构分数。
当结构分数很高、严格分数明显较低时,应进一步检查到底有哪些常量被修改。
创建码和构造参数
部署交易 input 通常可以理解为:
创建代码 + ABI 编码的构造参数创建代码包含初始化和返回 runtime 的逻辑;构造参数可能包含初始 owner、treasury、priceFeed、baseURI、价格和供应量。
创建代码适合放进相似度;owner、treasury 等身份参数更适合放进可信度。不要因为构造参数不同,就直接判断业务代码完全不同。
ABI、函数 selector 和事件 topic
函数 selector 是函数签名 Keccak-256 的前 4 字节。例如:
transfer(address,uint256) → a9059cbb可以比较函数 selector、event topic、custom error、参数结构和返回值。集合之间可以使用 Jaccard 相似度:
Jaccard = 交集数量 ÷ 并集数量不过 selector 只有 4 字节,理论上存在碰撞;某个 selector 出现在字节码中,也不能证明该函数真的可以正常调用。因此接口匹配只能作为一层证据。
控制流结构
控制流图通常简称 CFG。它把 opcode 划分为基本块,再分析它们之间的跳转关系。
CFG 能帮助发现新增隐藏分支、修改权限判断、增加外部调用和改变存储逻辑。它比普通字节序列更接近程序结构,但分析成本也更高,适合作为第二阶段精查。
行为比较
对参考合约和候选合约执行相同的只读调用,然后比较结果,例如:
name()、symbol()- owner、admin、treasury
- 价格和最大供应量
- paused、mintingActive
- 报价函数
- 非法参数是否以相同错误回滚
关键写入函数可以使用 eth_call 做模拟,不必广播交易。
行为比较非常重要,因为代码写法不同的两个合约仍可能实现相同功能。不过少量测试输入不能证明所有行为完全一致,所以它仍然只是证据之一。
存储布局和链上配置
可以比较关键 storage slot、角色地址、价格源、白名单根、手续费、最大供应量和初始化版本。
业务配置通常属于相似度;owner、admin、implementation、treasury 等身份字段更适合计入可信度。
源码和编译信息
如果合约已经验证,可以进一步比较标准化源码、AST、编译器版本、optimizer 设置、library 地址和可复现编译结果。
这是非常有价值的证据,但不适合作为必要条件,因为大量链上合约没有验证源码。
一套通用权重模板
| 比较维度 | 通用扫描 | 完全克隆 | 功能仿制 | 钓鱼高仿 |
|---|---|---|---|---|
| runtime/opcode | 25% | 40% | 15% | 20% |
| 创建码和初始化逻辑 | 10% | 20% | 5% | 5% |
| ABI、selector、event、error | 15% | 10% | 15% | 20% |
| 控制流 CFG | 10% | 10% | 15% | 10% |
| 只读调用和模拟行为 | 20% | 5% | 30% | 20% |
| 存储布局及业务配置 | 15% | 10% | 15% | 20% |
| 源码和编译信息 | 5% | 5% | 5% | 5% |
通用相似度可以写成:
S = runtime × 0.25 + creation × 0.10 + interface × 0.15 + CFG × 0.10 + behavior × 0.20 + state × 0.15 + source × 0.05这些数字只是起点。正式系统应该准备已知正样本和负样本,观察误报率、漏报率,再调整权重。
可信度要单独评分
| 身份证据 | 权重 |
|---|---|
| 官方部署者或 factory | 30% |
| proxy implementation 来源 | 25% |
| owner、admin、treasury 等角色 | 20% |
| 源码可以复现编译 | 15% |
| 官方 registry、签名或 allowlist | 10% |
不要把身份分直接混进相似度。否则陌生地址部署的完美克隆会因为可信度低而显得“不相似”,反而不利于发现高仿合约。
缺少数据时怎样计算
某些节点没有 trace,某些合约没有验证源码。如果直接把拿不到的数据记为零分,会无故降低相似度。
更合理的方式是重新归一化已经取得的维度,同时输出覆盖率:
相似度 S = 已获取维度的加权平均覆盖率 C = 已获取维度权重 ÷ 总权重例如:
相似度:94证据覆盖率:55%它表示现有证据显示高度相似,但整体证据还不够完整。
权重之外还需要硬条件
有些条件不应该参加加权,而应该直接拒绝:
- 部署交易已经回滚。
- 合约代码为空。
- 部署区块已经被链重组移除。
- 代理合约无法解析 implementation。
- 缺少业务必需的关键函数。
- 关键 getter 返回格式异常。
- 自动交易场景下,价格、权限或暂停状态不满足要求。
不要让平均分掩盖关键失败
合约即使 runtime 很相似,如果关键铸造函数无法调用,也不应该仅凭总分超过阈值就进入自动交易。
阈值应该怎样设置
可以先使用下面的经验值:
- 90 分及以上:高度相似。
- 75 到 90 分:疑似相似,需要人工复核。
- 75 分以下:通常认为不相似。
不同业务需要不同取舍:
- 只发送提醒时,更看重不漏报,可以设置为 75 到 85。
- 自动执行交易时,更看重不误报,建议设置为 92 到 97,并增加硬校验。
- 寻找完全克隆时,优先使用去除 metadata 后的代码哈希。
最终阈值应通过正负样本进行校准,而不是只凭感觉决定。
代理合约必须单独处理
常见代理包括:
- EIP-1167 最小代理
- EIP-1967
- UUPS
- Beacon Proxy
- Diamond/EIP-2535
如果只比较代理壳的 runtime,大量业务完全不同的代理可能都会显示接近 100% 相似。
正确做法是分别记录和比较:
代理壳代码implementation 地址implementation 的 runtimeadmin、owner、beacon 或 facet 配置升级型代理还需要监听 implementation storage slot 和升级事件的变化。
链重组和 RPC 不一致
实时扫描不能把第一次看到的区块当成永久结果。链可能发生重组,同一高度的区块 hash 也可能改变。
候选进入通知或自动执行前,应再次读取部署区块并确认:
- 区块号和区块 hash 仍然一致。
- 交易仍然存在并且成功。
- 合约地址仍然有代码。
- 多个 RPC 对规范链结果有足够一致意见。
RPC 在新区块阶段暂时返回空代码或空回执,也不一定表示部署失败。应做短暂重试,并区分“明确不匹配”和“暂时无法读取”。
初学者最容易踩的坑
- 只比较原始字节码,导致 compiler metadata 一变就漏报。
- 删除全部 PUSH 参数,导致恶意地址和费率修改被忽略。
- 只检查 selector,没有验证函数能否真正调用。
- 只比较名称和 symbol,任何恶意合约都可以伪造。
- 只扫描
to == null,漏掉 factory 的 CREATE/CREATE2。 - 只比较代理壳,没有解析 implementation。
- 只读取 latest 状态,看不到部署时的初始配置。
- 不处理重组,对已经消失的合约报警。
- 把相似度当成安全分或官方身份分。
- 只保存最终总分,事后无法解释为什么匹配。
推荐的实现顺序
如果第一次做扫描器,不需要一开始就实现所有高级功能。
第一阶段:可用的最小版本
- 扫描新区块中的顶层部署交易。
- 使用
eth_getCode获取 runtime。 - 去除 Solidity metadata。
- 比较代码长度、opcode 和 selector。
- 调用少量关键 getter。
- 保存区块 hash,并在报警前检查重组。
- 输出每个维度的分数和差异原因。
第二阶段:降低误报
- 加入构造参数解析。
- 分离相似度和身份可信度。
- 增加关键函数的
eth_call模拟。 - 检测 EIP-1167、EIP-1967 和 UUPS 代理。
- 加入证据覆盖率。
第三阶段:提高覆盖率
- 增加 trace,发现内部 CREATE/CREATE2。
- 建立历史合约索引。
- 分析 CFG 和 storage layout。
- 接入源码验证和可复现编译。
- 用正负样本校准权重和阈值。
总结
一套可靠的相似合约扫描系统,核心不是寻找某个万能算法,而是组合多层证据:
发现部署→ 确认规范链→ 识别代理→ 获取代码和状态→ 执行硬条件过滤→ 计算相似度→ 计算可信度→ 标注证据覆盖率→ 决定提醒、复核或自动操作对初学者来说,最重要的三个原则是:
- 不要只比较原始字节码。
- 不要把相似度当成可信度或安全性。
- 不要让加权平均分掩盖关键条件失败。
先从顶层部署、runtime、selector、关键 getter 和重组确认做起,就能形成一个可靠的基础版本;随后再逐步加入代理解析、trace、行为模拟和样本校准。