严重代理与升级静态扫描有 2 条规则
代理存储冲突与未保护升级
Proxy Storage Collision / Unprotected Upgrade
代理模式下代码在实现合约、数据存在代理合约。如果实现合约的变量布局变了(加变量、调顺序、改类型),旧数据会被按新布局解读;如果升级函数没有严格的权限控制,任何人可以换成恶意实现,一次抽空全部资金。
根因
代理本身只有转发逻辑,没有存储布局约束。Solidity 按声明顺序分配存储槽,所以「在开头插一个变量」会让所有后续变量整体错位——原本存着 owner 的槽被读成了一个余额。这是 Solidity 独有的、语言层面不报错的陷阱。
攻击是怎么一步步发生的
- 1攻击者
发现 upgradeTo 无权限控制
- 2代理合约
调用 upgradeTo(恶意实现)
- 3恶意实现
写入一个把全部资产转给攻击者的函数
- 4代理合约
再调用那个函数 —— delegatecall 在代理的存储上执行,资金被转走
有漏洞的版本
不要照抄soliditySolidity24 行
// ===== V1 实现合约 =====
contract ImplV1 {
address public owner; // slot 0
uint256 public totalDeposits;// slot 1
function initialize(address owner_) external {
owner = owner_; // 没有 initializer 修饰符
}
function upgradeTo(address newImpl) external {
// 致命:没有任何权限检查
assembly { sstore(0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, newImpl) }
}
}
// ===== V2 实现合约(危险:在开头插了变量)=====
contract ImplV2 {
bool public paused; // slot 0 ← 覆盖了原 owner
address public owner; // slot 1 ← 读取的是原来的 totalDeposits
uint256 public totalDeposits;// slot 2
// 升级后,owner 变成 totalDeposits 的值(一个巨大的数字),
// 任何依赖 owner 的判断都会失效
}upgradeTo没有onlyOwner:任何人可以换成自己的实现,然后执行任意代码- V2 在开头插入
paused→ 所有变量整体错位一位,旧数据被错误解读 initialize没有保护,部署后可以被抢注 owner- 存储槽魔数直接用内联汇编写死,可读性极差,容易写错且很难被审查发现
修复版本
推荐写法soliditySolidity35 行
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract ImplV1Safe is UUPSUpgradeable, OwnableUpgradeable {
// 规则一:新版本只能在**末尾追加**变量,永不插入、不改变已有变量的顺序与类型
address public owner; // slot 0(v1)
uint256 public totalDeposits; // slot 1(v1)
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address owner_) external initializer {
__Ownable_init(owner_);
__UUPSUpgradeable_init();
}
// 规则二:升级必须限权。UUPS 用这个钩子强制你实现权限判断,
// 不实现就编译失败——把「忘记加权限」这件事变成编译期错误
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
// 规则三:给存储布局留出扩展空间(gap),未来加变量先吃 gap
uint256[48] private __gap;
}
// ===== V2:只能追加 =====
contract ImplV2Safe is ImplV1Safe {
bool public paused; // 追加在 slot 2,前面的变量位置完全没变
mapping(address => bool) public blacklisted;
function setPaused(bool v) external onlyOwner {
paused = v;
}
}- 继承顺序:代理相关的基类在前,且布局要在所有版本间保持一致
- 新变量只能追加到末尾;要加在中间就必须用 storage gap 预留
_authorizeUpgrade是 UUPS 的强制要求,把权限变成了编译期约束- 整个链条用 OpenZeppelin Upgrades 插件来管,它会自动比对存储布局差异(
@openzeppelin/upgrades-core) - 升级前必须先测:在 fork 主网状态下跑一次升级 + 关键读取,确认 owner / 余额没被读错
怎么防
- 升级函数必须 onlyOwner / onlyRole,优先用 UUPS 的
_authorizeUpgrade把权限变成强制项 - 用 OpenZeppelin Upgrades 插件校验存储布局,把「删变量 / 改类型」拦在部署前
- 只在末尾追加变量;用 storage gap 预留扩展空间
- 实现合约的 constructor 里调
_disableInitializers() - 升级走多签 + 时间锁,给用户留出退出的时间窗口
- 把实现地址变更发事件,链上监控能第一时间发现异常升级
真实案例
2021多个未保护 UUPS 代理单起数百万美元级
upgradeTo 缺权限,被直接替换为恶意实现
2017Parity 库合约约 15 万 ETH 永久冻结
库合约被 selfdestruct,delegatecall 目标消失
2022多个早期 DeFi 协议因存储布局变更导致的状态错乱
升级后 owner 被读成别的变量值,权限判断失效
审计这个合约时要问的问题
- 1.这个合约是代理吗?实现地址存在哪个槽?
- 2.升级函数谁可以调?有没有多签与时间锁?
- 3.新版本的存储布局和旧版本逐槽比对过了吗?
- 4.实现合约能否被直接初始化?
- 5.升级过程中有没有短暂的功能不可用窗口?
这些问题也是审计检查清单里对应的条目——那里可以把每一条的检查结果记下来,最后导出成报告。
静态扫描能发现它吗
能——扫描引擎里有 2 条规则与它相关:
upgradeableassembly但「命中规则」只说明代码里出现了相关的写法,不说明它一定有问题、也不说明没有它就没问题。命中了就回到源码确认调用路径。