区块链技术基础知识–SHA256

简介

SHA256是安全散列算法SHA(Secure Hash Algorithm)系列算法之一,其摘要长度为256bits,即32个字节,故称SHA256。 SHA256是SHA-2下细分出的一种算法。SHA-2的名称来自于安全散列算法2(英语:Secure Hash Algorithm 2)的缩写,一种密码散列函数算法标准,由美国国家安全局 (NSA) 设计,美国国家标准与技术研究院(NIST) 发布的一系列密码散列函数,属于SHA算法之一,是SHA-1的后继者。SHA-2下又可再分为六个不同的算法标准。包括了:SHA-224、SHA-256、SHA-384、SHA-512、SHA-512/224、SHA-512/256。这些变体除了生成摘要的长度 、循环运行的次数等一些微小差异外,算法的基本结构是一致的。

回到SHA256上,说白了,它就是一个哈希函数。哈希函数,又称散列算法,是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值(或哈希值)的指纹。散列值通常用一个短的随机字母和数字组成的字符串来代表。

对于任意长度(按bit计算)的消息,SHA256都会产生一个32个字节长度数据,称作消息摘要。当接收到消息的时候,这个消息摘要可以用来验证数据是否发生改变,即验证其完整性。在传输的过程中,数据很可能会发生变化,那么这时候就会产生不同的消息摘要。

SHA算法有如下特性:

  1. 不可以从消息摘要中复原信息;

  2. 两个不同的消息不会产生同样的消息摘要。

专业术语

首先我们来了解几个专业术语

位(Bit),字节(Byte)和字(Word)

SHA始终把消息当成一个位(bit)字符串来处理。本文中,一个“字”(Word)是32位,而一个“字节”(Byte)是8位。比如,字符串“abc”可以被转换成一个位字符串:01100001 01100010 01100011。它也可以被表示成16进制字符串:0x616263.

补位

SHA算法中信息必须进行补位,以使其长度在对512取模以后的余数是448。也就是说,(补位后的消息长度)Q2 = 448。即使长度已经满足对512取模后余数是448,补位也必须要进行。

补位是这样进行的:先补一个1,然后再补0,直到长度满足对512取模后余数是448。总而言之,补位是至少补一位,最多补511位。以信息“abc”为例显示补位的过程。   

原始信息:01100001 01100010 01100011

补位第一步:0110000101100010 01100011 1

首先补一个“1”

补位第二步:0110000101100010 01100011 10…..0

然后补423个“0”

我们可以把最后补位完成后的数据用16进制写成下面的样子

61626380 0000000000000000 00000000

00000000 0000000000000000 00000000

00000000 0000000000000000 00000000

00000000 00000000

现在,补位完成后的数据的长度余数就是448了,我们可以进行下一步操作。

补长度

所谓的补长度是将原始数据的长度补到已经进行了补位操作的消息后面。通常用一个64位的数据来表示原始消息的长度。如果消息长度不大于2^64,那么第一个字就是0。在进行了补长度的操作以后,整个消息就变成下面这样了(16进制格式)

61626380 0000000000000000 00000000

00000000 0000000000000000 00000000

00000000 0000000000000000 00000000

00000000 0000000000000000 00000018

SHA256算法描述

为了更好的理解SHA256的原理,这里首先将算法中可以单独抽出的模块,包括常量的初始化、信息预处理、使用到的逻辑运算分别进行介绍,甩开这些理解上的障碍后,一起来探索SHA256算法的主体部分,即消息摘要是如何计算的。

常量

SHA256算法中用到了8个哈希初值以及64个哈希常量

其中,SHA256算法的8个哈希初值如下:

h0 := 0x6a09e667
h1 := 0xbb67ae85
h2 := 0x3c6ef372
h3 := 0xa54ff53a
h4 := 0x510e527f
h5 := 0x9b05688c
h6 := 0x1f83d9ab
h7 := 0x5be0cd19

这些初值是对自然数中前8个质数(2,3,5,7,11,13,17,19)的平方根的小数部分取前32bit而来

举个例子来说,$ \sqrt{2} $小数部分约为0.414213562373095048,

而0.414213562373095048≈6∗16 −1 +a∗16 −2 +0∗16 −3 +…

于是,质数2的平方根的小数部分取前32bit就对应出了0x6a09e667

在SHA256算法中,用到的64个常量如下:

428a2f98 71374491 b5c0fbcf e9b5dba5
3956c25b 59f111f1 923f82a4 ab1c5ed5
d807aa98 12835b01 243185be 550c7dc3
72be5d74 80deb1fe 9bdc06a7 c19bf174
e49b69c1 efbe4786 0fc19dc6 240ca1cc
2de92c6f 4a7484aa 5cb0a9dc 76f988da
983e5152 a831c66d b00327c8 bf597fc7
c6e00bf3 d5a79147 06ca6351 14292967
27b70a85 2e1b2138 4d2c6dfc 53380d13
650a7354 766a0abb 81c2c92e 92722c85
a2bfe8a1 a81a664b c24b8b70 c76c51a3
d192e819 d6990624 f40e3585 106aa070
19a4c116 1e376c08 2748774c 34b0bcb5
391c0cb3 4ed8aa4a 5b9cca4f 682e6ff3
748f82ee 78a5636f 84c87814 8cc70208
90befffa a4506ceb bef9a3f7 c67178f2

和8个哈希初值类似,这些常量是对自然数中前64个质数(2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97…)的立方根的小数部分取前32bit而来。

信息预处理

SHA256算法中的预处理就是在想要Hash的消息后面补充需要的信息,使整个消息满足指定的结构。

信息的预处理分为两个步骤:附加填充比特和附加长度

即我们上面讲的补位和补长度。

在完成了信息预处理之后,我们来看看sha256中的逻辑运算

逻辑运算

SHA256散列函数中涉及的操作全部是逻辑的位运算

包括如下的逻辑函数:

CH(x, y, z) = (x AND y) XOR ( (NOT x) AND z)  
MAJ( x, y, z) = (x AND y) XOR (x AND z) XOR (y AND z)  
BSIG0(x) = ROTR^2(x) XOR ROTR^13(x) XOR ROTR^22(x)  
BSIG1(x) = ROTR^6(x) XOR ROTR^11(x) XOR ROTR^25(x)  
SSIG0(x) = ROTR^7(x) XOR ROTR^18(x) XOR SHR^3(x)  
SSIG1(x) = ROTR^17(x) XOR ROTR^19(x) XOR SHR^10(x) 

其中 x、y、z皆为32bit的字。 ROTR^2(x)是对x进行循环右移2位。

计算消息摘要

基本思想:就是将消息分成N个512bit的数据块,哈希初值H(0)经过第一个数据块得到H(1),H(1)经过第二个数据块得到H(2),……,依次处理,最后得到H(N),然后将H(N)的8个32bit连接成256bit消息摘要

首先:将消息分解成512-bit大小的块

如上图所示,假设消息M可以被分解为n个块,于是整个算法需要做的就是完成n次迭代,

n次迭代的结果就是最终的哈希值,即256bit的数字摘要。

一个256-bit的摘要的初始值H0,经过第一个数据块进行运算,得到H1,即完成了第一次迭代

H1经过第二个数据块得到H2,……,依次处理,最后得到Hn,Hn即为最终的256-bit消息摘要

将每次迭代进行的映射用$ Map(H_{i-1}) = H_{i} $表示,于是迭代可以更形象的展示为:

图中256-bit的Hi被描述8个小块,这是因为SHA256算法中的最小运算单元称为“字”(Word),一个字是32位。

此外,第一次迭代中,映射的初值设置为前面介绍的8个哈希初值,如下图所示:

下面开始介绍每一次迭代的内容,即映射$ Map(H_{i-1}) = H_{i} $的具体算法:

第一步,构造64个字(word)

对于每一块,将块分解为16个32-bit的big-endian的字,记为w[0], …, w[15]

也就是说,前16个字直接由消息的第i个块分解得到

其余的字由如下迭代公式得到:

Wt=σ1(Wt−2)+Wt−7+σ0(Wt−15)+Wt−16

第二步, 进行64次循环

映射 $ Map(H_{i-1}) = H_{i} $ 包含了64次加密循环

即进行64次加密循环即可完成一次迭代

每次加密循环可以由下图描述:

图中,ABCDEFGH这8个字(word)在按照一定的规则进行更新,其中

深蓝色方块是事先定义好的非线性逻辑函数,上文已经做过铺垫

红色田字方块代表 mod $ 2^{32} $ addition,即将两个数字加在一起,如果结果大于$ 2^{32} ,你必须除以 ,你必须除以,你必须除以 2^{32} $并找到余数。

ABCDEFGH一开始的初始值分别为$ H_{i-1}(0),H_{i-1}(1),…,H_{i-1}(7) $

Kt是第t个密钥,对应我们上文提到的64个常量

Wt是本区块产生第t个word。原消息被切成固定长度512-bit的区块,对每一个区块,产生64个word,通过重复运行循环n次对ABCDEFGH这八个字循环加密。

最后一次循环所产生的八个字合起来即是第i个块对应到的散列字符串$ H_{i} $

由此便完成了SHA256算法的所有介绍

深入EVM:EVM工作机制的深层分析(终极版)

本文翻译自英文原文: Getting Deep Into EVM: How Ethereum Works Backstage

本文是“深入区块链”系列文章之一,意在深入介绍以太坊等区块链的内部工作机制。LinkChain博客前期曾翻译并分享了该系列文章的其中一篇“四十种智能合约支持平台(完全版)”,其它文章如下:

Getting Deep Into Geth: Why Syncing Ethereum Node Is Slow

Getting Deep Into Ethereum: How Data Is Stored In Ethereum?

本文将详细深入介绍EVM的核心机制,涉及智能合约建立机制、消息调用机制,并介绍存储、内存、CALLDATA和栈(STACK)这四类数据管理机制。

要理解本文内容,读者最好具有基础的以太坊知识。如果有必要,推荐阅读下面这篇博客:

5 resources to get started with ethereum

文中使用的所有例子和演示,均开源提供在该代码库中,读者可以克隆代码、运行npm install,进而查看运行结果。

EVM概览

在深入了解并通过代码例子查看EVM的工作机制之前,我们先阐述EVM最适用于以太坊之处,以及EVM的组成。在看到下面给出的复杂结构图时,请不要心生恐惧。一旦读完本文,你自然会理解这些它们。

下图给出了EVM是如何匹配以太坊运行的:

下图给出了EVM的基本架构:

下图给出了EVM各组成部分间的相互作用机制,由此实现了以太坊的神奇功能。

至此,我们对EVM有了一个整体上的了解。下面,我们将深入介绍各部分在以太坊运行中的重要作用。

以太坊合约(Ethereum Contracts)

基本概念

智能合约就是一种计算机程序。以太坊合约可以称为运行在EVM上的智能合约。EVM是一种“沙盒”(Sandbox)运行时,它为智能合约在以太坊中的运行提供了完全独立的环境。这也意味着,运行在EVM中的每个智能合约不能访问网络,也不能访问运行EVM主机上的任何进程。

我们知道,以太坊具有两种类型的账户,合约账户(contract account)和外部账户(external account)。每个账户由一个地址唯一标识,所有账户共享同一地址空间。EVM可处理的地址长度为160比特。

每个账户由余额(balance)、nonce、字节码(bytecode)和存储数据(即存储,storage)组成。以太坊的两类账户间存在着一些差异之处。例如,外部账户的字节码和存储为空,而合约账户中存储了字节码和整个状态树的默克尔根哈希值。此外,外部地址对应一个私钥,而合约账户则没有。对于合约账户,除了对每个以太坊交易做正常的加密认证外,所有其余动作均由其所持有的字节码控制。

创建合约

合约是以交易的形式创建的。交易中的接收者地址为空,数据域则包含了要创建合约的编译后字节码(需注意,一个合约可以创建另一个合约)。下面给出一个例子。打开练习一的目录,其中可以看到一个名为“MyContract”的合约。该合约的代码如下:

pragma solidity ^0.4.21;
contract MyContract {
  event Log(address addr);
function MyContract() public {
    emit Log(this);
  }
function add(uint256 a, uint256 b) public pure returns (uint256) {
    return a + b;
  }
}

运行命令truffle develop,以开发模式打开一个Truffle终端。一旦终端启动完成,使用下面命令在MyContract中部署实例:

truffle(develop)> compile
truffle(develop)> sender = web3.eth.accounts[0]
truffle(develop)> opts = { from: sender, to: null, data: MyContract.bytecode, gas: 4600000 }
truffle(develop)> txHash = web3.eth.sendTransaction(opts)

下面检查合约是否成功部署。运行如下代码:

truffle(develop)> receipt = web3.eth.getTransactionReceipt(txHash)
truffle(develop)> myContract = new MyContract(receipt.contractAddress)
truffle(develop)> myContract.add(10, 2)
{ [String: ‘12’] s: 1, e: 1, c: [ 12 ] }

我们深入查看一下上面的工作。当一个新的合约部署到以太坊区块链上时,首先完成的是创建合约对应的账户¹。在上例中我以看到,构造函数(constructor)中记录了合约的地址。为进一步确认,可查看receipt.logs[0].data中保存的就是合约生成的32个字节的地址,receipt.logs[0].topics中保持的是“Log(address)”字符串的Keccak-256哈希(即SHA3)。 K

调用合约中函数时,后台的运行结构图

下一步,随交易发送的数据将以字节码方式执行。它初始化存储中的状态变量,并确定所创建的合约体。该过程在合约的全生命周期中仅执行一次。初始化的代码并非存储在合约中,它实际上生成了要存储的字节码作为其返回值。谨记,一旦合约账户创建完成,就无法更改合约的代码²。

鉴于初始化过程返回了合约体中将要存储的字节码,因此从构造函数的逻辑无法访问此代码,这点具有意义的。下面以练习一中的Impossible合约为例:

contract Impossible {
  function Impossible() public {
    this.test();
  }
function test() public pure returns(uint256) {
    return 2;
  }
}

如果我们尝试编译该合约,那么就会得到警告,指出在构造函数中引用了 this (即“referencing this within the constructor function”)。该警告并不影响编译的继续进行。但是一旦部署新实例,就会出现终止执行并还原状态(revert)。这是因为运行尚未存储的代码是毫无意义的³。另一方面,因为账户已经创建,我们可以访问合约的地址,但是其中并未存储任何代码。

此外,代码执行还会产生另一种情况,例如更改了存储、创建更多账户、做更多的消息调用等。下面以AnotherContract合约代码为例:

contract AnotherContract {
  MyContract public myContract;
  function AnotherContract() public {
    myContract = new MyContract();
  }
}

在Truffle终端中运行下面命令,查看合约的运行情况:

truffle(develop)> compile
truffle(develop)> sender = web3.eth.accounts[0]
truffle(develop)> opts = { from: sender, to: null, data: AnotherContract.bytecode, gas: 4600000 }
truffle(develop)> txHash = web3.eth.sendTransaction(opts)
truffle(develop)> receipt = web3.eth.getTransactionReceipt(txHash)
truffle(develop)> anotherContract = AnotherContract.at(receipt.contractAddress)
truffle(develop)> anotherContract.myContract().then(a => myContractAddress = a)
truffle(develop)> myContract = MyContract.at(myContractAddress)
truffle(develop)> myContract.add(10, 2)
{ [String: ‘12’] s: 1, e: 1, c: [ 12 ] }

另一方面,鉴于Solidity结构最终也将编译为指令码,因此合约也可以使用CREATE指令码(opcode)创建。上面介绍的两种合约创建方式的工作机制相同。

下面,我们将介绍消息调用的工作机制。

消息调用(Message Call)

合约间可以通过消息调用实现相互调用。一个Solidity合约在每次调用另一个合约的函数时,就会生成一次消息调用。每次调用具有发送者、接受者、二进制内容(payload)、值以及GAS数量。限制消息调用的深度为不大于1024层。

Solidity为地址类型提供了原生调用方法,工作如下:

address.call.gas(gas).value(value)(data)

其中,gas是要传递的GAS数量,address是调用的地址,value是要传递的以太币wei数,data是要发送的二进制内容。谨记,valuegas是可选参数,使用应谨慎。因为默认情况下,低层调用将会将发送者几乎全部剩余的GAS发送出去。

图 GAS消费结构图

从上图可见,合约可以确定每次调用中要传递的GAS数量。每次调用都会因为“GAS耗尽”(OOG,out-of-gas)异常而终止执行。为避免出现安全问题,调用中至少会保留发送者GAS数量的1/64。这使得发送者可以处理调用的OOG异常、完成执行而不会耗尽GAS,进而也不会触发异常。

产生异常的结构图

下面以练习二中的Caller合约为例:

contract Implementation {
  event ImplementationLog(uint256 gas);
function() public payable {
    emit ImplementationLog(gasleft());
    assert(false);
  }
}
contract Caller {
  event CallerLog(uint256 gas);
  Implementation public implementation;

  function Caller() public {
    implementation = new Implementation();
  }
function () public payable {
    emit CallerLog(gasleft());
    implementation.call.gas(gasleft()).value(msg.value)(msg.data);
    emit CallerLog(gasleft());
  }
}

其中,Caller合约只有一个回调函数,实现所有接收到的调用重定向到Implementation实例。该实例只是通过每个接收到的调用上的assert(false)抛出,调用将消费所有提供的GAS,进而在传递调用给Implementation之前和之后,将GAS数量记录到Caller中。下面启动一个Truffle终端运行如下命令,查看运行情况:

truffle(develop)> compile
truffle(develop)> Caller.new().then(i => caller = i)
truffle(develop)> opts = { gas: 4600000 }
truffle(develop)> caller.sendTransaction(opts).then(r => result = r)
truffle(develop)> logs = result.receipt.logs
truffle(develop)> parseInt(logs[0].data) //4578955
truffle(develop)> parseInt(logs[1].data) //71495

如结果所示,71495大体上构成了4578955的第64个部分。该例子清晰地验证了,代码处理了内部调用抛出的OOG异常。

Solidity还提供了call操作码,支持在内联汇编(inline assembly)中管理调用:

call(g, a, v, in, insize, out, outsize)

其中,g是要传递的GAS数量,a是被调用地址,v是要传递的以太币wei数,in指定了保存调用数据的insize字节的内存地址,outoutsize指定了返回数据的内存存储地址。汇编调用与函数二者的唯一不同之处在于,汇编调用支持我们处理返回数据,而函数只会返回1或0指示函数处理成功与否。

EVM支持一类特殊的消息调用变体,称为“delegatecall”。同上,Solidity在提供内联汇编版本的同时,还提供了内建的地址方法。二者的不同之处在于,对于低层调用,目标代码在调用合约的上下文内执行,而msg.sendermsg.value并非如此。⁴

更好地理解delegatecall的工作机制,我们对下面的例子进行分析。首先给出Greeter合约的代码:

contract Greeter {
  event Thanks(address sender, uint256 value);
function thanks() public payable {
    emit Thanks(msg.sender, msg.value);
  }
}

如上,Greeter合约只定义了一个thanks函数,发出一个承载了msg.valuemsg.sender数据的事件。在Truffle终端中使用如下命令运行该方法:

truffle(develop)> compile
truffle(develop)> someone = web3.eth.accounts[0]
truffle(develop)> ETH_2 = new web3.BigNumber(‘2e18’)
truffle(develop)> Greeter.new().then(i => greeter = i)
truffle(develop)> opts = { from: someone, value: ETH_2 }
truffle(develop)> greeter.thanks(opts).then(tx => log = tx.logs[0])
truffle(develop)> log.event                     //Thanks
truffle(develop)> log.args.sender === someone   //true
truffle(develop)> log.args.value.eq(ETH_2)      //true

运行结果确认了该函数的功能。注意Wallet合约的代码:

contract Wallet {
  Greeter internal greeter;

  function Wallet() public {
    greeter = new Greeter();
  }

  function () public payable {
    bytes4 methodId = Greeter(0).thanks.selector;
    require(greeter.delegatecall(methodId));
  }
}

该合约只定义了一个回调函数,通过delegatecall执行Greeter#thanks方法。下面通过Wallet合约调用Greeter#thanks合约,在Truffle终端查看运行情况:

truffle(develop)> Wallet.new().then(i => wallet = i)
truffle(develop)> wallet.sendTransaction(opts).then(r => tx = r)
truffle(develop)> logs = tx.receipt.logs
truffle(develop)> SolidityEvent = require(‘web3/lib/web3/event.js’)
truffle(develop)> Thanks = Object.values(Greeter.events)[0]
truffle(develop)> event = new SolidityEvent(null, Thanks, 0)
truffle(develop)> log = event.decode(logs[0])
truffle(develop)> log.event                    // Thanks
truffle(develop)> log.args.sender === someone  // true
truffle(develop)> log.args.value.eq(ETH_2)     // true

从结果中可以看到,delegatecall函数保持了msg.valuemsg.sender

这意味着合约可以在运行时从不同的地址动态地加载代码。存储、当前地址和余额依然指向调用合约,只有代码是取自于被调用地址。这意味着可在Solidity中实现“软件库”⁵。

关于delegatecalls,我们还需要了解一件事情。如上所述,被调用合约的存储是可以被所执行代码访问的。下面查看Calculator合约的代码:

contract ResultStorage {
  uint256 public result;
}
contract Calculator is ResultStorage {
  Product internal product;
  Addition internal addition;
function Calculator() public {
    product = new Product();
    addition = new Addition();
  }

  function add(uint256 x) public {
    bytes4 methodId = Addition(0).calculate.selector;
    require(addition.delegatecall(methodId, x));
  }
function mul(uint256 x) public {
    bytes4 methodId = Product(0).calculate.selector;
    require(product.delegatecall(methodId, x));
  }
}
contract Addition is ResultStorage {
  function calculate(uint256 x) public returns (uint256) {
    uint256 temp = result + x;
    assert(temp >= result);
    result = temp;
    return result;
  }
}
contract Product is ResultStorage {
  function calculate(uint256 x) public returns (uint256) {
    if (x == 0) result = 0;
    else {
      uint256 temp = result * x;
      assert(temp / result == x);
      result = temp;
    }
    return result;
  }
}

其中,Calculator合约只有两个函数,即addproductCalculator合约并不知道如何执行相加或相乘运算,而是将相应的调用分别代理(delegate)给AdditionProduct合约。所有这些合约共享相同的状态变量结果,并存储每次计算的结果。下面在Turffle终端运行命令,查看运行情况:

truffle(develop)> Calculator.new().then(i => calculator = i)
truffle(develop)> calculator.addition().then(a => additionAddress=a)
truffle(develop)> addition = Addition.at(additionAddress)
truffle(develop)> calculator.product().then(a => productAddress = a)
truffle(develop)> product = Product.at(productAddress)
truffle(develop)> calculator.add(5)
truffle(develop)> calculator.result().then(r => r.toString()) // 5
truffle(develop)> addition.result().then(r => r.toString())   // 0
truffle(develop)> product.result().then(r => r.toString())    // 0
truffle(develop)> calculator.mul(2)
truffle(develop)> calculator.result().then(r => r.toString()) // 10
truffle(develop)> addition.result().then(r => r.toString())   // 0
truffle(develop)> product.result().then(r => r.toString())    // 0

可以确认,我们使用了Calculator合约的存储。此外,所执行的代码存储在Product合约和Addition合约中。

对于call函数,同样存在Solidity汇编操作码版本的delegatecall。下面给出Delegator合约的代码,注意其中对delegatecall的调用方式:

contract Implementation {
  event ImplementationLog(uint256 gas);
function() public payable {
    emit ImplementationLog(gasleft());
    assert(false);
  }
}
contract Delegator {
  event DelegatorLog(uint256 gas);
  Implementation public implementation;
function Delegator() public {
    implementation = new Implementation();
  }
function () public payable {
    emit DelegatorLog(gasleft());
    address _impl = implementation;
assembly {
     let ptr := mload(0x40)
     calldatacopy(ptr, 0, calldatasize)
     let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
    }

    emit DelegatorLog(gasleft());
  }
}

这里,我们使用了内联汇编去执行delegatecall。你可能注意到,其中没有值参数,这是因为msg.value不会发生更改。你可能会有疑问,为什么这里我们加载的是0x40地址?calldatacopycalldatasize是什么?我们将在本系列的下一篇文章中详细介绍。下面,我们可以在Truffle终端中运行同上的命令,验证合约的行为。

再次强调,重要的是要清楚理解delegatecall的工作机制。每此触发的调用将被从当前合约发送,而非代理调用的合约。此外,所执行代码可以读写调用者合约的存储。如果合约并未正确地实现,甚至是存在非常小的错误,都可能导致上百万美元的损失。下文列出了以太坊历史上一些最严重的错误:

HackPedia: 16 Solidity Hacks/Vulnerabilities, their Fixes and Real World Examples

该文列举了Solidity被破解和漏洞的完全列表、修复情况,以及一些真实世界的破解实例。

数据管理(Data Management)

EVM根据不同的应用场景,采用不同的方式管理不同类型的数据。除了合约代码之外,合约所管理的数据大体可分为四类:栈(Stack)、调用数据(calldata)、内存和存储。

栈(tack)

EVM本身就是一种栈机器。也就是说,EVM的操作并非基于注册函数,而是基于虚拟栈。栈的最大规模是1024,其中栈条目(item)的大小是256比特。事实上,EVM是一种256比特的字(word)机器,这种设计便于Keccak256哈希模式和椭圆曲线密码(elliptic-curve)的计算。下图给出了大部分操作码输入参数的来源。

EVM提供了多种操作码,用于直接操作栈。其中包括:

  • POP:从栈中移除条目。
  • PUSHn:将后面n个字节条目置于栈中,n的大小介于1到32之间。
  • DUPn:复制第n个栈条目,n的大小介于1到32之间。
  • SWAPn:交换栈中第一个和第n个条目的位置,n的大小介于1到32之间。

调用数据(Calldata)

调用数据是只读的字节地址编码空间,用于存储交易或调用的数据参数。不同于栈,要使用调用数据,必须要准确地指定字节偏移量和要读取的字节数。

调用数据的EVM操作码包括:

  • CALLDATASIZE:指定交易数据的规模。
  • CALLDATALOAD:加载32字节的交易数据到栈中。
  • CALLDATACOPY:拷贝交易数据的指定字节数到内存中。

Solidity为上述操作码提供了内联编译版本,分别是calldatasizecalldataloadcalldatacopy。其中,calldatacopy需要指定三个参数(t, f, s),将f地址处的调用数据拷贝s个字节到t地址。此外,Solidity支持通过msg.data访问调用数据。

你可能注意到,我们在以前文章的一些例子中使用了部分操作码。下面,我们再看一下delegatecall的内联汇编代码块:

assembly {
  let ptr := mload(0x40)
  calldatacopy(ptr, 0, calldatasize)
  let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
}

要将调用代理给_impl地址,我们必须提交msg.data。鉴于delegatecall操作码在内存中操作数据,我们首先需要将调用数据拷贝到内存中。这里,我们使用calldatacopy,将所有的调用数据拷贝到指定内存指针处。注意,我们使用了calldatasize

下面,我们看一下另一个使用调用数据的例子。在练习三的目录中,可以看到Calldata合约的代码如下:

contract Calldata {
function add(uint256 _a, uint256 _b) public view 
  returns (uint256 result) 
  {
    assembly {
      let a := mload(0x40)
      let b := add(a, 32)
      calldatacopy(a, 4, 32)
      calldatacopy(b, add(4, 32), 32)
      result := add(mload(a), mload(b))
    }
  }
}

上面的代码将返回由参数传递而来的两个数字的相加运算结果。注意,这里我们再一次加载了从0x40处读取的内存指针,原因将在本文稍后给出解释。我们在变量a中存储内存指针,并在变量b中存储a的32个字节之后的位置。然后我们使用calldatacopy将首个参数存储在a中。你应该注意到,数据是从调用数据的第四个位置处拷贝的,而非首个位置处。这是因为调用数据的头四个字节保存了被调用函数的签名,这里是bytes4(keccak256("add(uint256,uint256)"))。这是EVM用于识别调用时需要的函数。然后,我们存储b中第二个参数,拷贝调用数据随后32个字节。最后,我们只需要计算加载在内存中两个值的和。

在Truffle终端中运行下面命令,测试运行结果:

truffle(develop)> compile
truffle(develop)> Calldata.new().then(i => calldata = i)
truffle(develop)> calldata.add(1, 6).then(r => r.toString())    // 7

内存(Memory)

内存是一种易失性、字节可寻址的读写空间,用于在合约执行期间存储数据,主要是将参数传递给内部函数。鉴于内存是易失性区域,因此在每次消息调用开始,都要执行清除内存操作。所有位置的最初定义为零。对于调用数据,内存可采用字节级别寻址,但一次只能读取32字节。

一旦一个字写入了一块以前从未使用的内存,我们就称之为内存“扩展”了。除了内存写入需要一定代价外,内存扩展也是有代价的,前724个字节的扩展代价是线性的,之后的扩展代价呈二次方增长。

EVM提供了三个操作内存区域的操作码:

  • MLOAD:将字从内存加载到栈中。
  • MSTORE:将字保存到内存中。
  • MSTORE8:将一个字节保存到内存中。

Solidity同样对这些操作码提供了相应的内联汇编版本。

关于内存,我们还需要了解另一个关键点。Solidity总是在位置“0x40”处存储空闲内存指针,即对存储器中第一个未使用的字的引用。这就是为什么我们在操作内联汇编时需要加载这个字。这是因为头64字节内存是为EVM保留的,这样可以确保不会覆盖Solidity内部使用的内存。例如,在上面给出的delegatecall例子中,我们加载此指针,存储给定的调用数据,实现数据转发。这是因为内联汇编操作码delegatecall需要从内存中获取其负载。

此外,如果查看Solidity编译器的字节码输出,那么我们就会发现所有字节码是以0x6060604052…开始的,这表示了:

PUSH1   :  EVM操作码0x60
0x60    :  自由内存指针。
PUSH1   :  EVM操作码0x60。
0x40    :  自由内存指针的内存位置。
MSTORE  :  EVM操作码0x52。

在汇编层级操作内存必须要谨慎,因为存在覆盖保留区域的风险。

存储(Storage)

存储是一种持久的、字可寻址的读写空间,是合约存储其中持久信息的地方。不同于内存,存储是持久性区域,只能使用字作为地址。它是2²⁵⁶个槽的键值映射,其中每个槽32字节。除了合约自身的存储,合约既不能读取也不能写入其它任何存储。所有位置最初定义为零。

在EVM的所有操作中,将数据保存到存储是需GAS数量最高的操作之一。这笔费用并非一成不变的。将存储槽从零值修改为非零值需要2万个GAS。存储相同的非零值或将非零值设置为零时需要5千个GAS。但是,对于后一种应用场景,即将非零值设置为零时,会提供15000个GAS的返还款。

EVM提供了两个操作存储的操作:

  • SLOAD:将字从存储加载到栈中。
  • SSTORE:将字保存到存储。

同样,Solidity内联编译也支持这些操作码。

Solidity自动将合约中每个已定义的状态变量映射到存储的相应插槽中。映射策略非常简单:固定大小的变量(即除映射和动态数组之外的所有变量)从存储的位置0开始连续布局。

对于动态数组,p槽位存储数据长度,数组数据将由p哈希(即keccak256(p))确定槽位数。

对于映射,不使用槽位,对应于键k的值由keccak256(k,p)定位。谨记,keccak256 的参数(kp)总是填充为32字节。

为解释其中的工作机制,我们分析下面给出代码例子。在练习三合约目录中,提供了Storage合约的代码如下:

contract Storage {
uint256 public number;
  address public account;
  uint256[] private array;
  mapping(uint256 => uint256) private map;
function Storage() public {
    number = 2;
    account = this;
    array.push(10);
    array.push(100);
    map[1] = 9;
    map[2] = 10;
  }
}

打开一个Truffle终端,测试合约的存储结构。首先,编译并创建一个新的合约实例: Now, let’s open a truffle console to test its storage structure. First, we will compile and create a new contract instance:

truffle(develop)> compile
truffle(develop)> Storage.new().then(i => storage = i)

确保地址0保存数值2,地址1保存了合约的地址:

truffle(develop)> web3.eth.getStorageAt(storage.address, 0)  // 0x02
truffle(develop)> web3.eth.getStorageAt(storage.address, 1)  // 0x..

检查存储位置2保存了数组长度:

truffle(develop)> web3.eth.getStorageAt(storage.address, 2)  // 0x02

最后,检查存储位置3是未使用的,映射值的存储方式如我们上面所介绍:

truffle(develop)> web3.eth.getStorageAt(storage.address, 3) 
// 0x00
truffle(develop)> mapIndex = ‘0000000000000000000000000000000000000000000000000000000000000003’
truffle(develop)> firstKey = ‘0000000000000000000000000000000000000000000000000000000000000001’
truffle(develop)> firstPosition = web3.sha3(firstKey + mapIndex, { encoding: ‘hex’ })
truffle(develop)> web3.eth.getStorageAt(storage.address, firstPosition)
// 0x09
truffle(develop)> secondKey = ‘0000000000000000000000000000000000000000000000000000000000000002’
truffle(develop)> secondPosition = web3.sha3(secondKey + mapIndex, { encoding: ‘hex’ })
truffle(develop)> web3.eth.getStorageAt(storage.address, secondPosition)
// 0x0A

很好,上面演示了Solidity存储策略,正如我们所理解的!要了解更多Solidity是如何映射状态变量到存储中,可参阅官方文档.

希望本文有助于大家更好地理解EVM在以太坊架构中的功能。

¹ 以太坊黄皮书中提出,“新账户地址定义为:对仅包含发送者和帐户nounce的结构做RLP编码,对所得到编码求Keccak哈希值,取该哈希值最右边开始的160位。”

² zeppelin_os的支柱之一就是合同的可升级性。 Zeppelin一直在探索实施这一目标的不同策略。 在这里阅读更多相关信息。

³ Solidity在调外部函数前,会先验证该函数的地址中是否具有字节码。否则,终止函数执行并还原状态。

作者简介

Vaibhav Saini是一家由MIT Cambridge 创新中心孵化的初创企业TowardsBlockchain的联合创始人。Saini也是一名高级区块链开发人员,具有Ethereum、Quorum、EOS、Nano、Hashgraph、IOTA等多种区块链平台的开发经验。他目前是德里印度理工学院(IIT Delhi)的一名大二学生。

区块链技术基础知识–加密与哈希

区块链的世界中我们经常能够听到加密、哈希、签名等字眼,那么他们之间到底有什么区别和关联呢,下面我们来一一解读。

基本概念

加密(Encrypt)是将目标文本转换成具有不同长度的、可逆的密文。

哈希(Hash)是将目标文本转换成具有相同长度的、不可逆的杂凑字符串。(或叫做消息摘要)

加密和哈希的区别

首先,加密、哈希和数字签名都属于加密学的范畴。我们这里所说的加密和哈希指的是加密/解密算法和哈希算法。

哈希算法往往被设计成生成具有相同长度的文本,而加密算法生成的文本长度与明文本身的长度有关

例如,设我们有两段文本:“bitcoin”和“eth”。两者使用某种哈希算法得到的结果分别为:“140864078AECA1C7C35B4BEB33C53C34”和“8B36E9207C24C76E6719268E49201D94”,而使用某种加密算法的到的结果分别为“Njdsptpgu”和“Hpp”。可以看到,哈希的结果具有相同的长度,而加密的结果则长度不同。实际上,如果使用相同的哈希算法,不论你的输入有多么长,得到的结果长度是一个常数,而加密算法往往与明文的长度成正比。

哈希算法是不可逆的,而加密算法是可逆的

这里的不可逆有两层含义, 一是“给定一个哈希结果R,没有方法将E转换成原目标文本S”, 二是“给定哈希结果R,即使知道一段文本S的哈希结果为R,也不能断言当初的目标文本就是S”。其实稍微想想就知道,哈希是不可能可逆的,因为如果可逆,那么哈希就是世界上最强悍的压缩方式了——能将任意大小的文件压缩成固定大小。

加密则不同,给定加密后的密文R,存在一种方法可以将R确定的转换为加密前的明文S。

常见的加密算法

对称加密

指加密和解密使用相同密钥的加密算法。

常见的对称加密算法有: DES、3DES、DESX、Blowfish、IDEA、RC4、RC5、RC6和AES

非对称加密

指加密和解密使用不同密钥的加密算法,也称为公私钥加密。

常见的非对称加密算法:RSA、ECC(椭圆加密)、Diffie-Hellman、El Gamal

常见的哈希算法

常见的哈希算法有MD4、MD5、SHA1、SHA256和SHA512 等

数字签名技术

数字签名技术是使用非对称加密算法来验证数据发送者的方式。

简单举例如下:

发送报文时

发送方首先用接收者的公钥,对数据进行加密。

然后再将加密后的数据使用发送者的私钥进行加密(签名动作)

然后将数据发送给接受者

接收报文时

接收者接收到报文以后首先尝试使用发送者的公钥进行解密(签名验证)

如果解密失败则这个数据不是发送者发过来的,直接抛弃

如果解密成功,再使用接受者的私钥对数据进行解密,这样就可以看到明文了。

数据完整性校验

数据签名技术可以保证数据源没有问题,数据加解密可以保证数据被窃听者获取也能防止窃听者知道数据的内容,要做到数据的安全传输,还需要确定收到的数据没有经过窃听者的篡改,这就涉及到数据的完整性校验。

数据完整性校验一般使用哈希算法和密钥对数据进行哈希得到数据的一个哈希值,然后将该哈希值和数据一块发送给对方,对方收到数据之后,对数据使用相同的哈希算法和密钥进行哈希得到哈希值,如果得到的哈希值和对方发过来的相同,那么就说明数据没有经过篡改。