智能合约审计过了还被盗?聊聊代理模式、重入漏洞和"不可篡改"这个谎言

🔑 关键词:智能合约,代理模式,重入攻击,Gas优化,智能合约审计

📖 摘要:从 Ropsten 测试网卡交易讲到 Curve 5200 万美元被盗,拆解代理模式怎么让"不可篡改"变成营销话术、重入攻击八年了为什么还在犯、以及审计报告真正的价值在哪里。附 EIP-1967 存储槽、EIP-2929 gas 数字等可查证细节。

2019 年冬天,我在 Ropsten 测试网上部署第一个众筹合约,gas price 填了 1 gwei,那笔交易在内存池里躺了三天才被打包。当时我还挺得意——代码上链就再也改不了了嘛。现在回头看,这话对了一半,错的那一半恰恰是整个行业最爱拿来做营销的部分。

图片

先把结论摆出来:你今天用的绝大多数 DeFi 协议,"不可篡改"的是地址和存储布局,不是逻辑。

一、代理模式:把不可变性撕开一道口子

EIP-1967 定义了两个固定存储槽来放实现合约地址和管理员地址:

  • 实现地址槽:0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
  • 管理员槽:0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103

这两个数是 bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1) 算出来的,末尾减 1 是为了避开 keccak256 的预映像攻击。代理合约本身通常只有几千字节,用 delegatecall 把请求转发给实现合约,存储留在代理里。升级的时候,管理员改一下第一个槽里的地址就完事了。

图片

三种主流代理的差别,我按实际踩坑体验列一下:

方案 升级函数放哪 Gas 特点 坑点
Transparent 代理合约 每次调用多一次选择器比较 管理员调用不走 delegatecall,选择器冲突要小心
UUPS (EIP-1822) 实现合约 部署省 gas(代理更小) 实现里忘写 upgradeTo 就永久锁死
Diamond (EIP-2535) 多个 facet 能突破代码大小限制 存储布局管理复杂,工具链支持一般

表格里那个"代码大小限制"指的是 EIP-170 定的 24576 字节,超了直接部署失败。很多项目选 Diamond 不是设计上需要,纯粹是代码塞不下。EIP-170 是 2016 年定的规则,那会儿谁也没预料到今天的合约复杂度。

二、重入:从 The DAO 到 Curve,八年了还在犯

图片

2016 年 6 月 17 日,The DAO 被重入抽走约 360 万 ETH,直接催生了 1920000 区块高度的硬分叉,ETH 和 ETC 从此分家。2017 年 11 月 6 日,一个叫 devops199 的用户调用了 Parity 多签钱包里没做权限控制的 initWallet,51.3 万 ETH 被永久冻结,到今天还在那个地址里躺着。

再往后,2023 年 7 月 30 日,一批用 Vyper 0.2.15、0.2.16、0.3.0 编译的 Curve 池子出事——@nonreentrant 装饰器生成的锁逻辑失效了,被直接薅走约 5200 万美元,算上抢跑机器人的部分,受影响资金超过 7000 万。注意,这不是 Vyper 语法写错,是编译器在生成字节码时把锁给优化没了。

所以重入这道防线要防的不只是"你的代码写错了",还有"你的工具链错了"。

实操上,Checks-Effects-Interactions 还是第一道防线:先改状态,再做外部调用。OpenZeppelin 的 ReentrancyGuard 用的是两个常量 _NOT_ENTERED = 1_ENTERED = 2,不是 0 和 1。原因很朴素——EIP-3529(伦敦升级,2021 年 8 月)把 SSTORE 清零退款从 15000 降到了 4800,同时取消了 SELFDESTRUCT 的 24000 退款。0→非0 要 20000 gas,非0→非0 只要 5000,用 1 和 2 的话每次进出都能省下约 15000 gas。这个数字是实打实的,不是我拍脑袋。

还有一类叫只读重入(read-only reentrancy)——在 view 函数里读一个还没更新完的池子状态当价格用,Balancer 在 2023 年初就吃过这个亏。防它的办法是在状态更新完成前加一个 locked 检查,而不是只在写操作上加锁。

图片

三、Gas 优化:别信那些"省 50% gas"的教程

我见过太多人一上来就上 unchecked、把 uint256 全改成 uint8。后面这个是典型的反向优化——EVM 每个栈元素都是 32 字节,uint8 只有在 storage 里跟旁边的变量打包进同一个槽才有意义,单独用反而多出掩码操作。

真正有用的是这几个数:

  • 冷访问 SLOAD 2100 gas,热访问 100 gas(EIP-2929 之后)。把循环里反复读的 storage 变量提到 memory,一次循环省 2000 gas。
  • calldata 里非零字节 16 gas,零字节 4 gas。这就是为什么很多协议宁愿用 bytes32 传参也不用字符串。
  • immutableconstant 不占存储槽,值在部署时写进代码里,读的时候是 PUSH32 而不是 SLOAD,差 2100 gas。
  • 自定义 error(Solidity 0.8.4+)比 require 带字符串便宜,因为不用存那串字。

图片

不过说句实话,在主网 gas 价格回到 5-20 gwei 的今天,你省下的那点 gas 摊到用户头上可能就几分钱。为了省 gas 把代码写成天书,是负收益。

四、审计报告到底值多少钱

先说数字:Certora Prover、Slither、Foundry 的 invariant testing 这几样加起来,我估摸着能挡住 60%-70% 的常见问题。剩下那 30% 靠审计师的个人经验,很难标准化。

审计过还被黑的例子一只手数不完:Wormhole 2022 年 2 月丢了 12 万 ETH,Ronin 2022 年 3 月丢了 17.36 万 ETH 加 2550 万 USDC,Nomad 2022 年 8 月被薅走约 1.9 亿美元——那次纯粹是 init 参数校验漏了,结果谁来都能分一杯羹。

我的观点可能有点冒犯:审计报告的公关价值,大概和它的技术价值一样高。投资人和交易所要看,所以项目方必须做。真认真的团队会把审计当成一次外部 code review,然后自己再补一套监控和暂停机制。

图片

所以我自己做项目会坚持三件事:

  1. 有紧急暂停(pause),但配时间锁和分段解锁流程,别让 owner 一秒钟卷走所有钱。
  2. 设单笔和单日提取上限,把最坏情况从"全部"压到"有限"。
  3. 用 Foundry 写 invariant test,至少跑 10 万轮,比堆 unit test 有用得多。

追求"零漏洞"是妄想,追求"出事了能兜住"才现实。

最后回到开头那句——不可篡改确实是个好听的词,但真正承载价值的是代码背后的经济模型、治理结构和应急预案。地址写在链上是改不了,逻辑不是,人心更不是。想明白这一层,比背一百份审计 checklist 管用。

🏷️ 标签: