可升级代理漏洞
代理通过 DELEGATECALL 执行实现合约的代码。委托执行时,业务状态保存在代理中,address(this) 为代理地址,msg.sender 保留代理收到调用时的调用者。直接调用实现地址则使用实现自己的状态。
代理模式与升级入口
| 模式 | 升级逻辑 | 权限与状态 |
|---|---|---|
| Transparent | 位于代理侧,OpenZeppelin 实现通常由 ProxyAdmin 管理 | 代理区分管理员调用与业务调用 |
| UUPS | 位于当前实现中,经代理委托执行 | 实现检查权限并修改代理的实现地址槽 |
| Beacon | Beacon 保存实现地址 | 多个代理使用该 Beacon 返回的实现 |
模式名称不决定具体库版本的接口。OpenZeppelin 5.4.0 的 UUPS 升级入口为 upgradeToAndCall;原始 EIP-1822 与现代 OpenZeppelin 使用的实现地址槽也不相同,见 UUPS。
存储槽冲突
手写代理若把 owner、implementation 等变量放在线性布局起始位置,委托执行的实现变量可能写入相同槽位。冲突发生在代理的存储中,并不是两个部署地址天然共享全部存储。
EIP-1967 为实现地址、管理员和 Beacon 定义特殊槽位,降低与 Solidity 常规布局重叠的风险。该设计依赖哈希安全假设,不能阻止实现代码主动向这些槽位写入数据。
代理初始化与实现初始化
代理初始化设置代理的 owner、角色和业务参数。若代理部署后仍暴露未保护的初始化入口,攻击者可能抢先调用并取得代理权限。部署时附带初始化数据,可将创建代理和设置状态放在同一笔交易中。
实现合约的构造函数仅影响实现地址。OpenZeppelin 可升级实现通常在构造函数调用 _disableInitializers(),锁定实现自身的初始化入口;该操作不会替代理完成初始化。
直接调用实现的 initialize() 不会自动修改代理的 owner。历史 UUPS 漏洞涉及特定旧版本的直接升级调用和委托自毁路径,影响的是实现代码可用性,详见代理进阶。
OpenZeppelin 5.4.0 的升级入口使用 onlyProxy 检查活动代理上下文,再由 _authorizeUpgrade 检查升级权限。msg.sender == address(this) 表示调用者是合约自身,不能替代上述两个检查。
函数选择器与外部执行权限
代理管理接口与业务接口可能出现相同选择器。透明代理按管理员身份区分路由;UUPS 将升级接口放在实现中,但仍需避免业务接口冲突,并保护升级权限。
外部输入若能选择 delegatecall 目标,可能让不可信代码修改当前合约的全部状态。普通 call 使用被调用合约的上下文,也可能调用未正确鉴权的外部入口,但不能直接通过 ABI 调用 internal 或 private 函数。
消息转发和插件系统需要同时约束调用者、目标、方法与参数。只限制目标地址不能证明所有可调用方法都安全。
升级时的布局兼容
// V1:a 在 slot 0,b 在 slot 1
uint256 public a;
uint256 public b;
// 不兼容的 V2:b 改为 slot 0,将读取旧 a 的值
uint256 public b;
普通线性布局的升级通常保留既有变量顺序、类型和含义,再按兼容规则追加状态。__gap 用于预留布局空间,不能自动修复已删除或重排的业务变量。
ERC-7201 按命名空间组织存储,但命名空间内部仍需保持布局兼容。从旧布局切换到命名空间布局需要明确的数据迁移;改变寻址方式不会自动搬移旧值。
升级安全条件
- 代理初始化与实现锁定分别验证,初始化参数对应明确的权限主体。
- UUPS 检查调用上下文、升级授权及新实现的兼容接口。
- 新旧布局与业务不变量保持兼容,迁移和重复初始化路径可被验证。
- 升级治理明确多签、时间锁或其他授权机制,管理员能力纳入信任模型。
- 部署地址、实现槽、源码版本和实际执行字节码相互对应。