跳到正文

代理存储冲突:升级如何破坏合约状态

代理保留状态,而实现代码可以变更。了解不兼容的存储布局如何覆写余额、所有者和升级控制,以及如何安全验证升级。

更新于

仅供教育参考,不构成投资建议;投资可能产生损失。

直接答案

当实现代码以不同于现有状态创建时布局所赋予的含义,读取或写入代理的存储槽,就会发生代理存储冲突。升级可能因此把余额当作地址、清空所有者、破坏映射的根槽,或覆写升级控制数据。

使用 delegatecall 时,实现字节码在代理的上下文中执行:存储、余额和 address(this) 都属于代理。变量名不会存储在链上,因此 EVM 只按新代码计算出的槽位和字节偏移操作。

所以,升级必须保持已部署布局兼容,而不只是成功编译或公开相同函数。授权升级之前,应将编译器生成的布局与确切的已部署版本比较。

工作原理

Solidity 通常在继承关系经过 C3 线性化后,按声明顺序从槽 0 起放置状态变量。小于 32 字节的值可能共用一个槽;结构体和数组还有额外的放置规则,而映射和动态数组会根据基础槽推导数据位置。基础槽一旦移动,其派生数据的位置也会改变。

需要审计以下四类不同的冲突边界:

  • 代理与实现之间: 实现地址、管理员等代理自有字段不得占用应用状态所用的槽。ERC-1967 为实现、信标和管理员数据规定了编译器常规分配不会触及的标准槽。
  • 旧实现与新实现之间: 现有应用变量必须保持兼容的槽、偏移和类型。在末尾追加变量可能安全,但插入、重排、删除或改变变量类型会重新解释已有存储字。
  • 继承关系: 向基类添加状态或改变继承顺序,可能移动派生合约声明的存储,即使派生合约源码没有变化。
  • 预留或命名空间存储: 正确使用存储间隙可以为基类预留空间,ERC-7201 风格的命名空间可以隔离布局。但两者都不允许任意修改既有布局内部。

示例

假设版本 1 的布局如下:

uint256 totalAssets; // slot 0
address owner;       // slot 1

版本 2 错误地在开头插入一个变量:

bool paused;         // slot 0, offset 0
uint256 totalAssets; // slot 1
address owner;       // slot 2

升级后,paused 会读取旧 totalAssets 的低位字节,新 totalAssets 会把旧 owner 存储字当作整数读取,而 owner 会读取槽 2 中原有的内容,通常为零。原始存储字仍然存在,但新代码赋予了它们不同含义。交易可能成功执行,却以错误解释后的状态进行授权或记账。

风险与升级检查

  • 为两个实现生成存储布局输出,并以实际部署的参考合约为准,比较槽、偏移、类型和继承信息。
  • 对传统线性布局,只在末尾追加新变量。除非已证明兼容,否则不要重排现有字段、改变类型、删除后复用,也不要修改基类。
  • 使用存储间隙时,应按实际占用的预留槽数精确缩减间隙。使用命名空间存储时,须确保命名空间标识唯一,并验证每个现有命名空间内的变更。
  • 不要把 ERC-1967 当作完整保护。它将代理元数据与编译器分配的应用槽分开,但不能保证两个实现布局彼此兼容。
  • 在分叉网络或状态快照上测试升级及任何重新初始化函数。执行前后都要核对所有者、角色、余额、授权额度、映射条目、暂停状态、实现槽以及回滚或紧急控制。

如果升级已疑似造成冲突,在治理权限允许时,应暂停后续升级和状态变更调用。保留升级前区块号与实现字节码,比较受影响槽位的原始存储,并由合格的合约工程师设计迁移方案、接受独立审查。没有经验证的存储映射就反复升级,可能破坏更多原本可恢复的状态。

常见误区

  • “变量名没变,布局就是安全的。” 名称不决定存储位置,类型、顺序、打包、继承和命名空间规则才决定。
  • “删除变量就能释放槽位供复用。” 代理存储会持续存在。除非经过严格审查的迁移将旧值清除或转换,否则复用槽位只是给旧存储字赋予新含义。
  • “一笔测试交易成功就证明升级兼容。” 测试可能只触及少量槽位。布局验证和状态差异测试必须覆盖特权字段、打包值、映射、数组和继承存储。

相关主题

来源

导航

搜索知识库...