Etherscan 上的未验证合约:从字节码判断是不是代理合约、是不是代币
本页目录
结论先行:即使没有验证过的源码,合约的 runtime bytecode 通常也能说明它是哪一类合约。代理合约很短,几乎没有自己的函数选择器,代码里有 DELEGATECALL;OpenZeppelin 风格的代理还会读取 EIP-1967 实现槽位 0x3608…2bbc,这个槽位会出现在本站 EVM 函数选择器提取工具的存储布局里。代币会暴露 ERC-20 的那一组选择器;除此之外,mint、pause、upgradeToAndCall 这类额外选择器,说明代码里包含哪些特权操作。
这是一篇讲“怎么读懂合约接口”的技术文章。它不评价某个代币好不好,不讨论该不该持有或买卖,也不判断运营方是否可信。代码能告诉你有哪些函数,但不能告诉你别人会怎么用它们。
适合谁读:遇到一个没有验证源码的合约、想在集成或写文章之前先弄清它是什么的开发者、审计人员和学生。
读完你能学到:
- 字节码能透露未验证合约的哪些信息,又藏住了哪些
- EIP-1967 代理、Transparent 代理和 EIP-1167 克隆在字节码上的特征
- 怎么找到实现合约地址,转而分析实现合约
- 怎么把代币的选择器列表当成“能力清单”来读
我实际跑了什么: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 工具的分析代码(src/tools/evm/analyze.ts,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 字节选择器会碰撞。
REA 6.4.0 能做什么
REA(morluto 的开源项目)提供 rea inspect-evm-interface <file> hex --json(MCP 工具 inspect_evm_interface),底层同样是 EVMole 0.9.3。这里有两个限制:
- 只支持 Linux x64。 在我的 Apple Silicon Mac 上,CLI 返回
capability_unavailable:“Initial real-verified offline EVM interface profile supports Linux x64 only.” - 不返回存储布局。 6.4.0 的适配层只请求选择器、参数和可变性,回复的 schema 要求
storage必须不存在。所以 REA 给你函数列表(代理是 0 行)和末尾元数据,但给不出浏览器工具能显示的 EIP-1967 槽位。
我直接运行了 6.4.0 自带的 worker 模块,输入 ERC1967Proxy 和克隆两个样本,结果一致:两者都是 0 个函数,OpenZeppelin 代理有 CBOR 元数据,克隆没有。REA 没有“是不是代理”的判定规则,结论要你自己根据证据得出,Evidence 记录会保存这些依据。REA 的输出字段详见选择器恢复指南。
需要记住的局限
- 选择器碰撞:查表得到的名字只是 4 字节相同,不是证明。表里参数和推断参数不一致时,工具会提示。
- 是推断,不是 ABI:可变性和参数类型都是推断的。我的实现合约里
decimals()被识别为view,而在一个类似的不可升级代币里它是pure。 - 非常规代理:Diamond(EIP-2535)、beacon 代理和手写代理用的是别的槽位或查找逻辑。存储表为空、没有 EIP-1967 槽位,都不能证明它不是代理。
- 代码不等于意图:代码里有某种能力,不代表有人会用、会怎么用。
常见问题
用“反编译器”不是更好吗?
反编译器试图重建逻辑,输出本身是猜测,需要再核实。选择器、存储槽位和操作码更接近字节码里的事实。先从它们入手,剩下解释不了的部分再交给反编译器。
代理合约显示 0 个函数,是工具坏了吗?
不是,代理本来就长这样。去分析实现合约。
不连 RPC 能看到实现地址吗?
EIP-1167 克隆可以,地址就在那 45 个字节里。EIP-1967 代理不行,地址在存储里,需要节点,或者替你读存储的区块浏览器。
下一步
- 从字节码读出 solc 版本和编译设置:元数据尾部怎么工作,以及怎么验证你自己的代理和实现合约。
- 用 REA 离线恢复 EVM 函数选择器:用 solc 的真实输出核对推断出的选择器。
- 用 REA 和 AI agent 解 CTF 逆向题:合约类 CTF 题里也常考代理和存储。
请分析你自己部署的合约、经授权审查的合约,或出于学习目的分析公开合约。本文是技术文档,不构成任何财务或投资建议。请遵守所用 RPC 服务和区块浏览器的条款,并阅读 REA 的 DISCLAIMER.md。