深入理解EVM存储机制及安全问题

创宇区块链安全实验室
创宇区块链安全实验室 机构得得号

Sep 30 专注构建区块链安全生态,致力于让人类进入安全的区块链世界。

摘要: 当智能合约的安全受到威胁,如何正确使用EVM存储机制?

前言

EVM 是一个轻量级的虚拟机,其设计初衷就是提供一种可以忽略硬件、操作系统等兼容性的虚拟的执行环境供以太坊网络运行智能合约。

简单来说 EVM 是一个完全独立的沙盒,在 EVM 中运行的代码是无法访问网络,文件系统和其他进程的,以此来避免错误的代码让智能合约毁灭或者影响外部环境。

EVM存储结构

N32w8KQKDm30FvaDaaBx4dsTuHPe3qMf4YrLARgu.png

可以看到 EVM 存储数据分为两类:

  • 存储在 code 和 storage 里的数据是 non-volatile (不容易丢失的)

  • 存储在 stack,args,memory 里数据是 volatile (容易丢失的)

各个存储位置的含义

code

code 部署合约时储存 data 字段也就是合约内容的空间,即专门存储智能合约的二进制源码的空间。

Storage

Storage 是一个可以读写修改的持久存储的空间,也是每个合约持久化存储数据的地方。Storage 是一个巨大的 map,一共 2^256 个插槽 (slot),每个插糟有 32byte,合约中的“状态变量”会根据其具体类型分别保存到这些插槽中。

Stack

stack 即所谓的“运行栈",用来保存 EVM 指令的输入和输出数据。可以免费使用,没有 gas 消耗,用来保存函数的局部变量,数量被限制在 16 个。

stack的最大深度为 1024 ,其中每个单元是 32 byte。

rWynWb0UCNSq9EMuCUn78rhUlBGrBfIy1dmhoBWw.png

Args

 args 也叫 calldata,是一段只读的可寻址的保存函数调用参数的空间,与栈不同的地方的是,如果要使用calldata里面的数据,必须手动指定偏移量和读取的字节数。

Memory

 Memory 一个简单的字节数组,主要是在运行期间存储数据,将参数传递给内部函数。基于 32byte 进行寻址和扩展。

EVM数据存储概述

前面已经说过 Storage 是每个合约持久化存储数据的地方其储存数据的方式是通过插槽来实现的,现在就具体介绍它是怎么实现的:

状态变量

1.对于大小在 32 字节以内的变量(常量),以其定义的顺序作为它的索引值来存储。即第一个变量的索引为 key(0),第二个变量的索引为 key(1)...

2.对于连续较小的值,可能被优化存储在同一个位置,比如:合约中前四个状态变量都是 uint64 类型的,则四个状态变量的值会被打包成一个 32 字节的值存储在 0 位置。

未优化:

pragma solidity ^0.4.11;

contract C {

  uint256 a = 12;
  uint256 c = 12;
  uint256 b = 12;
  uint256 d = 12;
  function m() view public
returns(uint256,uint256,uint256,uint256){

      return (a,b,c,d);
  }

}

NHz3YAeaPwOPii5MnYinDPg3HReTZBhAHMloVa4L.png

优化后:

pragma solidity ^0.4.11;

contract C {

  uint64 a = 12;
  uint64 c = 12;
  uint64 b = 12;
  uint64 d = 12;
  function m() view public
returns(uint64,uint64,uint64,uint64){

      return (a,b,c,d);
  }

}

2UFipPoABpETiUm5TB1vq9fIaawZpqMxyTasveHT.png

结构体

    对于大小在 32 字节以内的结构体同样也是顺序存储,例如结构体变量索引定义在位置 0,结构体内部有两个成员,则这两个成员的依序为 0 和 1。

pragma solidity ^0.4.11;

contract C {

struct Info {
  uint256 a ;
  uint256 b ;
}
  function m() external returns(uint256,uint256){
      Info storage info;
      info.a = 12 ;
      info.b = 24 ;
      return(info.a,info.b);
  }


}

Kk4JGoFGg1GKJCjX2nJh42n4q6MUi8ihsKY7RxSU.png

映射(map)

map 存储位置是通过 keccak256(bytes32(key)+bytes32(position))计算得到的, position 表示 key 对应 storage 类型变量存储的位置。

pragma solidity ^0.4.11;

contract Test {
mapping(uint256 => uint256) knownsec;

function go() public {
    knownsec[0x60] = 0x40;
}
}

LopTsKxV91S1LVMyUM5FLax5AH1d6usLzCt3nPJQ.png

image-20210910144656629数组

定长数组

定长数组同上只要在 32 字节以内也是顺序存储,不过在编译时编译器会进行边界检查防止越界。

pragma solidity ^0.4.11;

contract C {

  uint256[3] a = [12,24,48] ;
   
  function m() public view
returns(uint256,uint256,uint256){

      return (a[0],a[1],a[2]);
  }
   
}

image-20210910105757679zO9iGZFMmMnloVnB7RMBauiwDryEeFKVC3HPl4xn.png

可变长度数组

由于可变长度数组长度不定,一般在编译可变长度数组时会提前预留存储空间,所以就会使用状态变量的位置存储可变长度数组的长度,而具体的数据地址会通过计算 keccak256(bytes32(position))算得数组首地址,再加数组长度偏移量获得具体的元素。

pragma solidity ^0.4.11;

contract C {

  uint256[] a = [12,24,48] ;
   
  function m() public view returns(uint256,uint256,uint256){
      return (a[0],a[1],a[2]);
  }
   
}

image-20210910105851287uEuVYa7uTyibznuOnn7jUVwXEppjdaoY78YchWTl.png

字节数组和字符串

如果长度小于等于 31 字节 :

1.对于定长字节数组则是同定长数组一样;

2.对于可变字节数组和字符串,会在存储值位置补 0 一直到 32 字节,并用补 0 的最后一个字节存储字符串的编码长度。

pragma solidity ^0.4.4;

contract A{
  string public name0 = "knownsec"; 
  bytes8 public name=0x6b6e6f776e736563;
  bytes public g ;
   
  function test() public {
      g.push(0xAA);
      g.push(0xBB);
      g.push(0xCC);
  }
  function go() public view returns(bytes){
      return g;
  }
}

image-20210910141815486R2JRGRDA7PL4HXwP2EnYCBmD2dILYhjUPj0q3gsM.png

当节数组和字符串长度大于 31 字节时

1.变量位置存储编码长度,并且编码长度公式更换为编码长度 = 字符数 * 2 + 1

2.真实存储值第一个位置通过公式 keccak256(bytes32(position)) 获取,剩余值在获取到的位置顺序存储,同样在最后存储位置补 0 到 32 字节。

string public name = "knownsecooooooooooooooooooooooooo";

LKEV6tNJGNhlJ26jZC7TQOagMmKE1tITvirxYVo3.png

安全问题

前面已经讲到EVM的存储结构及存储机制,现在我们再来探讨其安全问题。

未初始化变量

漏洞原理:

在官方手册中提到结构体,数组和映射的局部变量默认是放在 storage 中的,而 solidity 语言中函数中设置的局部变量的默认类型取决于它们本身的类型。

因此如果在函数内部设置以上 storage 类型变量却没有进行初始化,他们就相当于存储指针指向合约中的其他变量,当我们对其进行改变时改变的就是其指向的变量。

漏洞合约,目的修改owner为自己地址:

pragma solidity ^0.4.0;

contract testContract{

  bool public unlocked = false;

  address public owner = 0xCA35b7d915458EF540aDe6068dFe2F44E8fa733c;

struct Person {

  bytes32 name;

  address mappedAddress;

}

  function test(bytes32 _name , address _mappedAddress) public{

      Person person;

      person.name = _name;

      person.mappedAddress = _mappedAddress;

      require(unlocked);

  }


}

漏洞合约分析:

可以看到该合约在函数部分创建新的结构体时没有进行初始化,由此我们可以利用该函数进行对 owner的修改。不过使用该函数我们还要通过 require 验证,不过这也不难因为状态变量 unlocked 也同样在我们可控的范围内。

具体操作:

调用 test 函数分别传入向

_name 传入:

0x0000000000000000000000000000000000000000000000000000000000000001(真值)

_mappedAddress 传入:

0xfB89eCb0188cb83c220aADDa1468C1635208e821(个人地址)

传参前:

image-20210910175557158hVtC41sKcgrEyR6W9B0B9KbGYj67flkV3tudtxVw.png

传参后:

image-20210910175658949WESWij0lilUHT0vhRoR85uJGbGytMczrZTVUJmNQ.png

可以看到已经成功更改了地址。

总结

可以看到 EVM 的存储器就是一个 key=>value 的健值数据库,存储的数据可以通过校验和来确保一致。

但是其也是和智能合约语言进行交互的,当其中一些规则发生冲突很可能就被别有用心的人用来作恶,所以规范的使用智能合约语言是避开漏洞的必要条件。 

作者:创宇区块链安全实验室;来自链得得内容开放平台“得得号”,本文仅代表作者观点,不代表链得得官方立场凡“得得号”文章,原创性和内容的真实性由投稿人保证,如果稿件因抄袭、作假等行为导致的法律后果,由投稿人本人负责得得号平台发布文章,如有侵权、违规及其他不当言论内容,请广大读者监督,一经证实,平台会立即下线。如遇文章内容问题,请联系微信:chaindd123。

链得得仅提供相关信息展示,不构成任何投资建议
本文系作者 创宇区块链安全实验室 授权链得得发表,并经链得得编辑,转载请注明出处、作者和本文链接

更多精彩内容,关注链得得微信号(ID:ChainDD),或者下载链得得App

相关币种

  • 币种
    实时价格
    涨跌幅
分享到:

相关推荐

    评论(0

    Oh! no

    您是否确认要删除该条评论吗?

    分享到微信