区块链学习笔记之再探以太坊

区块链安全 2年前 (2022) admin
527 0 0
上一节的最后我们说到了二进制接口,说到了opcode。我们调用一个函数可以看作是发起一笔交易,而这一笔交易发送的数据,则是由想要调用的函数的函数选择器、函数需要的参数等组成。于是这一节,我们将从稍微底层一点的角度去学习智能合约存储相关的知识。

运行机制

EVM 是以太坊虚拟机,也是状态机,通过 oracles 预言机获取链外信息,然后智能合约编写 ”如果…就执行…“ 的算法,改变 EVM 的状态。链外的用户也可以通过交易调用合约来改变 EVM 的状态。它们也可以作为矿工生成区块,区块生成后通过共识机制广播到其他节点,最终确认后形成区块。
区块链学习笔记之再探以太坊

反汇编样例分析

举个例子,我们有如下合约代码
pragma solidity ^0.4.23;

contract example {
    uint public a;
    uint public b;

    function hello1(){
        a = 1;
    }

    function hello2(uint var1){
        b = var1;
    }
    
}
其编译部署上链后,我们再将字节码反汇编可以得到
contract Contract {
    function main() {
        memory[0x40:0x60] = 0x80;
    
        if (msg.data.length < 0x04) { revert(memory[0x00:0x00]); }
    
        var var0 = msg.data[0x00:0x20] / 0x0100000000000000000000000000000000000000000000000000000000 & 0xffffffff;
    
        if (var0 == 0x0dbe671f) {
            // Dispatch table entry for a()
            var var1 = msg.value;
        
            if (var1) { revert(memory[0x00:0x00]); }
        
            var1 = 0x007c;
            var var2 = a();
            var temp0 = memory[0x40:0x60];
            memory[temp0:temp0 + 0x20] = var2;
            var temp1 = memory[0x40:0x60];
            return memory[temp1:temp1 + (temp0 + 0x20) - temp1];
        } else if (var0 == 0x297c930b) {
            // Dispatch table entry for hello2(uint256)
            var1 = msg.value;
        
            if (var1) { revert(memory[0x00:0x00]); }
        
            var1 = 0x00bd;
            var2 = msg.data[0x04:0x24];
            hello2(var2);
            stop();
        } else if (var0 == 0x4df7e3d0) {
            // Dispatch table entry for b()
            var1 = msg.value;
        
            if (var1) { revert(memory[0x00:0x00]); }
        
            var1 = 0x00d4;
            var2 = b();
            var temp2 = memory[0x40:0x60];
            memory[temp2:temp2 + 0x20] = var2;
            var temp3 = memory[0x40:0x60];
            return memory[temp3:temp3 + (temp2 + 0x20) - temp3];
        } else if (var0 == 0xdf022cbc) {
            // Dispatch table entry for hello1()
            var1 = msg.value;
        
            if (var1) { revert(memory[0x00:0x00]); }
        
            var1 = 0x00ff;
            hello1();
            stop();
        } else { revert(memory[0x00:0x00]); }
    }
    
    function a() returns (var r0return storage[0x00]; }
    
    function hello2(var arg0{
        storage[0x01] = arg0;
    }
    
    function b() returns (var r0return storage[0x01]; }
    
    function hello1() {
        storage[0x00] = 0x01;
    }
}
我们来进行逐行解读。
首先,我们的函数入口是main函数,
memory[0x40:0x60] = 0x80;
首先初始化,将当前空闲指针的地址放入 memory[0x40:0x60]
if (msg.data.length < 0x04) { revert(memory[0x00:0x00]); }
如果传入的消息的长度小于4个字节,就回退(revert),不做任何状态更改。因为一次函数调用至少要传四个字节,因为函数选择器的长度是四个字节。
var var0 = msg.data[0x00:0x20] / 0x0100000000000000000000000000000000000000000000000000000000 & 0xffffffff;
后面这个很大的数是$2^{224}$,0x20=32个字节是 $2^{256}$,所以还剩 $2^{32}$ 比特,也就是四个字节,然后还与了一个 0xffffffff,取四字节(其实没有必要了)。
所以我们发过去的 msg.data 是从高位开始的 “塞进” slot的。
那么这里我们 var0 存入的值就是函数选择器了。
随后是对 var0 进行的一系列 if……else if…… 判断
    if (var0 == 0x0dbe671f) {
  ......
        } else if (var0 == 0x297c930b) {
  ......
        } else if (var0 == 0x4df7e3d0) {
  ......
        } else if (var0 == 0xdf022cbc) {
  ......
        } else { revert(memory[0x00:0x00]); }
    }
如果 var0 等于 0x0dbe671f,就调用 a(),因为
peth > sha3 a()
0dbe671f81a573cff601b9c227db0ed2e5339d3e0a07edc45c42f315a9cb8f0f
可以注意到前4个字节就是 0x0dbe671f
        if (var0 == 0x0dbe671f) {
            // Dispatch table entry for a()
            var var1 = msg.value;
        
            if (var1) { revert(memory[0x00:0x00]); }
        
            var1 = 0x007c;
            var var2 = a();
            var temp0 = memory[0x40:0x60];
            memory[temp0:temp0 + 0x20] = var2;
            var temp1 = memory[0x40:0x60];
            return memory[temp1:temp1 + (temp0 + 0x20) - temp1];
首先会判断是否存在 msg.value,如果 msg.value 不等于0,就会回退,说明 a() 不是一个 payable 的函数。随后给 var1 赋值 0x007c(无实际意义)
然后给 var2 赋值 a(),是运行函数 a() 返回的内容。然后 temp0 获取当前空闲指针指向的地址,比如为0x80,
然后就把 var2 的值,也就是函数地址,赋值到空闲指针指向的地址,即memory[0x80:0x80 + 0x20] = a();
随后给 temp1 赋值当前空闲指针的地址,还是0x80,因为这个函数调用一次之后没后续了,这里可以被覆盖。
最后 memory[temp1:temp1 + (temp0 + 0x20) - temp1]; 即返回  memory[0x80:0x80 + 0x20]; 也就是运行函数 a() 返回的结果。
这里我们看到函数 a() 的具体内容
    function a() returns (var r0) { return storage[0x00]; }
是将 storage[0x00] 的内容返回,也就是 slot0 的内容。
因为根据我们原代码的定义
contract example {
    uint public a;
    uint public b;
根据顺序,a 的值会被存在 slot0中,b的值会被存在slot1中,这个后续我们还会再深入。
同理,当 var0 等于 0x4df7e3d0,也就是调用 b() 时,
peth > sha3 b()
4df7e3d0fdffd35719c59893b4839a04b686be9ac7bec9cdd04a272e9ad7c628
整个过程也是类似的。
可能会有疑问,明明我们源代码里面没有定义整两个函数,为什么反汇编出来存在这两个函数呢?
注意到我们给 a,b 变量定义的类型是 public 的,所以我们可以从外部访问 a,b 这两个变量。于是在编译的时候,为了实现这样的功能,public 这个属性就给他们构建了这样一个用于访问状态的函数。这样的函数不改变状态,所以也是无消耗的,不需要gas,也不接受ether。(在solidity 后面的版本中,这样不改变状态的函数会用pure等修饰符进行修饰)
当 var0 等于 0xdf022cbc,也就是调用 hello1()
peth > sha3 hello1()
df022cbc1f505806982da95791292644c22f8a2b2c487b63844bdf6ca13714ec
        } else if (var0 == 0xdf022cbc) {
            // Dispatch table entry for hello1()
            var1 = msg.value;
        
            if (var1) { revert(memory[0x00:0x00]); }
        
            var1 = 0x00ff;
            hello1();
            stop();
由于该函数也不是 payable 的,所以还是会检查是否带了 msg.value,如果有,则回退,否则就会调用 hello1() 函数,看到hello1()
    function hello1() {
        storage[0x00] = 0x01;
    }
会进行状态的改变,即将 slot0 处的值变为 0x01,对比源代码,其实就是对变量 a 进行赋值。
然后 stop();结束调用。
当 var0 等于 0x297c930b,也就是调用 hello2(uint256)
peth > sha3 hello2(uint256)
297c930ba4983097298563c2a37f1f470dc5e81bf4c68fdf08e199c2eb95dbe6
        } else if (var0 == 0x297c930b) {
            // Dispatch table entry for hello2(uint256)
            var1 = msg.value;
        
            if (var1) { revert(memory[0x00:0x00]); }
        
            var1 = 0x00bd;
            var2 = msg.data[0x04:0x24];
            hello2(var2);
            stop();
第一步仍然是检查 msg.value,然后这里给 var2赋值,为msg.data[0x04:0x24],也就是用于函数要用的参数,在智能合约中一般以 0x20 个字节为一个单位,那uint256刚好也是 0x20 个字节。随后调用函数 hello2(var2);
    function hello2(var arg0) {
        storage[0x01] = arg0;
    }
hello2函数内容也很简单,就是将 slot1 赋值为 arg0,也就是对 b 进行赋值。
然后 stop();结束调用。
到此,这个简单程序的反汇编代码我们就分析完毕了。
在上面的分析中我们提到了两个点,一个是声明变量的储存位置,一个是我们交易data的格式组成。那么接下来,我们将详细的探究一下这两个方面的内容。

状态变量在储存中的布局

在合约中,数据的存储方式是从位置 0 开始连续放置在存储(storage)中的。存储以存储插槽(storage slot)为单位,一个存储插槽的大小为 32 字节。而状态变量则根据自身的储存大小尽可能紧凑的存储在存储中,具体规则如下:
  • 存储插槽中的第一项会以低位对齐的方式储存。
  • 值类型仅使用存储他们所需的字节。
  • 如果一个被使用的存储插槽中剩余空间不足以存储一个值类型,那么它会被存入下一个存储插槽。
  • 结构体和数组数据总是会开启一个新插槽(但是结构体或数组中的各元素还是按规则紧急打包)。
  • 结构体和数组之后的数据也会开启一个新插槽。
如前文我们所声明的 uint a 和 uint b 变量(uint 默认为 uint256),在存储中布局如下
 ————————————————————————————————
|              a (32byte)        |      <-      slot 0
 ————————————————————————————————
|              b (32byte)        |   <-      slot 1
 ————————————————————————————————
如果我们把变量声明换做
uint120 public a;
uint120 public b;
那么存储布局如下
 ————————————————————————————————
| 0 0 | b (15byte) | a (15byte)  |          <-      slot 0
 ————————————————————————————————

映射和动态数组

由于映射和动态数组的大小是可变的,因此,我们没法预先给他们分配存储插槽,但他们的声明也会根据以上规则占用一个完整的32字节的插槽,而包含在它们其中的元素的存储位置,则是根据他们占用的这个插槽哈希生成而来。如一个数组的声明占用了第 p 个 slot,则这个数组的元素会从 keecak256(p) 开始,且其中的元素也是紧凑排列的,即如果元素的长度不超过16字节,那么它们就有可能共享一个存储插槽。
假设我们声明如下变量
uint public a;
address public owner;
uint[] public users;
mapping(address => uint) balance;
那么存储布局如下
 ————————————————————————————————
|              a (32byte)        |      <-      slot 0
 ————————————————————————————————
|   0  |    owner (20byte)       |   <-      slot 1
 ————————————————————————————————
|       length of users[]        |   <-      slot 2
 ————————————————————————————————
|            [balance]           |   <-      slot 3
 ————————————————————————————————
|                                |   <-      slot 4
 ————————————————————————————————
|               ...              |   <-      ......
 ————————————————————————————————
|            users[0]            |   <-      slot keccak(2)
 ————————————————————————————————
|            users[1]            |   <-      slot keccak(2)+1
 ————————————————————————————————
|               ...              |   <-      ......
前面我们说的是一维数组,里面的数据是从 keecak256(p) 开始,那如果是二维数组呢?那就递归的沿用这个规则就好,就是从keecak256(keecak256(p)) 开始。
例如,想要确定 x[i][j] 的位置,x 是 uint256[][] 类型,占用了第 p 个插槽,那么计算公式如下  keccak256( keccak256(p) + i) + j 如果数据类型不是 uint256,那么最后的偏移要稍微计算一下。
接着我们讨论一下另一个比较特殊的变量,映射。前面说的数组,它在声明的时候会占用一个完整的 slot ,然后会在这个 slot 里保存这个数组的长度,并且在这个数组边长的时候进行更新。同样的,映射在进行变量声明的时候,也会占用一个完整的 slot,但只是占着,不用。 而映射中的健 k 所对应的值 v 所在的插槽则位于 keccak256(h(k).p) 处,其中 h() 是一个填充函数,规则根据键 k 的类型如下所示,. 是连接符,p 是声明时所占用的槽位。
  • 如果 k 是数值类型,h() 则将该值填充为 32 字节
  • 如果 k 是字符串和字节数组,h() 则不做填充。
而如果映射值不是数值类型,则计算出来的槽位标志是数据的起始位置,如,如果值是结构体类型,想要访问到某个具体的结构体成员则要在该起始位置的基础上再添加一个偏移值。

地址计算样例

考虑以下合约
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;


contract C {
    struct S { uint16 a; uint16 b; uint256 c; }
    uint x;
    mapping(uint => mapping(uint => S)) data;
}
这里计算一下 data[4][9].c 的位置,首先映射占用 slot 1 (前面结构体的声明并不占用插槽),因此 data[4] 是从 keccak256(uint256(4).uint256(1)) 开始存储,但是 data[4] 又是一个映射,所以它的占用的插槽 p 则为前面我们计算的值,那么 data[4][9] 则是从 keccak256(uint256(9).keccak256(uint256(4).uint256(1))) 开始储存,而 c 在结构体 S 中的偏移是 1,因为 a,b 合占一个 slot,所以最后 data[4][9].c 的位置为 keccak256(uint256(9).keccak256(uint256(4).uint256(1)))+1,且 c 是一个  uint256 类型,所以它会占用一整个存储插槽。
我们设计部署一下代码观察
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;


contract C {
    struct S { uint16 a; uint16 b; uint256 c; }
    uint x;
    mapping(uint => mapping(uint => S)) data;

    constructor() public {
        data[4][9].c = 0xdeadbeef;

    }
}
在调试界面我们看到
区块链学习笔记之再探以太坊
给出的地址是0x27a93c3e7d03e75f149a36691115f591e714097122c43aa51fa243e8f7faf083
我们来编程计算一下
    function address_calc() public returns (bytes32){
        return bytes32(uint256(keccak256(abi.encode(uint256(9),uint256(keccak256(abi.encode(uint256(4),uint256(1)))))))+1);
    }
调用 address_calc() 得到
区块链学习笔记之再探以太坊

比特串和字符串

摘自https://learnblockchain.cn/docs/solidity
  • byte

在solidity中,比特串(bytes)分为定长的和不定长的。其中,定长比特串有 bytes1,bytes2,...,bytes3232 种,是值类型。可以进行如下几种运算
  • 比较运算符: <=, <, ==, !=, >=, > (返回布尔型)
  • 位运算符: &, |, ^ (按位异或), ~ (按位取反)
  • 移位运算符: << (左移位), >> (右移位)
  • 索引访问:如果 x 是 bytesI 类型,那么 x[k] (其中 0 <= k < I)返回第 k 个字节(只读)。
  • bytes

bytes 和 string 类型的变量是特殊的数组。 bytes 类似于 bytes1[],但是它在 calldata 和 memory 中是连续地存在一起的,不会按每 32 字节一单元的方式来存放。不过我们更多时候应该使用 bytes 而不是 bytes1[] ,因为 Gas 费用更低, 在 memory 中使用 bytes1[] 时,会在元素之间添加31个填充字节。 而在 storage 中,由于紧密包装,这没有填充字节。
  • string

字符串字面常量是指由双引号或单引号引起来的字符串( "foo" 或者 'bar')。 它们也可以分为多个连续的部分( "foo" "bar" 等效于 "foobar"),这在处理长字符串时很有用。 不像在 C 语言中那样带有结束符; "foo" 相当于 3 个字节而不是 4 个。 和整数字面常量一样,字符串字面常量的类型也可以发生改变,
但它们可以隐式地转换成 bytes1,……, bytes32,如果合适的话,还可以转换成 bytes 以及 string
例如: bytes32 samevar = "stringliteral" 字符串字面常量在赋值给 bytes32 时被解释为原始的字节形式。
字符串字面常量只能包含可打印的ASCII字符,这意味着他是介于 0x20 和 0x7E 之间的字符。
此外,字符串字面常量支持下面的转义字符:
  • <newline> (转义实际换行)
  • \ (反斜杠)
  • ' (单引号)
  • " (双引号)
  • b (退格)
  • f (换页)
  • n (换行符)
  • r (回车)
  • t (标签 tab)
  • v (垂直标签)
  • xNN (十六进制转义,见下文)
  • uNNNN (unicode 转义,见下文)
xNN 表示一个 16 进制值,最终转换成合适的字节,而 uNNNN 表示 Unicode 编码值,最终会转换为 UTF-8 的序列。

存储

bytes 和 string 编码是一样的。
一般来说,编码与 bytes1[] 类似,即有一个槽用于存放数组本身同时还有一个数据区,数据区位置使用槽的 keccak256 hash计算。 然而,对于短字节数组(短于32字节),数组元素与长度一起存储在同一个槽中。
具体地说:如果数据长度小于等于 31 字节,则元素存储在高位字节(左对齐),最低位字节存储值 length * 2。 如果数据长度大于等于 32 字节,则在占位插槽 p 存储 length * 2 + 1 ,数据照常存储在 keccak256(p) 中。 因此,可以通过检查是否设置了最低位(因为 length * 2 + 1 的最低位肯定为 1)来区分短数组和长数组。

calldata布局

calldata 由 函数选择器、参数编码组成。
  • 函数选择器 Function Selector

一个函数调用数据的前 4 字节,指定了要调用的函数。这就是某个函数签名的 keccak 哈希的前 4 字节(高位在左的大端序)。这里的函数签名就是函数名称加上由括号括起来的参数类型列表,参数类型间由一个逗号分隔开,且没有空格。
以下是以下常用的基础类型:
  • uint<M>: M 位的无符号整数, 0 < M <= 256、 M % 8 == 0。例如: uint32, uint8, uint256
  • int<M>:以 2 的补码作为符号的 M 位整数, 0 < M <= 256、 M % 8 == 0
  • address:除了字面上的意思和语言类型的区别以外,等价于 uint160。在计算和 函数选择器Function Selector 中,通常使用 address
  • uint、 int: uint256、 int256 各自的同义词。在计算和 函数选择器Function Selector 中,通常使用 uint256 和 int256
  • bool:等价于 uint8,取值限定为 0 或 1 。在计算和 函数选择器Function Selector 中,通常使用 bool
  • bytes<M>: M 字节的二进制类型, 0 < M <= 32
  • function:一个地址(20 字节)之后紧跟一个 函数选择器Function Selector (4 字节)。编码之后等价于 bytes24
  • 参数编码

从第5字节开始是被编码的参数。这种编码方式也被用在其他地方,比如,返回值和事件的参数也会被用同样的方式进行编码,而用来指定函数的4个字节则不需要再进行编码。
  • calldata样例

给定一个合约:
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.4.16;

contract Foo {
  function bar(bytes3[2]) public pure {}
  function baz(uint32 x, bool y) public pure returns (bool r) { r = x > 32 || y; }
  function sam(bytes, bool, uint[]) public pure {}
}
这样,对于我们的例子 Foo,如果我们想用 69 和 true 做参数调用 baz,我们总共需要传送 68 字节,可以分解为:
  • 0xcdcd77c0:函数选择器。即 baz(uint32,bool) 的 Keccak 哈希的前 4 字节。
  • 0x0000000000000000000000000000000000000000000000000000000000000045:第一个参数,一个被用 0 值字节补充到 32 字节的 uint32 值 69
  • 0x0000000000000000000000000000000000000000000000000000000000000001:第二个参数,一个被用 0 值字节补充到 32 字节的 boolean 值 true
合起来就是:
0xcdcd77c000000000000000000000000000000000000000000000000000000000000000450000000000000000000000000000000000000000000000000000000000000001
它返回一个 bool。比如它返回 false,那么它的输出将是一个字节数组 0x0000000000000000000000000000000000000000000000000000000000000000,一个bool值。
如果我们想用 ["abc", "def"] 做参数调用 bar,我们总共需要传送68字节,可以分解为:
  • 0xfce353f6:函数选择器。即 bar(bytes3[2]) 的签名。
  • 0x6162630000000000000000000000000000000000000000000000000000000000:第一个参数的第一部分,一个 bytes3 值 "abc" (左对齐)。
  • 0x6465660000000000000000000000000000000000000000000000000000000000:第一个参数的第二部分,一个 bytes3 值 "def" (左对齐)。
合起来就是:
0xfce353f661626300000000000000000000000000000000000000000000000000000000006465660000000000000000000000000000000000000000000000000000000000
如果我们想用 "dave"、 true 和 [1,2,3] 作为参数调用 sam,我们总共需要传送 292 字节,可以分解为:
  • 0xa5643bf2:函数选择器,即 sam(bytes,bool,uint256[]) 的签名。注意, uint 被替换为了它的权威代表 uint256
  • 0x0000000000000000000000000000000000000000000000000000000000000060:第一个参数(动态类型)的数据部分的位置,即从参数编码块开始位置算起的字节数。在这里,是 0x60 。因为 0x00-0x20存了这个值,0x20-0x40 存了 bool 的值,0x40-0x60 存了 uint256[] 数据部分的位置
  • 0x0000000000000000000000000000000000000000000000000000000000000001:第二个参数:boolean 的 true。
  • 0x00000000000000000000000000000000000000000000000000000000000000a0:第三个参数(动态类型)的数据部分的位置,由字节数计量。在这里,是 0xa0。 # 0x40
  • 0x0000000000000000000000000000000000000000000000000000000000000004:第一个参数的数据部分,以字节数组的元素个数作为开始,在这里,是 4。# 0x60
  • 0x6461766500000000000000000000000000000000000000000000000000000000:第一个参数的内容: "dave" 的 UTF-8 编码(在这里等同于 ASCII 编码),并在右侧(低位)用 0 值字节补充到 32 字节。 (区别 bytes[] 在 calldata 、memory、storage中布局的不同点)# 0x80
  • 0x0000000000000000000000000000000000000000000000000000000000000003:第三个参数的数据部分,以数组的元素个数作为开始,在这里,是 3。# 0xa0
  • 0x0000000000000000000000000000000000000000000000000000000000000001:第三个参数的第一个数组元素。
  • 0x0000000000000000000000000000000000000000000000000000000000000002:第三个参数的第二个数组元素。
  • 0x0000000000000000000000000000000000000000000000000000000000000003:第三个参数的第三个数组元素。
合起来就是:
0xa5643bf20000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000464617665000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003

小结

那么这一节我们学习了变量存储、calldata布局相关的知识,这样在阅读一些简单的汇编代码的时候,就能够大概的还原源代码的布局;也能够分析具体交易中data的含义,从而进一步方便我们的审计工作。

原文始发于微信公众号(山石网科安全技术研究院):区块链学习笔记之再探以太坊

版权声明:admin 发表于 2022年10月20日 上午10:35。
转载请注明:区块链学习笔记之再探以太坊 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...