区块链安全101-深入EVM
字数 2008 2025-08-22 12:23:25
EVM 深入解析:从字节码到智能合约执行
1. EVM 概述
EVM(以太坊虚拟机)是以太坊协议的核心组件,负责智能合约的部署和执行。可以将其想象为一台拥有数百万个可执行合约的超级计算机,每个合约都有自己的永久存储空间。
1.1 合约编译结构
Solidity代码编译成字节码后,通常分为三部分:
- Contract Creation Code:合约创建代码
- Runtime Code:运行时代码
- Metadata:元数据
这些部分之间通常用INVALID操作码分隔。当看到CODECOPY-39时,通常表示合约的创建部分。
2. EVM 核心组件
2.1 堆栈(Stack)
- EVM使用后进先出(LIFO)的堆栈结构
- 堆栈深度为1024个项,每个项256位(32字节)
- 操作码使用堆栈顶部的元素作为输入
2.2 内存(Memory)
- 可扩展的字节寻址一维数组
- 初始为空,读写和扩展都需要消耗Gas
- 内存成本与使用量成比例上升
- 理论上有\(2^{256}\)个元素,但受Gas限制实际使用有限
- 交易结束后内存内容被丢弃
- 大多数内存读取操作以32字节为单位
内存扩展Gas成本计算
内存扩展成本计算公式:
memory_size_word = (memory_byte_size + 31) / 32
memory_cost = (memory_size_word ** 2) / 512 + (3 * memory_size_word)
memory_expansion_cost = new_memory_cost - last_memory_cost
2.3 存储(Storage)
- 持久化存储,比内存昂贵得多
- 可视为初始化为零的\(2^{256}\)个32字节值的数组
- 智能合约可以在任何位置读写值
- 固定大小值从slot0开始分配
- 动态大小值通过哈希安全定位
3. 内存操作
3.1 内存数据结构
- 简单字节数组,数据可以32字节或1字节存储
- 读取始终以32字节为单位
- 主要操作码:
MSTORE(x,y):从内存位置x存储32字节的yMLOAD(x):将内存位置x开始的32字节加载到堆栈MSTORE8(x,y):将1字节的y存储到位置x
3.2 空闲内存指针(Free Memory Pointer)
内存布局:
0x00-0x3f(64字节):暂存空间0x40-0x5f(32字节):空闲内存指针(初始为0x80)0x60-0x7f(32字节):零槽(不应写入)
空闲内存指针更新公式:
freeMemoryPointer + dataSizeBytes = newFreeMemoryPointer
初始化代码:
60 80 = PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE
3.3 内存中的数据结构操作
结构体(Struct)
struct Point {
uint256 x;
uint32 y;
uint32 z;
}
// 读取
assembly {
x := mload(0x80)
y := mload(add(0x80,0x20))
z := mload(add(0xa0,0x20))
}
// 写入
assembly {
mstore(0x80,1)
mstore(add(0x80,0x20),2)
mstore(add(0xa0,0x20),3)
}
固定大小数组
uint32[3] memory arr = [uint32(1), uint32(2), uint32(3)];
// 读取
assembly {
a0 := mload(0x80)
a1 := mload(0xa0)
a2 := mload(0xc0)
}
// 写入
assembly {
mstore(arr, 11) // 0x80
mstore(add(arr, 0x20), 22) // 0xa0
mstore(add(arr, 0x40), 33) // 0xc0
}
动态数组
uint256[] memory arr = new uint256[](5);
// 读取
assembly {
p := arr
len := mload(arr)
a0 := mload(add(arr, 0x20))
a1 := mload(add(arr, 0x40))
a2 := mload(add(arr, 0x60))
}
// 写入
assembly {
p := arr
mstore(arr, 3) // 存储数组长度
mstore(add(arr, 0x20), 11)
mstore(add(arr, 0x40), 22)
mstore(add(arr, 0x60), 33)
mstore(0x40, add(arr, 0x80)) // 更新空闲内存指针
}
4. 存储操作
4.1 存储布局规则
不同类型变量的存储位置:
| 类型 | 声明 | 值位置 |
|---|---|---|
| 简单变量 | T v |
v的slot |
| 固定大小数组 | T[10] v |
v[n]在(v的slot) + n * (T的大小) |
| 动态数组 | T[] v |
v[n]在keccak256(v的slot) + n * (T的大小)v.length在v的slot |
| 映射 | mapping(T1 => T2) v |
v[key]在keccak256(key . (v的slot)) |
4.2 槽打包(Slot Packing)
Solidity编译器会尝试将多个小类型变量打包到一个32字节的存储槽中。例如:
contract StorageTest {
uint32 value1; // 4 bytes slot0
uint32 value2; // 4 bytes slot0
uint64 value3; // 8 bytes slot0
uint128 value4;// 16 bytes slot0
}
4.3 存储操作码
SSTORE:从堆栈获取32字节key和32字节value,将value存储到key指定的位置SLOAD:从堆栈获取32字节key,将key位置的32字节value推送到堆栈
5. Calldata
Calldata是发送给函数的编码参数,即发送给EVM的数据。每个calldata长度为32字节(64个字符),分为静态和动态两种类型。
5.1 编码
- 使用
abi.encode()生成原始调用数据 - 对特定接口函数,使用
abi.encodeWithSelector()
interface A {
function transfer(uint256[] memory ids, address to) external;
}
contract B {
function a(uint256[] memory ids, address to) external pure returns(bytes memory) {
return abi.encodeWithSelector(A.transfer.selector, ids, to);
}
}
5.2 解码
使用abi.decode()解码:
(uint256 a, uint256 b) = abi.decode(data, (uint256, uint256));
5.3 静态变量示例
对于函数transfer(uint256 amount, address to),参数:
- amount: 1300655506
- address: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45
生成的calldata:
0x000000000000000000000000000000000000000000000000000000004d866d9200000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45
5.4 函数选择器
函数选择器是函数签名keccak256哈希的前4个字节。例如transfer(uint256,address)的选择器:
keccak256("transfer(uint256,address)") → 前4字节为b7760c8f
完整calldata:
0xb7760c8f000000000000000000000000000000000000000000000000000000004d866d9200000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45
5.5 动态变量
动态变量(如bytes、string、动态数组)的结构:
- 第一个32字节:偏移量(动态数据开始位置)
- 第二个32字节:长度
- 后续:元素
例如字符串"Hello World!"的编码:
0x0000000000000000000000000000000000000000000000000000000000000020 // 偏移量32字节
0x000000000000000000000000000000000000000000000000000000000000000c // 长度12字节
0x48656c6c6f20576f726c64210000000000000000000000000000000000000000 // 内容"Hello World!"