W3
严重代理与升级静态扫描有 2 条规则

代理存储冲突与未保护升级

Proxy Storage Collision / Unprotected Upgrade

代理模式下代码在实现合约、数据存在代理合约。如果实现合约的变量布局变了(加变量、调顺序、改类型),旧数据会被按新布局解读;如果升级函数没有严格的权限控制,任何人可以换成恶意实现,一次抽空全部资金。

根因

代理本身只有转发逻辑,没有存储布局约束。Solidity 按声明顺序分配存储槽,所以「在开头插一个变量」会让所有后续变量整体错位——原本存着 owner 的槽被读成了一个余额。这是 Solidity 独有的、语言层面不报错的陷阱。

攻击是怎么一步步发生的

  1. 1
    攻击者

    发现 upgradeTo 无权限控制

  2. 2
    代理合约

    调用 upgradeTo(恶意实现)

  3. 3
    恶意实现

    写入一个把全部资产转给攻击者的函数

  4. 4
    代理合约

    再调用那个函数 —— delegatecall 在代理的存储上执行,资金被转走

有漏洞的版本

不要照抄solidity
Solidity24
// ===== 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
  • 存储槽魔数直接用内联汇编写死,可读性极差,容易写错且很难被审查发现

修复版本

推荐写法solidity
Solidity35
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

但「命中规则」只说明代码里出现了相关的写法,不说明它一定有问题、也不说明没有它就没问题。命中了就回到源码确认调用路径。

延伸阅读

下一步:想知道这条在手上的合约里有没有出现,用Rug Pull 扫描器,它会检查前面列出的 2 条相关规则。 审计检查清单可以把它落成一条可勾选、可写备注的记录,最后导出 Markdown 报告;想先弄懂这一类漏洞背后的通用思路,去参考手册看对应的语言与标准条目。