返回论坛

EVM 存储机制深度解析:插槽包装与数据定位

查找币 学术研究 安全研究 Web3安全 区块链安全

查找币安全研究院

钱包恢复评估 | 链上取证分析 | Web3 事件响应
以合法授权、证据保全、隐私保护和可复核流程为前提,不要求用户在线提交完整私钥或助记词。

查看研究院 研究报告中心
> 查找币安全团队技术研究系列 ## 引言 在《EVM 深入探讨》系列的前两部分中,我们剖析了 EVM 如何通过函数签名定位合约字节码、调用栈与 calldata 的交互机制,以及合约内存的工作原理。本文作为第三部分,将聚焦于合约存储的核心机制——存储插槽包装(Slot Packing),这是理解 Solidity 合约存储布局的关键技术节点。 对于参与过 Ethernaut 挑战(https://ethernaut.openzeppelin.com)或 Solidity CTF 竞赛的研究者而言,插槽包装知识往往是破解谜题的核心突破口。本文将结合实际案例,从底层操作码层面揭示 EVM 如何高效管理存储空间。 ## 存储基础架构 ### 1. 数据结构模型 EVM 的合约存储本质上是一个键值映射结构:**32 字节的 key 映射到 32 字节的 value**。由于 key 空间为 2^256 个,理论上可存储多达 (2^256 - 1) 个独立存储槽位。所有未显式赋值的存储位置初始值均为零,且零值不会被实际存储——这正是将存储值归零时可退还 Gas 的技术根源。 从抽象视角看,存储可视为一个天文级大小的数组:key 的二进制值直接对应数组索引。第 0 个二进制 key 代表数组第 0 项,第 1 个 key 代表第 1 项,依此类推。 ### 2. 定长变量的存储分配 合约存储变量分为定长和不定长两类。本文重点分析定长变量的存储分配策略。考虑以下合约: ```solidity contract StorageTest { uint256 value1; uint256[2] value2; uint256 value3; } ``` 由于所有变量均为 uint256(32 字节),EVM 采用线性分配策略:从插槽 0 开始,按声明顺序依次分配。插槽 0 存储 `value1`,`value2` 作为定长数组占用插槽 1 和 2,插槽 3 存储 `value3`。 ## 插槽包装(Slot Packing) ### 1. 包装机制的触发条件 现在观察另一个合约: ```solidity contract StorageTest { uint32 value1; // 4 bytes uint32 value2; // 4 bytes uint64 value3; // 8 bytes uint128 value4; // 16 bytes } ``` 与上一个例子不同,此合约仅占用**单个存储插槽(插槽 0)**。关键差异在于变量类型:uint32、uint64、uint128 分别代表 4、8、16 字节数据。Solidity 编译器会在分配时检查当前插槽的剩余空间: - **插槽 0 初始容量**:32 字节 - **`value1` 占用**:4 字节(剩余 28 字节) - **`value2` 占用**:4 字节(剩余 24 字节) - **`value3` 占用**:8 字节(剩余 16 字节) - **`value4` 占用**:16 字节(恰好填满插槽 0) ### 2. 包装布局的内存结构 插槽 0 的 32 字节数据按以下顺序排列(从低位到高位): ``` [ value1 (4B) | value2 (4B) | value3 (8B) | value4 (16B) ] ``` 这种紧凑布局显著降低了存储成本。但需要注意的是:**跨插槽包装会导致 Gas 成本显著增加**。例如,若 `value4` 改为 uint256(32 字节),则 `value1`、`value2`、`value3` 打包至插槽 0,`value4` 单独占用插槽 1。此时若修改 `value4`,需同时加载插槽 0 和 1 的数据进行重组,Gas 消耗会大幅上升。 ### 3. 结构体内部的包装 结构体成员同样遵循包装规则: ```solidity contract StructTest { struct S { uint64 a; uint256 b; uint64 c; } S data; } ``` `data` 的存储布局为: - **插槽 0**:`a`(8 字节) - **插槽 1**:`b`(32 字节) - **插槽 2**:`c`(8 字节) 由于 `b` 为 32 字节,无法与 `a` 或 `c` 共享插槽。若调整声明顺序为 `uint256 b; uint64 a; uint64 c;`,则 `a` 和 `c` 可打包至插槽 1。 ## 底层操作码实现 EVM 通过 `SLOAD` 和 `SSTORE` 操作码访问存储,但这两个操作码仅支持 32 字节整块读写。插槽包装的实现依赖位操作和位掩码。 ### 1. 读取包装变量 以读取上述合约中的 `value3`(8 字节)为例,EVM 执行以下步骤: ```assembly PUSH1 0x0 // 存储插槽索引 0 SLOAD // 加载插槽 0 的 32 字节数据 PUSH8 0xffffffffffffffff // 8 字节位掩码 PUSH8 0x8 // 左移 8 字节(跳过 value1 和 value2) SHL // 将目标数据对齐到低位 AND // 提取 value3 的 8 字节数据 ``` ### 2. 写入包装变量 修改 `value2` 时需执行更复杂的操作: ```assembly PUSH1 0x0 // 插槽索引 SLOAD // 加载完整 32 字节数据 PUSH8 0xffffffff // 4 字节位掩码 PUSH8 0x20 // 左移 32 位(4 字节) SHL // 构建 value2 的写入掩码 NOT // 取反以清除目标位置 AND // 清除 value2 在原数据中的位置 PUSH4 // 新的 value2 值 PUSH8 0x20 // 左移 32 位对齐 SHL // 对齐新值 OR // 合并新旧数据 PUSH1 0x0 SSTORE // 写回存储 ``` ## 安全审计视角 ### 1. 常见漏洞模式 - **未对齐访问**:跨插槽包装变量被多次修改时,可能因 Gas 不足导致交易失败 - **存储碰撞**:动态数组和映射的存储布局与定长变量不同,不当的声明顺序可能引发意外覆盖 - **Gas 攻击**:攻击者可利用插槽包装特性,通过精心构造的输入迫使高 Gas 操作执行 ### 2. 审计检查清单 - 验证结构体成员声明顺序是否最优 - 检查跨插槽包装变量的修改频率 - 确认动态数组与定长变量的存储隔离 - 测试极端 Gas 条件下的存储操作行为 ## 总结 插槽包装是 EVM 存储优化的核心技术,通过位操作实现 32 字节块内的精细数据管理。理解其底层实现机制,对于编写高效合约、识别安全漏洞、优化 Gas 消耗至关重要。本系列后续将深入 Geth 客户端源码,解析 `SLOAD` 和 `SSTORE` 操作码的底层实现。 --- **本文由查找币安全团队整理发布**
在论坛中查看和回复