智能合约可升级代理到底算不算后门?从 EIP-1967 槽位、storage 冲突到 UUPS 和 Transparent 选型的踩坑记录
起因是我在 Etherscan 上找不到 borrow 函数
2023 年 7 月的一个下午,我在看一个借贷协议,从 DeFiLlama 点进合约地址,在 Etherscan 的 Contract 标签下面翻了半天,只看到 fallback、admin、implementation 这么几个东西,borrow、repay、liquidate 一个都没有。我盯着那个地址看了十几秒才反应过来,这是个代理合约。
更尴尬的是那次 Read as Proxy 按钮没出来(后来知道是 Etherscan 当时还没给这个较老的代理做识别),我只能自己动手:先拿 cast storage 去读 EIP-1967 规定的实现地址槽位,把 32 字节的返回值后面 40 位十六进制切出来,拼成地址,再回 Etherscan 上打开那个实现合约,borrow 函数这才露出来。
这事儿之后我养成一个习惯:拿到任何一个 DeFi 合约地址,第一件事不是看它的业务逻辑,是先确认它是不是代理、实现地址是哪个、谁能换实现。因为后面这一串决定了我到底在看一份写死在链上的代码,还是在看一份随时可能被替换掉的草稿。
代理模式是被 The DAO 和 Parity 逼出来的
很多人现在一上手就是 OpenZeppelin 的 TransparentUpgradeableProxy,已经忘了这东西最初是解决什么问题的。2016 年 6 月 The DAO 被重入攻击,360 万枚 ETH 被卷走,当时价值约 5000 万美元,最后只能靠以太坊硬分叉回滚,分出 ETH 和 ETC。这件事之后行业里有个共识:合约部署上去就改不了,改不了意味着你写错一个字,几千万美元就没了。代理模式就是为了在「不可篡改」和「能修 bug」之间开个口子。
但开口子本身也出过事,而且是教科书级别的事。Parity 多签钱包在 2017 年 7 月被拿走 15.3 万枚 ETH,约 3000 万美元;四个月后的 11 月 6 日,一个 GitHub ID 叫 devops199 的人调用了那个钱包库合约里没人调用过的 initWallet,把自己设成了 owner,然后执行 kill,库合约自毁。因为它是一个被所有钱包通过 delegatecall 共享调用的库,后果是 587 个钱包、约 51.4 万枚 ETH 被彻底冻死,到今天还锁在里面。
这个案例值得反复看的点在于:delegatecall 执行的是对方的代码,但读写的是自己的存储。所以库合约里一个 owner 变量,在调用方钱包的存储里就是 slot 0,改的就是对方的 slot 0。代理模式的全部风险,基本都长在这句话上面。
EIP-1967 那两个槽位,以及 storage 冲突是怎么发生的
最简单的代理长这样:代理合约自己有个 address implementation 存在 slot 0,用户调用时 fallback 里 delegatecall 到实现合约。问题立刻来了——实现合约的第一个状态变量也在 slot 0。如果实现的第一个变量是 address owner,那代理的 implementation 就被覆盖了;反过来也一样。变量顺序、类型、继承关系,任何一点错位,写进去的数据就是一团乱码。
EIP-1967 就是来终结这个混乱的,做法很干脆:别用 slot 0 了,用两个伪随机槽位。
- 实现地址:
keccak256('eip1967.proxy.implementation') - 1=0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc - 管理员地址:
keccak256('eip1967.proxy.admin') - 1=0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 - Beacon 模式另有一个
0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50
翻译成人话:只要你在实现合约里别手贱去写 assembly 直接碰这几个槽,变量怎么排都不会撞。读起来的命令是这样:
cast storage 0x代理合约地址 \
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc \
--rpc-url https://eth-mainnet.g.alchemy.com/v2/你的KEY
返回 32 字节十六进制,去掉前面的 24 个 0,剩下的就是实现合约地址。同一个套路换 admin 的槽位,能读出管理员地址,这是我排查时看得第二个东西。
Transparent 和 UUPS 到底怎么选
目前主流就两条路,OpenZeppelin 都实现了,但它们的取舍完全相反。
Transparent 的思路是「升级逻辑放在代理里」。代理的 fallback 第一件事是判断 msg.sender == admin:是管理员,就走代理自己的升级函数;不是,才 delegatecall 转发给实现。这样做的代价有两个:一是每次调用都多一次 SLOAD 判断,实测比 UUPS 每次贵两千 gas 上下;二是管理员不能用实现合约里的函数,一调就被代理自己的逻辑拦下来了,这个坑很多人第一次遇到会一脸问号。
UUPS 反过来,「升级逻辑放在实现里」,代理的 fallback 只负责无脑转发。好处是省掉那个判断、gas 便宜、代码量小;坏处是——如果某次升级忘了让新的实现继续继承 UUPSUpgradeable,升级函数就没了,这份合约从此永久锁死,谁也改不了。OpenZeppelin 在 4.1 之后把 _authorizeUpgrade 改成必须显式实现的抽象函数,就是为了防这个昏招。
还有一个很隐蔽的坑是函数选择器冲突:代理自己也有函数(比如 upgradeTo(address),选择器 0x3659cfe6),如果实现合约里恰好有个函数的选择器也是这四字节,调用就会被代理截胡,转发不出去。OpenZeppelin 文档里拿 collate_propagate_storage(bytes16) 举过这个例子。我的判断是:如果项目方对升级没把握、团队规模小,Transparent 更省心;如果 gas 敏感、团队 CICD 里有代理检查流程,UUPS 更合适。别看别人用什么就跟什么。
升级权限才是真正的地雷区
技术选型这些事其实都不难,真正让我夜里睡不着的,是那个 admin 槽位里躺着的是谁。
我做过一个很粗糙的统计:随手翻几十个主流项目的代理合约,admin 是多签的有,但 admin 直接是一个 EOA 的也不少。一个 EOA,意味着一个人的私钥泄露就能换掉整份实现合约。Ronin 桥 2022 年 3 月出事,损失 17.36 万 ETH 加 2550 万 USDC,约 6.24 亿美元,根子上是 9 个验证者里 5 个私钥拿到了;Wormhole 2022 年 2 月被卷走约 3.2 亿美元,是 Solana 侧验证指令没校验传入的 guardian 账户是不是当前有效的;Nomad 2022 年 8 月丢约 1.9 亿美元,更简单,初始化时把可信根设成了 0x00,等于宣布「任何消息都合法」,然后一堆人照着别人的交易改个地址批量复制。
这三件事没有一件是 Solidity 语法问题。它们全都是信任假设问题。可升级代理把「代码即法律」悄悄换成了「多签即法律」,而绝大多数用户在存钱之前根本没看过那个多签是谁。
我自己的观点:把可升级性拆成三层
我认为「可升级」不该是一个二元选项,而应该按钱的责任大小切三层。
第一层是不可变的核心:清算公式、抵押率计算、资金出入口。这些东西一旦上线就不该再动,因为用户就是冲着这套规则来的,改了这个,等于换了个产品。第二层是可升级的外围:利率模型参数、预言机路由、辅助工具合约,这些改起来只影响效率不影响本金安全,用 UUPS 或者 Diamond 的 facet 都行。第三层是升级动作本身,它必须带延迟和退出窗口——timelock 我给的底线是 48 小时,因为用户需要时间看到治理提案、判断好坏、决定跑不跑。那种 announce 和 execute 在同一个区块内完成的升级,在我看来跟没有 timelock 没区别。
顺带说一句 EIP-2535 钻石标准。它确实解决了单份实现合约 24KB 字节码上限的问题,也让升级颗粒度可以细到单个 facet,但它在信任层面一点都没变好——facet 依然是可替换的,只是换法更灵活了。别把工程便利当成安全承诺。
一份我自己在用的代理排查清单
每次看新项目,我会按这个顺序过一遍,大概十分钟能有个基本判断:
第一步,读 EIP-1967 的实现槽和管理员槽,确认实现地址,别只信 Etherscan 的 Read as Proxy 按钮,老合约不一定有。第二步,去链上查 admin 是 EOA 还是合约,如果是合约,多半是 Gnosis Safe,去看它的 getOwners 和 getThreshold,3/5、2/3 还是 1/1,差别很大。第三步,看有没有 timelock 合约夹在中间,读 getMinDelay(),几秒和两天是两种东西。第四步,翻实现合约里 _authorizeUpgrade 的实现,有没有 onlyOwner、onlyRole 之类的限制,还是压根没写。第五步,看 initialize 有没有 initializer 修饰符,能不能被二次调用,或者有没有人抢跑初始化。第六步,如果项目说做过升级,去对比前后两个实现的 storage layout,看看有没有插变量、改类型、动继承顺序。
这六步做完,你大概就知道自己面对的是一份写死的合约,还是一份随时可以被换掉的壳子。剩下的,就是你自己判断信不信那几个人了。