rea-reverse

怎么判断一个合约是不是代理合约?未验证源码也能从字节码看出来

发布者 rea-reverse更新于 约 6 分钟读完
本页目录

即使 Etherscan 上没有验证过的源码,合约的 runtime bytecode 通常也能说明它是哪一类合约。代理合约很短,几乎没有自己的函数选择器,代码里有 DELEGATECALL;OpenZeppelin 风格的代理还会读取 EIP-1967 实现槽位 0x3608…2bbc,这个槽位会出现在本站 EVM 函数选择器提取工具的存储布局里。代币会暴露 ERC-20 的那一组选择器;除此之外,mint、pause、upgradeToAndCall 这类额外选择器,说明代码里包含哪些特权操作。

字节码只能告诉你有哪些函数,说不出别人会怎么用,所以本文是技术分析,不构成投资建议。

我实际跑了什么:2026-10-14,我编译了真实的 OpenZeppelin Contracts 5.6.1(ERC1967Proxy 和 TransparentUpgradeableProxy),以及我自己基于 OpenZeppelin Contracts Upgradeable 5.6.1 写的一个可升级代币,统一用 solc 0.8.37、开优化器、runs 200。另外按 OpenZeppelin Clones.sol 里的字节模板拼了一个 EIP-1167 克隆,实现地址用的是占位值。每份 runtime bytecode 都交给本站 EVM 工具的分析器(EVMole 0.9.3)处理,再和 solc 的操作码清单、method identifiers 对照。全程没有部署任何合约,也没有分析任何真实代币。

字节码能看出什么,看不出什么

只凭 runtime bytecode,可以恢复出:

  • 分发器接受的 4 字节函数选择器,以及推断出的参数类型和可变性
  • 代码读写的存储槽位,以及哪些选择器会访问它们
  • 末尾的编译器元数据(solc 版本、metadata 哈希),见从字节码读 solc 版本
  • 是否有 DELEGATECALL、SELFDESTRUCT、CALL 这类操作码(有源码时 solc 会直接列出,没有时任何反汇编器都能看)

看不出来的:函数名(选择器是哈希,名字只能靠查表)、变量名、注释,以及可读形式的业务逻辑。字节码也说不出谁持有 owner 或 admin 角色,那是链上状态,不是代码。

第 1 步:拿到 runtime bytecode

cast code 0xContract --rpc-url "$RPC_URL" > target.hex

用普通 JSON-RPC 调 eth_getCode 得到的是同样的内容。把十六进制粘贴进 EVM 工具。工具完全在本地运行,自己不访问 RPC 或区块浏览器,所以取代码这一步要你自己做。

第 2 步:它像不像代理合约?

我构造的三种代理,工具给出的结果如下:

样本 大小 识别出的函数 存储布局 元数据
OpenZeppelin ERC1967Proxy 163 字节 0 0x360894a1…382bbc,address solc 0.8.37
OpenZeppelin TransparentUpgradeableProxy 1,059 字节 0 0x360894a1…382bbc,address solc 0.8.37
EIP-1167 最小代理克隆 45 字节 0 无 无
我的代币实现合约(对照) 5,301 字节 20 9 个槽位共 10 条记录 solc 0.8.37

有三点值得注意。

没有选择器。 代理把所有调用都转发给另一个合约,自己没有 ABI 分发器,所以 EVMole 在三个样本里都找到 0 个函数。这和工具页写明的局限一致:“代理合约只显示代理自身的选择器”。Transparent 代理在 admin 调用时确实会检查一个选择器,但这个检查写在 fallback 逻辑里,不是普通的分发器入口。

EIP-1967 槽位会出现在存储布局里。 这是写稿前需要实测确认的问题,答案是会。两个 OpenZeppelin 代理的存储表都列出了槽位 360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc,类型 address;读写两列是空的,因为通向那条 SLOAD 的不是哪个选择器,而是 fallback。这个值就是 EIP-1967 定义的实现槽位 keccak256('eip1967.proxy.implementation') - 1,我用 js-sha3 重新算了一遍,32 字节完全一致。solc 给 ERC1967Proxy 输出的操作码清单里,整个机制只有几条指令:

PUSH32 0x360894A13BA1A3210667C828492DB98DCA3E2076CC3735A920A3CA505D382BBC SLOAD ...
CALLDATASIZE PUSH0 PUSH0 CALLDATACOPY PUSH0 PUSH0 CALLDATASIZE PUSH0 DUP5 GAS DELEGATECALL

从槽位读出实现地址,复制调用数据,然后委托调用。EIP-1967 的另外两个槽位,admin(0xb531…6103)和 beacon(0xa3f0…3d50),都没有出现。OpenZeppelin 5 的 Transparent 代理把 admin 放在 immutable 变量里,源码注释说是为了“to avoid unnecessary SLOADs”;admin 槽位只在部署时写一次,运行时代码从不读取它。

克隆没什么可分析的,这本身就是特征。 EIP-1167 代码一共 45 字节:363d3d373d3d3d363d73,接着是 20 字节的实现地址,最后是 5af43d82803e903d91602b57fd5bf3。没有存储,没有元数据,也没有选择器。看到这组固定的开头和结尾,中间那 20 个字节就是实现地址,不用读存储。

Etherscan 自己也会标记代理。它的代理合约说明写道,它会查找“identifiable bytecode patterns associated with well-known proxy contract implementations”;同时提醒,检测到的 delegatecall “may not even be reachable”,而使用了含 delegatecall 的库的合约也可能被误判成代理。自己读字节码时也是同样的道理:DELEGATECALL 只是值得细看的理由,不是结论。

第 3 步:找到实现合约再分析

EIP-1967 代理的实现地址存在存储里,而不是代码里。读取这个槽位:

cast storage 0xProxy 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --rpc-url "$RPC_URL"

cast storage(文档)返回原始的 32 字节,地址是最后 20 字节;JSON-RPC 的对应方法是 eth_getStorageAt。本文没有对真实链执行这两条命令。拿到地址后,再用 cast code 取实现合约的代码去分析,真正的接口在那里。

我写的实现合约(UUPS、ERC-20、可暂停、带 owner),工具找到 20 个函数。下面是它们和离线表匹配到的名字,我把表格排成了两列:

0x06fdde03 name()                         0x5c975abb paused()
0x095ea7b3 approve(address,uint256)       0x70a08231 balanceOf(address)
0x18160ddd totalSupply()                  0x715018a6 renounceOwnership()
0x23b872dd transferFrom(address,address,uint256)
0x313ce567 decimals()                     0x8456cb59 pause()
0x3f4ba83a unpause()                      0x8da5cb5b owner()
0x40c10f19 mint(address,uint256)          0x95d89b41 symbol()
0x4f1ef286 upgradeToAndCall(address,bytes) 0xa9059cbb transfer(address,uint256)
0x52d1902d proxiableUUID()                0xad3cb1cc UPGRADE_INTERFACE_VERSION()
0xc4d66de8 (unknown)                      0xdd62ed3e allowance(address,address)
                                          0xf2fde38b transferOwnership(address)

0xc4d66de8 不在页面的离线表里。按我源码的 method identifiers,它是 initialize(address),没有源码的读者就得自己去查这种“未知”项。存储表里又出现了实现槽位,这次它被 0x4f1ef286(upgradeToAndCall)写入,这直接证明了这个函数会更换代理背后的代码。其余槽位是 OpenZeppelin 的 ERC-7201 命名空间存储位置,我和 OpenZeppelin 源码里的常量逐一对过:0x52c63247…ace00(ERC-20 的余额、授权额度、总量、名称、符号)、0x9016d09d…9300(Ownable)、0xcd5ed15c…3300(Pausable)、0xf0c57e16…a00(Initializable)。

第 4 步:把代币的选择器读成能力清单

ERC-20 代币有六个必需函数:totalSupply、balanceOf、transfer、transferFrom、approve、allowance,大多数还有可选的 name、symbol、decimals。六个必需选择器都在,基本可以认定是 ERC-20,或者是故意做成像 ERC-20 的东西。ERC-721 和 ERC-1155 也有各自常见的选择器,工具的离线表里都有。

真正有信息量的是“还有什么”。把每个额外选择器当成能力清单里的一行,再对照它写了哪些槽位:

看到的选择器 代码能做什么 存储表里的证据
mint(address,uint256) 增发代币 写入余额和总量
pause() / unpause() 暂停、恢复转账 写入 Pausable 标志,transfer 和 transferFrom 会读它
upgradeToAndCall(address,bytes) 替换代理背后的合约逻辑 写入 EIP-1967 槽位
transferOwnership / renounceOwnership 转移或放弃特权角色 写入 Ownable 槽位

在我的合约里,这些函数都是 onlyOwner,字节码里也能看到 mint 和 pause 在执行前先读 Ownable 槽位。但字节码说不出 owner 是谁;有 renounceOwnership 选择器,也不代表角色已经被放弃。要知道这些,得从节点读当前状态(比如 owner() 的返回值),并阅读已有的验证源码。blacklist、setFee 这类名字的函数不在我的样本里。遇到未知选择器,可以去公开签名库查,但要把匹配结果标成假设,因为 4 字节选择器会碰撞。

先用自己编译的合约校准

在未知合约上相信推断出的选择器之前,先在自己编译的合约上跑一遍,这时 solc 能给出标准答案。solc --hashes 会生成 <合约名>.signatures,在 Function signatures: 标题下每行一个 选择器: 签名(新版 solc 还会附上 error 和 event 段,可以忽略)。把工具推断出的选择器存成 inferred.txt,每行一个、不带 0x,然后比对:

awk '/^Function signatures:/{f=1; next} /^$/{f=0} f' out/MyToken.signatures | cut -d: -f1 | sort > truth.txt
sort -o inferred.txt inferred.txt
comm -3 truth.txt inferred.txt     # 没有输出 = 选择器集合一致

可变性(mutability)也用同样的方法对照 ABI 里的 stateMutability。可变性是推断出来的,相似代码的不同构建之间也可能不一样(见下文局限里的 decimals()),solc 标为 view 的函数不一定被推断成 view。去掉 --optimize 重新编译,就能看出编译设置的影响有多大。

需要记住的局限

  • 选择器碰撞:查表得到的名字只是 4 字节相同,不是证明。表里参数和推断参数不一致时,工具会提示。
  • 是推断,不是 ABI:可变性和参数类型都是推断的。我的实现合约里 decimals() 被识别为 view,而在一个类似的不可升级代币里它是 pure。
  • 非常规代理:Diamond(EIP-2535)、beacon 代理和手写代理用的是别的槽位或查找逻辑。存储表为空、没有 EIP-1967 槽位,都不能证明它不是代理。
  • 代码不等于意图:代码里有某种能力,不代表有人会用、会怎么用。

常见问题

用“反编译器”不是更好吗?

反编译器试图重建逻辑,输出本身是猜测,需要再核实。选择器、存储槽位和操作码更接近字节码里的事实。先从它们入手,剩下解释不了的部分再交给反编译器。