区块链安全101-深入EVM
kkontheway 发表于 上海 区块链安全 961浏览 · 2024-07-02 07:54

了解EVM


EVM是以太坊协议的一部分,用于控制智能合约的部署和执行,可以想象成一台超级计算机,拥有数百万个可执行的东西,也就是合约,每一个东西都有自己的永久存储。

Solidity代码需要先编译成字节码,才能部署到以太坊网络,字节码代表着一系列用于EVM的Opcode

ByteCode


当一个合约被编译的时候,大部分合约都会被编译成三个部分:

  • Contract Creation Code
  • Runtime Code
  • Metadata

在中间会有INVALID来分割。

当看到CODECOPY-39的时候一般就是合约的创建部分

OpCode

实际上就是对Stack Memory Storage做一些事情。

Stack

EVM是堆栈的,深度游1024个items,每个item都有256bit也就是32字节。因为是堆栈的所有符合后进先出。

Opcodes使用堆栈元素作为input,始终使用栈顶的元素进行操作

Memory

在EVM中,Memory可以被认为是一个可拓展的通过字节寻址的一维数组,开始时是空的,读取写入和扩展都需要花费Gas。同时内存的成本是和使用的成比例上升的,所以即使memory理论上有$2^{256}$ 个elements但是一般也用不到那么多。同时还有Gas限制,所以更用不到了。

Calldata类似,但是Calldata无法扩展或者覆盖,并且充当合约调用的输入

Memory和Calldata不是持久的,在交易结束后就会被丢弃,同时几乎所有的从内存中的读取操作都是以32字节为单位的。

Gas Cost

在智能合约执行期间,可以使用操作码访问内存。当首次访问偏移量(读取或写入)时,内存可能会触发扩展,这会消耗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

memory_byte_size 可以通过操作码 MSIZE 获得。 MSIZE 触发的内存扩展成本呈二次方增长,通过使更高的偏移量成本更高来抑制内存的过度使用。任何访问内存的操作码都可能触发扩展(例如 MLOAD、RETURN 或 CALLDATACOPY)。

Memort Data Structure

合约内存是一个简单的字节数组,其中数据可以用32字节(256位)或者1字节(8位)进行存储,但是读取是32字节的读取。

关于存储的操作主要有三个Opcode:

  • MSTORE(x,y) - 从内存位置X存储32字节的Y
  • MLOAD(x) - 将内存位置x开始的32字节加载到stack上
  • MSTORE8(x,y) - 将1字节的y存储到位置x上

Memory是一个数组,也就是我们能从任何位置读取并且返回32字节的数据,Memory是线性的,可以在字节级别进行寻址。

Free Memory Pointer

Memory中的布局是这样的:

Free Memory Pointer只是执行空闲内存开始位置的指针,能够确保智能合约跟踪那些内存已经被写入,哪些没有被写入。

Free Memory Pointer可以防止合约覆盖已分配给另外一个变量的内存

当一个变量写入内存的时候,合约将首先引用Free memory pointer来确定数据应该存储在哪里。

然后,它通过记录要写入新位置的数据量来更新空闲内存指针。这两个值的简单相加将产生新的可用内存的开始位置。

freeMemoryPointer + dataSizeBytes = newFreeMemoryPointer

一开始空闲指针的定义是:

60 80                       =   PUSH1 0x80
60 40                       =   PUSH1 0x40
52                          =   MSTORE

这些有效地表明空闲内存指针位于内存中的字节 0x40(十进制 64),其值为 0x80(十进制 128)。

那为什么说是从0x80开始空闲呢?这是因为Solidity 的内存布局保留了 4`32 字节槽:

  • 0x00 - 0x3f (64 bytes): scratch space 暂存空间
    • 暂存空间可用于语句之间,即内联汇编内和散列方法。
  • 0x40 - 0x5f (32 bytes): free memory pointer 空闲内存指针
    • 空闲内存指针,当前分配的内存大小,空闲内存的起始位置,初始为0x80
  • 0x60 - 0x7f (32 bytes): zero slot 零槽
    • 零槽用作动态内存阵列的初始值,并且永远不应该被写入。

接下来让我们来看看EVM是如何操作Memory的:

Operate Memory

Struct

虽然到这里我们还没有提到Storage中的slot,但是先记住,在Memory中Data不是和Storage一样Packed,哪怕没有32字节大小,比如可能是一个uint32或者address,他依然会占据一个32字节的槽。E.g:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract MemBasic {
    struct Point {
        uint256 x;
        uint32 y;
        uint32 z;
    }

    function read() public pure returns (uint256 x,uint256 y,uint256 z) {
        Point memory p = Point(1,2,3);

    }
}

我们首先定义了一个struct,然后在memory中初始化了他,现在我想要使用mload来读取memory中的x,y,z我们该怎么做呢:

assembly {
    x := mload(0x80) // 空闲指针初始化的位置
    y := mload(add(0x80,0x20)) // 往后加32字节
    z := mload(add(0xa0,0x20)) // 再加32字节
}

这是读取,下面我们再来看看如何写入struct数据:

Point memory p;
assembly {
    mstore(0x80,1)
    mstore(add(0x80,0x20),2)
    mstore(add(0xa0,0x20),3)
}

这时候如果想看一下Free memory pointer指向哪里我们可以使用:

Free_memory_pointer := mload(0x40) //因为这是存储指针的位置

Fixed size Array

和Struct一样,只需要记住在Memory中不管大小有多大,都是32字节的,不会打包

contract MemFixedArray {
    function test_read()
        public
        pure
        returns (uint256 a0, uint256 a1, uint256 a2)
    {
        // arr is loaded to memory starting at 0x80
        // Each array element is stored as 32 bytes
        uint32[3] memory arr = [uint32(1), uint32(2), uint32(3)];

        assembly {
            a0 := mload(0x80)
            a1 := mload(0xa0)
            a2 := mload(0xc0)
        }
    }

    function test_write()
        public
        pure
        returns (uint256 a0, uint256 a1, uint256 a2)
    {
        uint32[3] memory arr;

        assembly {
            // 0x80
            mstore(arr, 11)
            // 0xa0
            mstore(add(arr, 0x20), 22)
            // 0xc0
            mstore(add(arr, 0x40), 33)
        }

        a0 = arr[0];
        a1 = arr[1];
        a2 = arr[2];
    }
}

DynamicArray

一样,直接贴代码:

contract MemDynamicArray {
    function test_read()
        public
        pure
        returns (bytes32 p, uint256 len, uint256 a0, uint256 a1, uint256 a2)
    {
        uint256[] memory arr = new uint256[](5);
        arr[0] = uint256(11);
        arr[1] = uint256(22);
        arr[2] = uint256(33);
        arr[3] = uint256(44);
        arr[4] = uint256(55);

        assembly {
            p := arr
            // 0x80
            len := mload(arr)
            // 0xa0
            a0 := mload(add(arr, 0x20))
            // 0xc0
            a1 := mload(add(arr, 0x40))
            // 0xe0
            a2 := mload(add(arr, 0x60))
        }
    }

    function test_write() public pure returns (bytes32 p, uint256[] memory) {
        uint256[] memory arr = new uint256[](0);

        assembly {
            p := arr
            // Store length of arr
            mstore(arr, 3)
            // Store 1, 2, 3
            mstore(add(arr, 0x20), 11)
            mstore(add(arr, 0x40), 22)
            mstore(add(arr, 0x60), 33)
            // Update free memory pointer
            mstore(0x40, add(arr, 0x80))
        }

        // Data will be ABI encoded when arr is returned to caller
        return (p, arr);
    }
}

Storage

在Ethereum中所有的合约账户能够将数据持久的存储在Storage中,Storage的成本要比Memory贵很多,因为交易执行后,所有的以太坊都要更新合约的Storage。

我们可以将Storage视作一个天文数字般的数组,最初充满了零。数组中每个值都是32字节,有$2^{256}$这样的值,智能合约可以在任何位置读取或者写入值

首先我们先记住几个基本概念:

  • 每个智能合约都有 $ 2^{256} $ 个32 字节值的数组形式存储,全部初始化为零。
  • 当我们设置状态变量的值时,他会将其分配在slot中。
  • 0不是显式存储的
  • Solidity将固定大小的值定位在slot中从slot0开始,比如uint256,address....
  • Solidity 利用存储的稀疏性和哈希输出的均匀分布来安全地定位动态大小的值。

我们来看一个例子:

contract StorageTest {
    uint256 a;
    uint256[2] b;

    struct Entry {
        uint256 id;
        uint256 value;
    }
    Entry c;
}

在上面的代码中:

  • a会被存储在slot0中
  • b存储在slot1和2中,因为b是一个数组,同时长度是定好的2,所以会分配两个slot
  • c会从slot3开始,并且消耗两个slot,因为Entry结构存储了两个32字节的值

我们可以使用Foundry的inspect来查看布局:

forge inspect StorageTest storage
{
  "storage": [
    {
      "astId": 47406,
      "contract": "src/Test.sol:StorageTest",
      "label": "a",
      "offset": 0,
      "slot": "0",
      "type": "t_uint256"
    },
    {
      "astId": 47410,
      "contract": "src/Test.sol:StorageTest",
      "label": "b",
      "offset": 0,
      "slot": "1",
      "type": "t_array(t_uint256)2_storage"
    },
    {
      "astId": 47418,
      "contract": "src/Test.sol:StorageTest",
      "label": "c",
      "offset": 0,
      "slot": "3",
      "type": "t_struct(Entry)47415_storage"
    }
  ],
  "types": {
    "t_array(t_uint256)2_storage": {
      "encoding": "inplace",
      "label": "uint256[2]",
      "numberOfBytes": "64",
      "base": "t_uint256"
    },
    "t_struct(Entry)47415_storage": {
      "encoding": "inplace",
      "label": "struct StorageTest.Entry",
      "numberOfBytes": "64",
      "members": [
        {
          "astId": 47412,
          "contract": "src/Test.sol:StorageTest",
          "label": "id",
          "offset": 0,
          "slot": "0",
          "type": "t_uint256"
        },
        {
          "astId": 47414,
          "contract": "src/Test.sol:StorageTest",
          "label": "value",
          "offset": 0,
          "slot": "1",
          "type": "t_uint256"
        }
      ]
    },
    "t_uint256": {
      "encoding": "inplace",
      "label": "uint256",
      "numberOfBytes": "32"
    }
  }
}

对于不同的类型,Solidity的规范如下:

Kind Declaration Value Location
Simple Variable T v v v's slot
Fixed-size array T[10] v v[n] (v's slot) + n * (size of T)
Dunamic array T[] v v[n]
v.length
keccak256(v's slot) + n * (size of T)
v's slot
Mapping Mapping(T1 => T2) v v[key] keccak256(key . (v's slot))

Slot Packing

Solidity 编译器知道它可以在存储槽中存储 32 字节的数据。当我们定了一个uint32类型的value1的时候,value1仅占用 4 个字节存储在槽slot0 时,编译器读取下一个变量时会查看是否可以将其打包到当前存储槽中。所以如果value2是uint128的话也会打包保存在slot0。

比如如下的代码:

contract StorageTest {
    uint32 value1;
    uint32 value2;
    uint64 value3;
    uint128 value4;
    }

他的存储布局会是什么样的?

contract StorageTest {
    uint32 value1; // 4 bytes slot0
    uint32 value2; // 4 bytes slot0
    uint64 value3; // 8 bytes slot0
    uint128 value4;// 16 bytes slot0
    }

因为一个Slot占据了32个bytes如果可以打包的话,就会进行打包。

Storage Opcodes

关于StorageOpcode主要有两个SSTORESLOAD

SSTORE:

它从调用堆栈中获取 32 字节key和 32 字节value,并将该 32 字节value存储在该 32 字节Key指代的位置。e.g:

Input
1 0
2 0xFFFF

Storage结果:

Storage key after input 1 Storage value
0 0xFFFF

SLOAD:

它从Stack中获取 32 字节key,并将存储在该 32 字节key位置的 32 字节Value推送到Stack上。e.g:

假设目前的Storage如下:

Storage key Storage Value
0 46

那么结果就是:

Input Output
0 46

CallData

Calldata也就是我们发送给函数的编码参数。也就是发送给EVM的数据。

每个calldata长度为32字节或者64个字符,一般calldata分为两种:

  • Static
  • Dynamic

如何编码

如果对类型进行编码,直接传递到abi.encode()中来生成原始调用数据

如果对特定接口函数,我们需要使用abi.encodeWithSelector(selector, parameters)

interface A {
  function transfer(uint256[] memory ids, address to) virtual external;
}

contract B {
  function a(uint256[] memory ids, address to) external pure returns(bytes memory) {
    return abi.encodeWithSelector(A.transfer.selector, ids, to);
  }
}

如何解码

如果使用abi.encode编码的,我们可以使用abi.decode来解码:

(uint256 a, uint256 b) = abi.decode(data, (uint256, uint256))

Static Variables

假如我们在和下面的合约交互:

pragma solidity 0.8.17;
contract Example {
    function transfer(uint256 amount, address to) external;
}

我们想要传递的参数是:

amount: 1300655506
address: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45

那将会生成一段:0x000000000000000000000000000000000000000000000000000000004d866d9200000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45我们该怎么阅读呢,其实我们只需要吧0x删去,然后以32字节为一组分开就好:

0x
// uint256
000000000000000000000000000000000000000000000000000000004d866d92
// address
00000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45

Function

我们都知道函数选择器是函数加上参数然后进行keccak256哈希之后的前四个字节,所以如果我们想抗药调用transfer(uint256,address),我们就需要先:

keccak256("transfer(uint256,address)");

然后将结果的前四个字节b7760c8f拼接到calldata中:

0xb7760c8f000000000000000000000000000000000000000000000000000000004d866d9200000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45

Dynamic Variables

动态变量是非固定大小的类型,包括 bytesstring 和动态数组 <T>[] ,以及固定数组 <T>[N]

动态类型的结构始终以偏移量开始,偏移量是动态类型开始位置的十六进制表示。例如,十六进制 20 表示 32-bytes 。一旦到达偏移量,就会有一个较小的数字表示类型的长度。也就是说第一个32字节是偏移量,第二个32字节是长度,其余为元素

我们用一个例子形象的表明一下,比如一个字符串string类型,值是Hello World!:

0x
0000000000000000000000000000000000000000000000000000000000000020
000000000000000000000000000000000000000000000000000000000000000c
48656c6c6f20576f726c64210000000000000000000000000000000000000000

第一个表明偏移量是20也就是十进制的32,所以我们跳过直接去下一行,值是0c,十进制的12,表明我们的字符串有12个字符,最后一行就是元素。

Refer

https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-3ea?s=r

https://blog.openzeppelin.com/deconstructing-a-solidity-smart-contract-part-i-introduction-832efd2d7737

0 条评论
某人
表情
可输入 255