3847 words
19 minutes
从零理解 EVM 相似合约扫描:比较维度、权重设计与合约发现方法
2026-08-30
No Tags

扫描相似合约,表面上是在回答“这两个合约像不像”,实际上需要同时处理三个问题:代码是否相似、行为是否相似,以及它是否来自可信的部署来源。如果只比较一段字节码,结果很容易出现误报或漏报。

这篇文章面向 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_traceBlockByNumber
debug_traceTransaction
trace_block

trace 能找到执行过程中出现的 CREATECREATE2,也能看到调用路径和 init code,是发现内部部署的重要手段。

它的缺点是请求较重、公共 RPC 经常不开放,而且不同节点客户端的返回格式可能不同。

监听已知 factory#

如果已经知道项目使用哪个 factory,可以监听 factory 的事件、调用交易或 CREATE2 参数。

这种方式速度快、请求少、身份置信度也比较高,但只能覆盖已知 factory。EVM 本身不存在一个所有合约都必须发送的“合约已创建”标准事件。

使用区块浏览器或索引器#

区块浏览器 API、Subgraph 或链上数据服务通常能直接提供合约地址、创建者、ABI 和验证源码。

这对历史查询很方便,但可能有延迟、限流或数据缺失,不适合作为自动执行交易前的唯一数据源。

自建归档节点和状态索引#

大型扫描系统可以分析每个区块执行前后的状态变化,找出新出现代码的账户。

它的覆盖率很高,但需要归档节点、数据库和持续索引,建设成本也是最高的。

找到地址后,需要获取哪些材料#

数据常用方法用途
部署后代码eth_getCode获取真正执行的 runtime bytecode
顶层部署交易eth_getTransactionByHash获取创建码和构造参数
交易结果eth_getTransactionReceipt确认成功、区块和 contractAddress
合约 gettereth_call读取名称、价格、owner、开关等
存储槽eth_getStorageAt读取代理 implementation、admin 等
历史事件eth_getLogs获取业务行为和升级记录
内部部署trace 接口获取内部 CREATE/CREATE2

建议每个候选都保存 chainId、地址、部署交易、区块号、区块 hash、创建者、init code hash、runtime code hash、代理 implementation 和各个维度的评分。

应该从哪些方面比较#

部署后运行时代码#

eth_getCode 得到的是合约真正执行的 runtime bytecode,通常是最重要的比较对象。

常见比较方法有三种:

  1. 原始字节码哈希:必须完全相同,适合寻找精确克隆。
  2. 去除 Solidity metadata 后比较:忽略编译器附加信息。
  3. opcode 序列比较:把字节码拆成 EVM 指令后计算编辑距离或序列相似度。

如果删除所有 PUSH1PUSH32 后面的参数,可以忽略部署地址和普通常量变化,但也可能忽略手续费、管理员地址和外部调用地址等安全相关修改。

推荐同时计算两个分数:

  • 保留 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/opcode25%40%15%20%
创建码和初始化逻辑10%20%5%5%
ABI、selector、event、error15%10%15%20%
控制流 CFG10%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

这些数字只是起点。正式系统应该准备已知正样本和负样本,观察误报率、漏报率,再调整权重。

可信度要单独评分#

身份证据权重
官方部署者或 factory30%
proxy implementation 来源25%
owner、admin、treasury 等角色20%
源码可以复现编译15%
官方 registry、签名或 allowlist10%

不要把身份分直接混进相似度。否则陌生地址部署的完美克隆会因为可信度低而显得“不相似”,反而不利于发现高仿合约。

缺少数据时怎样计算#

某些节点没有 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 的 runtime
admin、owner、beacon 或 facet 配置

升级型代理还需要监听 implementation storage slot 和升级事件的变化。

链重组和 RPC 不一致#

实时扫描不能把第一次看到的区块当成永久结果。链可能发生重组,同一高度的区块 hash 也可能改变。

候选进入通知或自动执行前,应再次读取部署区块并确认:

  • 区块号和区块 hash 仍然一致。
  • 交易仍然存在并且成功。
  • 合约地址仍然有代码。
  • 多个 RPC 对规范链结果有足够一致意见。

RPC 在新区块阶段暂时返回空代码或空回执,也不一定表示部署失败。应做短暂重试,并区分“明确不匹配”和“暂时无法读取”。

初学者最容易踩的坑#

  • 只比较原始字节码,导致 compiler metadata 一变就漏报。
  • 删除全部 PUSH 参数,导致恶意地址和费率修改被忽略。
  • 只检查 selector,没有验证函数能否真正调用。
  • 只比较名称和 symbol,任何恶意合约都可以伪造。
  • 只扫描 to == null,漏掉 factory 的 CREATE/CREATE2。
  • 只比较代理壳,没有解析 implementation。
  • 只读取 latest 状态,看不到部署时的初始配置。
  • 不处理重组,对已经消失的合约报警。
  • 把相似度当成安全分或官方身份分。
  • 只保存最终总分,事后无法解释为什么匹配。

推荐的实现顺序#

如果第一次做扫描器,不需要一开始就实现所有高级功能。

第一阶段:可用的最小版本#

  1. 扫描新区块中的顶层部署交易。
  2. 使用 eth_getCode 获取 runtime。
  3. 去除 Solidity metadata。
  4. 比较代码长度、opcode 和 selector。
  5. 调用少量关键 getter。
  6. 保存区块 hash,并在报警前检查重组。
  7. 输出每个维度的分数和差异原因。

第二阶段:降低误报#

  1. 加入构造参数解析。
  2. 分离相似度和身份可信度。
  3. 增加关键函数的 eth_call 模拟。
  4. 检测 EIP-1167、EIP-1967 和 UUPS 代理。
  5. 加入证据覆盖率。

第三阶段:提高覆盖率#

  1. 增加 trace,发现内部 CREATE/CREATE2。
  2. 建立历史合约索引。
  3. 分析 CFG 和 storage layout。
  4. 接入源码验证和可复现编译。
  5. 用正负样本校准权重和阈值。

总结#

一套可靠的相似合约扫描系统,核心不是寻找某个万能算法,而是组合多层证据:

发现部署
→ 确认规范链
→ 识别代理
→ 获取代码和状态
→ 执行硬条件过滤
→ 计算相似度
→ 计算可信度
→ 标注证据覆盖率
→ 决定提醒、复核或自动操作

对初学者来说,最重要的三个原则是:

  1. 不要只比较原始字节码。
  2. 不要把相似度当成可信度或安全性。
  3. 不要让加权平均分掩盖关键条件失败。

先从顶层部署、runtime、selector、关键 getter 和重组确认做起,就能形成一个可靠的基础版本;随后再逐步加入代理解析、trace、行为模拟和样本校准。

从零理解 EVM 相似合约扫描:比较维度、权重设计与合约发现方法
https://lilac.lat/posts/evm-similar-contract-scanning-guide/
Author
Lorem Ipsum
Published at
2026-08-30
License
CC BY-NC-SA 4.0