您正在查看: 2023年2月

使用默克尔(Merkle)树实现NFT白名单

简介

在我们今天所知道和喜爱的区块链出现之前,默克尔树一直是密码学和计算机科学领域的一个方面。如今,我们开始慢慢看到它们在链上更频繁地被用于数据验证的目的。在这篇文章中,我将解释Merkle Trees如何在NFT(ERC-721)背景下实现代币白名单的目的,它们是如何提供保证只能由预定参与者认领代币。

什么是Merkle树?

默克尔树是一种树状结构,树上的每个节点都由一个值表示,这个值是一些加密哈希函数的结果。哈希函数是单向的,从一个输入产生一个输出很容易,但从一个输出确定一个输入在计算上是不可行的。默克尔树有3种类型的节点,如下所示:

叶子节点

叶子节点位于树的最底部,它们的值是原始数据根据指定的哈希函数进行哈希的结果。一棵树上有多少个叶子节点,就有多少个需要哈希的原始数据。例如,如果有7个数据需要被哈希,就会有7个叶子节点。

父节点

父节点可以位于树的不同层次,这取决于整个树的大小,父节点总是位于叶节点之上。父节点的值是由它下面的节点的哈希值决定的,通常从左到右开始。由于不同的输入总是会产生不同的哈希值,不考虑哈希值的碰撞,节点哈希值的连接顺序很重要。值得一提的是,根据树的大小,父节点可以Hash其他父节点。

根节点

根节点位于树的顶端,由位于它下面的两个父节点的哈希值连接而成,同样从左到右开始。任何默克尔树上都只有一个根节点,根节点拥有根哈希值。

我知道这是一个需要消化的信息,所以请参考下面的图表(图1),以便更好地了解这些树的结构。

上下文背景

如前所述,在NFT(ERC-721)的背景下使用Merkle树,如果为选定的参与者群体保留一定数量的代币,这其实就是一个白名单。Merkle树必须是预先计算的,在这种情况下,可以让一个叶子节点代表我们白名单中的一个钱包地址。

让我们想象一下,你的项目已经确定了一个白名单策略,为选定的钱包地址保留了任意数量的代币,这些地址可能是通过竞争、抽奖或其他系统的方式选择。这些白名单上的地址可以在某个时间点(通常在公共铸币之前)能申领获得为他们保留的代币。当然,还可以出于各种其他的原因,这些可能涉及到避免高额的Gas费,奖励创造力,鼓励早期参与,社区参与等等。

由于这些地址是已知的,而且是不变的,我们可以使用这些信息来创建一个Merkle树。我们可以使用merkletreejs和keccak256 JavaScript库来进行实现。

注:为了简单起见,将只使用7个钱包地址,以保持树的大小精简。

JavaScript实现

要做的第一件事是衍生出我们的叶子节点。如果你还记得,在一棵树上位于叶子节点正上方的每个父节点,最多只能Hash两个叶子节点。如果叶子节点的数量不均匀,父节点将处理一个叶子节点。每个叶子节点应该是某种形式的Hash数据,所以在这个例子中,让我们使用keccak256库来哈希白名单上的所有地址(图2)。我们使用这种特定的哈希算法,因为它将在以后的Solidity智能合约中使用。


图2. 衍生出叶子节点和默克尔树对象

对白名单上的所有地址进行了哈希,从而获得了我们的叶子节点,现在就可以创建Merkle树对象。我们使用merkletreejs库,通过调用new MerkleTree()函数,将叶子节点作为第一个参数,哈希算法作为第二个参数,{ sortPairs: true }选项作为最后一个参数。最后一个参数是可选的,但我在试图在这个例子不使用它时遇到了很大困难。

图3. Merkle树的可视化和根哈希。

现在已经得出了一个完整的Merkle树,可以通过调用Merkle树对象的getRoot()方法(图3)来获得根哈希值。记住,Merkle树的根哈希值是树上根节点正下方的两个前面的父节点的哈希值。在本例中,0xf352...和0x3cc0...。使用toString()方法在控制台打印Merkle树,为我们提供了一个很好的可视化的树的结构。

Merkle树的巧妙之处在于,它不需要任何关于原始数据块的知识来验证一个节点是否属于我们的树。如果我们试图验证一个叶子节点属于我们的树,只需要知道直接相邻的叶子节点哈希值(如果有的话),以及叶子节点正上方相邻的父节点哈希值就可以了。对于这个工作原理的简短解释,我建议查看Tara Vancil的这个视频。这个信息被称为proof,将被Solidity智能合约使用,以验证调用者是否属于白名单。

Web实现

现在我们有了Merkle树对象和它的根哈希值,我们准备开始考虑如何让白名单用户申领他们的代币时向智能合约提供Merkle证明。我们需要做的是在项目网站上实现一些JavaScript,在铸币页面上请求外部API。这个API将接收连接的钱包地址,因为它是我们最初用来生成叶子节点的,并返回指定的证明。

在服务器端,你会收到地址,使用keccak256进行哈希,并使用Merkle Tree对象上的getHexProof()方法检索证明。下图(图4)显示了你可能从这个API调用中返回的例子。

图4. 对应地址的Merkle证明。编辑:0x7b地址可以忽略,这是我的一个打印错误。

前端在收到这个证明之后,并将其作为参数与参与者的交易一起发送到合约,我们现在可以开始研究如何在智能合约中验证它。

智能合约的实现

注:本文展示的智能合约例子是用最小的代码量构建的,以展示一个概念证明。它绝不是一个你应该如何编写铸币功能的例子。

为了验证所提供的证明,需要做的第一件事是导入OpenZeppelin MerkleProof.sol contract(第6行,图5),这将使我们能够在智能合约代码中使用MerkleProof.verify()函数。接下来需要做的是定义根Merkle哈希值。如果智能合约在白名单确定之前已经被部署到以太坊主网上,那么可以假设有一些setter函数可以用来在以后的时间点更新这个值。在这个例子中,我对根Merkle哈希值进行了硬编码,以便在部署时被设置(第12行,图5)。

图5. 智能合约代码

接下来,我们需要验证该证明。 证明是一个bytes32类型的值数组。技术上来说,它们是string类型的,但无论如何,Solidity都会正确解释。先生成的目标叶子节点(第25行,图5),如果你记得,这是一个白名单地址的keccak256哈希。在这个例子中,通过哈希msg.sender的值来生成目标叶节点。记住,这个值是不可改变的,不能被恶意改变。

由于只有白名单地址被用来生成我们的叶子节点,我们可以假设,如果一个非白名单地址试图使用有效或无效的证明来调用这个函数,生成的目标叶子节点将根本不存在于我们的Merkle树上,验证将失败。这个实现的最后一步只是调用MerkleProof.verify()函数,将提供的证明作为第一个参数,将根Merkle哈希作为第二个参数,将目标叶节点作为最后一个参数。如果这个函数返回 "false",require语句将失败,交易将被简单地回退,否则,该函数将继续执行,执行铸造代币逻辑。

临别赠言

我们已经学会了如何使用默克尔树实现白名单,这是一个相对简单明了的方法,展示了在NFT项目中使用白名单生成默克尔树,实现只有白名单中的指定地址才能申领代币。我知道还有其他的解决方案,但在我研究过的方案中,我认为迄今为止最吸引人的方案。

本翻译由 Duet Protocol 赞助支持。
转载自:https://learnblockchain.cn/article/4521

使用Golang实现Merkle算法

Merkle树是区块链技术的基本组成部分。它是由不同数据块的散列组成的数学数据结构,用作块中所有交易的摘要。

它还允许对大量数据中的内容进行有效和安全的验证。此结构有助于验证数据的一致性和内容。比特币和以太坊都使用Merkle树结构。Merkle树也被称为哈希树。

从根本上说,Merkle树是数据结构树,其中每个叶节点都用数据块的哈希标记,非叶节点用加密标记 其子节点标签的哈希值。叶节点是树中的最低节点。

原理

区块链中每个区块都会有一个 Merkle 树,它从叶子节点(树的底部)开始,一个叶子节点就是一个交易哈希。叶子节点的数量必须是双数,但是并非每个块都包含了双数的交易。如果一个块里面的交易数为单数,那么就将最后一个叶子节点(也就是 Merkle 树的最后一个交易,不是区块的最后一笔交易)复制一份凑成双数。

从下往上,两两成对,连接两个节点哈希,将组合哈希作为新的哈希。新的哈希就成为新的树节点。重复该过程,直到仅有一个节点,也就是树根。根哈希然后就会当作是整个块交易的唯一标示,将它保存到区块头,然后用于工作量证明。

代码实现

package main

import (
    "bufio"
    "crypto/sha256"
    "fmt"
    "os"
    "strconv"
)

//默克尔树节点结构体
type Node struct {
    Index    int
    Value    string
    RootTree *MHTree
}

//默克尔树结构体
type MHTree struct {
    Length   int
    Nodes    []Node
    rootHash string
}

//获得默克尔树根节点哈希值
func (t *MHTree) GetRootHash() string {
    //不管是否存储,都重新计算哈希值
    t.rootHash =   t.Nodes[1].getNodeHash()                            
    return t.rootHash
}

//计算默克尔树中某个节点的哈希值
func (n *Node) getNodeHash() string {
    //叶子节点,则直接计算该节点Value的哈希值
    if n.Value != "" {
        return calDataHash(n.Value)
    }
    //非叶子节点,则递归计算哈希值,其为2个子节点哈希值的哈希值123123123123
    return calDataHash(n.RootTree.Nodes[n.Index*2].getNodeHash() + n.RootTree.Nodes[n.Index*2+1].getNodeHash()
        )                                                         

//计算数据的哈希值
func calDataHash(data string) string {
    hash := sha256.New()
    hash.Write([]byte(data))
    return string(hash.Sum(nil))
}

//从结构化文件创建默克尔树
func CreateMHTree(fileName string) MHTree {
    var tree MHTree
    //打开文件
    file, err := os.Open(fileName)
    if err != nil {
        panic(err)
    }
    defer file.Close()
    //获取读取器
    buf := bufio.NewReader(file)
    //读取首行,获得叶子节点数目(要求叶子节点数目为2的整数次幂
    dataCountStr, _, _ := buf.ReadLine()
    dataCount, _ := strconv.Atoi(string(dataCountStr))

    //判断幂次
    level := 0
    for i := 1; ; i++ {                                          
        if 2<<i == dataCount {
            level = i
            break
        }
    }
    //创建默克尔树

    //给非叶子节点赋值
    for i := 1; i <= 2<<level-1; i++ {                               
        tree.Nodes[i].Index = i
        tree.Nodes[i].RootTree = &tree
    }
    //读取文件数据,给叶子节点赋值
    for i := 2 << level; i < tree.Length; i++ {                  
        str, _, _ := buf.ReadLine()
        tree.Nodes[i].Index = i
        tree.Nodes[i].RootTree = &tree
        tree.Nodes[i].Value = string(str)
    }
    return tree
}

func main() {
    var fileName string

    fmt.Println("请输入原始数据文件名称")
    fmt.Scanln(&fileName)
    mhTree1 := CreateMHTree(fileName)
    fmt.Println("请输入比对数据文件名称")
    fmt.Scanln(&fileName)
    mhTree2 := CreateMHTree(fileName)

    hash1 := mhTree1.GetRootHash()
    hash2 := mhTree2.GetRootHash()

    if hash1 == hash2 {
        fmt.Println("用户没有改变数据")
    } else {
        fmt.Println("用户改变了数据")
    }

}

转载自:https://learnblockchain.cn/article/5364

通过逆向和调试深入EVM #1 - 理解汇编

0.简介

在这个系列的教程中,我们将学习如何调试和逆向 EVM 智能合约。

你可能已经知道,当一个智能合约在区块链中没有被验证时,你无法读取它的实体代码,只有字节代码被显示。


问题是很难从字节码中完全 "反编译(de-compile)",以重建编译前的solidity代码。

但是不用担心,在这一系列的教程中,我将清楚地教你所有的技术,以逆向区块链中的任何智能合约。
与不知道的人相比,学习这项技术有几个好处:

你将能够阅读不透明的智能合约(即使源代码没有被验证)。
你会对EVM有深刻的理解,从而成为一个更好的开发者/智能合约审计。(从而赚更多的钱:))。
你会在你的智能合约中更有效地调试代码,避免在出现错误时浪费大量的时间。(特别是如果顶层错误是通用的,如:"执行被回退(Execution reverted)")

本文是关于通过逆向和调试理解EVM系列的第1篇,本系列包含 7 篇文章:

1. 简介

下面是我们将进行逆向/调试的智能合约:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract Test {  

   function test() external {      } 

   function test2() external {      }  

   function test3() external {      }  
}

看上去很简单,对吗?

是的,我们就从简单合约开始。

  1. 在Remix IDE中编译(0.8.7版) https://remix.ethereum.org

  2. 部署(选择JavaScript London虚拟机)

  3. 调用 test() 函数并点击蓝色的调试按钮,以显示调试器。

  4. 一旦完成,你应该在Remix中看到调试标签。

我们90%的工作将在这里进行。
在深入研究这个问题之前,这里有一些前备知识,你需要了解:

  • 一些solidity开发经验
  • 十六进制数字和基本的计算机科学知识
  • Remix IDE的基础知识。
  • 兴趣和可能(很多)的咖啡。

2. 什么是字节码/汇编?

每个智能合约都是由字节码构成的,例如,这是我们在文章开头创建的智能合约的字节码(十六进制):

0x6080604052348015600f57600080fd5b5060043610603c5760003560e01c80630a8e8e0114604157806366e41cb7146049578063f8a8fd6d146051575b600080fd5b60476059565b005b604f605b565b005b6057605d565b005b565b565b56fea2646970667358221220d28f98515dc0855e1c6f5aa3747ff775f1b8ab6545f14c70641ff9af67c2465164736f6c63430008070033

这个字节码的每一个字节都对应着汇编语言中的一条指令。你可能已经知道,EVM并不直接理解solidity语言,它只理解汇编中的指令,这是一种低级语言。

在编译的时候,编译的作用只是把 solidity 代码翻译成汇编代码。
汇编是一种非常原始的 "语言",只有指令和参数, 例如:

000 PUSH 80
041 PUSH1 00
056 DUP1

智能合约中第00字节(第一个指令)的指令是PUSH 80(在字节码操作码中翻译为6080)。
第41字节的指令是PUSH1 00(并且有1个参数是00)(在字节码操作码中是6000)。
第56字节的指令是DUP1 没有参数(字节码操作码为80)。

在后面,我们将逐步解释这些指令的内部作用。
在EVM中,大约有100条有效指令,有些是很容易猜到其含义,比如:

  • ADD/SUB/OR/XOR
  • 但其他的则需要更多的解释。

提示。每次有不明白的指令,你可以去https://www.ethervm.io/,这个网站总结了所有以太坊指令,显示了参数和返回值。

3. Solidity中的存储

你可能已经知道,在solidity中有3种类型的存储。
solidity中有3种类型的存储。

  • 存储(storage),直接存储在区块链中,使用32字节数字 "槽(slot)"来标识,。一个槽的大小是32个字节(或64个十六进制数字)。
  • "内存(memory)",在智能合约执行结束时被清除,由一个名为 "十六进制数字 "的地址来标识。
  • 还有栈,它是一个LIFO(后进先出)类型的队列,当每个项由一个数字标识(以0开始)。

4. LIFO栈是如何工作的?

默认情况下,在智能合约开始的时候,堆栈是空的,它包含的内容是不存在的!
现在有2种方法可以操作堆栈,可以通过使用指令PUSH或POP。

4.1 PUSH

它将数据推在第0位,并将每个数据往前推1个位置。例如,如果我们使用PUSH指令在堆栈中写入0xff。

Stack before (3 elems): |Place 0: 0x50|Place 1: 0x17|Place 2: 0x05|
----------------------------
Stack after PUSH ff: |Place 0: 0xff|Place 1: 0x50|Place 2: 0x17|Place 3: 0x05|

0xff被写在0位,0x50从0位到1位,0x17从1位到2位,0x05从2位到3位,现在栈包含4个元素而不是3个。

让我们看看另一个例子:

Stack before (0 elems, empty): ||
----------------------------
Stack after PUSH 33: |Place 0: 0x33|

堆栈现在包含1个元素。最后一个例子:

Stack before (0 elems, empty): |Place 0: 0x33|
----------------------------
Stack after PUSH 00: |Place 0: 0x00|Place 1: 0x33|

堆栈现在包含2个元素,就像这样简单。

4.2 POP

POP指令,做逆向操作:弹出第0槽中的数据,并将每个数据向后推1槽。

Stack before (3 elems): |Place 0: 0x50|Place 1: 0x17|Place 2: 0x05|
----------------------------
Stack after POP (2 elems): |Place 0: 0x17|Place 1: 0x05|

第0位的数据被删除了,0x17的位置从1位变成了0位,同样,0x05的位置从2位变成了1位。栈现在包含2个元素

下面是另一个例子:

Stack before (1 elems): |Place 0: 0x33|
----------------------------
Stack before POP (0 elems, empty): ||

如果你理解了这一点,也就这么简单。你理解了LIFO类型的存储,你就可以更进一步了:)

在EVM(以及其他汇编)中,堆栈通常用于存储函数和指令的参数 + 返回值。

在这个系列的文章中,我们将使用以下表示:

  • Stack(0) = 堆栈中的第一个值(在位置0)。
  • Stack(1) = 堆栈中的第二个值(在位置1处)。
  • Stack(n) =堆栈中的第n+1个值(在位置n处)。

每次我解释一条指令时,堆栈的内容以这种格式: |0x15|0x25|0x00| , 这里:

  • 0x15是Stack(0),是堆栈中的第一个值,位置为0
  • 0x25是Stack(1),是Stack的第二个值
  • 0x00是Stack(2)。
  • 以此类推,如果堆栈里有更多的值的话

5. 汇编的第一行

一旦你理解了这些概念,现在就可以开始了, 点击下面的按钮,重新启动智能合约的执行:(默认情况下,remix在函数test()的开始处启动调试会话,因为在执行函数之前有一些代码,我们需要改变这一点)

如果一切顺利,第一批指令应该弹出,可以通过点击这些箭头在指令之间逐一导航。

第一条指令是:

000 PUSH 80 | 0x80 |
002 PUSH 40 | 0x40 | 0x80 |
004 MSTORE  ||

EVM在堆栈中PUSH 80和PUSH 40,结果它看起来像:
| 0x40 | 0x80 |。
在第4字节:MSTORE需要2个参数(offset,value): Stack(0) 和 Stack(1)
MSTORE将Stack(1)的值存储在内存中的Stack(0)位置中。

因此,EVM将0x80存储在内存的0x40地址,在调试标签的内存部分,你应该看到:

由于内存中的每一个插槽都是32个字节的长度(使用小端序的十六进制0x20),因此插槽40的内存位于0x40和0x40+0x20=0x60之间(我们将其记为内存[0x40:0x60]

这就是为什么0x80在最后(0x5f位置)。

“????? "是内存中的字节的ASCII表示。

内存中的 "0x40 "槽在EVM中被命名为空闲内存指针,当需要内存时,它被用来分配内存的新槽。(我将在后面解释为什么它是有用的)。

重要的是:注意在一条指令之后,堆栈中所有需要的参数都会从堆栈中清除,并被返回值所取代。

由于MSTORE在堆栈中占用了2个参数,在MSTORE指令完成后,这2个参数会从堆栈中删除。

所以堆栈现在什么都不包含。

6. MSG.VALUE

005 CALLVALUE |msg.value|
006 DUP1      |msg.value|msg.value|
007 ISZERO    |0x01|msg.value|
008 PUSH1 0f  |0x0f|0x01|msg.value|
010 JUMPI     |msg.value|
011 PUSH1 00  |0x00|msg.value| (if jumpi don't jump to 0f)
013 DUP1      |0x00|0x00|msg.value|
014 REVERT 

CALLVALUE指令把msg.value(发送给智能合约的以太币)放在堆栈中。
由于我们没有向智能合约发送任何以太币,堆栈中的值是:| 0x00 |

DUP1指令将Stack(0)推入堆栈,我们可以说它 "复制"了堆栈开头的第一个指令:
|0x00 |0x00 |

注意还有DUP2, DUP3...DUPn(直到DUP16),它们将第n个值(Stack n-1)推到堆栈中。

而EVM在第7字节调用ISZERO,ISZERO使用Stack中的1个参数(它是Stack(0))。

顾名思义,ISZERO验证Stack(0)是否等于0,如果是,EVM在第一个槽中推送 "1 "的值,即True。
| 0x01 | 0x00 |

EVM还删除了第一个0x00,因为它是ISZERO的参数。

之后在第8个指令,EVM将0x0f推到堆栈中 : | 0x0f | 0x01 | 0x00 |。

接下来我们有一个条件跳转(JUMPI),如果Stack(1)是1,EVM直接进入字节数Stack(0)所在的位置(因为Stack(0)=0f,十进制15),因此Stack(1)=1,EVM直接跳转到第15个指令

如果不是,EVM继续执行它的路径,执行PUSH**、DUP1和最后在第14字节的REVERT指令一样,以一个错误停止执行。

但是在这里,一切都很好!因为Stack(1)=1,所以在执行过程中会出现错误。由于Stack(1)=1,所以EVM跳到了0x0f(相当于15的十进制)。

我们将尝试理解在第5个指令和第14个指令之间发生了什么。

请注意,我们声明函数test()是非 payable 的,而且合约中没有receive()或fallback()函数可以接收以太币。

因此,这个合约不能接收到任何以太币(除了一个特定的情况,但在这里并不重要),所以如果我们发送以太币,它就会回退!。汇编中的代码相当于:

005 CALLVALUE load msg.value
006 DUP1      duplicate msg.value
007 ISZERO    verify if msg.value is equal to 0
008 PUSH1 0f  push 0f in the Stack (the byte location after the REVERT byte location)
010 JUMPI     jump to this location if msg.value is not equal to 0
011 PUSH1 00  push 00 in the Stack
013 DUP1      duplicate 00 in the Stack
014 REVERT    revert the execution

用 Solidity 表示,等价于:

if (msg.value > 0) { 
   revert(); 
} else {
   // Jump to byte 15
}

所以这第二部分的代码只是验证是否有任何以太币发送到合约中,否则它就会被回退。

在第15个指令时,堆栈为 | 0x00 | (因为JUMP在堆栈中使用了2个参数,EVM将它们删除)

7. CALLDATASIZE

015 JUMPDEST     | 0x00 |
016 POP          ||
017 PUSH1 04     | 0x04 |
019 CALLDATASIZE | msg.data.size | 0x04 |
020 LT           | msg.data.size > 0x04 |
021 PUSH1 3c    | 0x3c | msg.data.size > 0x04 |
023 JUMPI        || (JUMPI takes 2 arguments)060 JUMPDEST     ||
061 PUSH1 00     |0x00|
063 DUP1         |0x00|0x00|
064 REVERT       ||

JUMPDEST没有任何作用。它只是表示一条JUMP或JUMPI指令指向这里,如果EVM跳到一个没有标记为 "JUMPDEST"的地址(比如16号是POP),它就会自动回退。

接下来,EVM将堆栈的最后一个元素POP出来,然后PUSH 04,因此在第17个指令之后,堆栈内只有一个元素: | 0x04 |

EVM调用CALLDATASIZE,等于msg.data.size(以太坊交易中数据字段的大小),现在堆栈是:| 0x04 | 0x04 |

(当一个函数被调用时没有参数msg.data.size = 4,这4个字节被称为函数 "签名")

例如这里msg.data等于"0x12345678",msg.data.size=4(8个十六进制数字)。

后来在第20个指令,EVM调用LT(小于),它比较堆栈上的两个值(如果Stack(0) < Stack(1),那么我们写1,否则写0)。

在我们的例子中,它是假的! 4不小于4(运算符LT是严格的)。

所以EVM不会跳到3c(因为Stack(0) = 3c和Stack(1) = 0),EVM继续执行流程,就像什么都没发生一样。

但是如果CALLDATASIZE小于4(如0、1、2或3),那么Stack(1)=1,然后EVM跳到0x28(十进制的40),EVM 回退 !

下面是发生的情况:

015 JUMPDEST     
016 POP           pop
017 PUSH1 04      store 0x04 in the stack
019 CALLDATASIZE  get msg.data.size in the stack
020 LT            verify if msg.data.size < 0x04
021 PUSH1 3c      push 0x3c (60 in dec)
023 JUMPI         jump to 60 if msg.data.size < 0x04060 JUMPDEST     
061 PUSH1 00     
063 DUP1         
064 REVERT        revert the execution

这意味着msg.data不能小于4,你会在下一节明白为什么!

if (msg.data.size < 4) { revert(); }

8. 函数选择器

一旦所有事先验证完成。

我们需要调用函数test()并执行它的代码。但在我们的合约中有几个函数(test() test2() 和 test3()),如何找出EVM需要执行的函数呢?

这就是函数选择器的作用。

下面是接下来的反汇编步骤

024 PUSH1 00 |0x00| (the stack was previously empty in byte 23)
026 CALLDATALOAD |0xf8a8fd6d0000000.60zeros.000000000|
027 PUSH1 e0 |0xe0|0xf8a8fd6d0000000.60zeros.000000000|
029 SHR |0xf8a8fd6d|
030 DUP1 |0xf8a8fd6d|0xf8a8fd6d|
031 PUSH4 0a8e8e01 |0x0a8e8e01|0xf8a8fd6d|0xf8a8fd6d|
036 EQ |0x0|0xf8a8fd6d|0xf8a8fd6d| 
037 PUSH1 41 |0x41|0x1|0xf8a8fd6d|
039 JUMPI |0xf8a8fd6d|
040 DUP1 |0xf8a8fd6d|0xf8a8fd6d|
041 PUSH4 66e41cb7 |0x66e41cb7|0xf8a8fd6d|0xf8a8fd6d|
046 EQ |0x0|0xf8a8fd6d|
047 PUSH1 49 |0x49|0x1|0xf8a8fd6d|
049 JUMPI |0xf8a8fd6d|
050 DUP1 |0xf8a8fd6d|0xf8a8fd6d|
051 PUSH4 f8a8fd6d |0xf8a8fd6d|0xf8a8fd6d|0xf8a8fd6d|
056 EQ |0x1|0xf8a8fd6d|
057 PUSH1 51 |0x51|0x1|0xf8a8fd6d|
059 JUMPI |0xf8a8fd6d|

你可能已经知道什么是以太坊的函数签名:它是函数名称的哈希值的前4个字节,对于test()来说,它是 :

bytes4(keccak256(”test()”)) = 0xf8a8fd6d

CALLDATALOAD 接受1个参数Stack(0)作为偏移量,并将msg.data之后的在参数位置(这里是Stack(0))的下一个32字节存储在堆栈中Stack(0)

在此案例中,它存储msg.data的前32字节(因为Stack(0) = 0)。

但只有4个字节(如前所述),因此堆栈将是这样的:
| 0xf8a8fd6d00000000000000000000000000000000000000000000000000000 |

下一个操作码是PUSH e0和SHR位于第27指令(使用2个参数),它通过Stack(0)(这里是c0)向右(>>)执行二进制移位,堆栈(在SHR之前)的值为:

|0xc0|0xf8a8fd6d00000000000000000000000000000000000000000000000000000 |

下面是用SHR进行的详细计算(如果你愿意可以跳过):

A place in stack is of length 32 bytes = 256 bits

In binary Stack(1) = 11111000101010001111110101101101 and 192 zeros after that

c0 = 192 in decimal, so we will shift 192 time to the right

0 times   : 11111000101010001111110101101101..... + 192 zeros
1 times   : 011111000101010001111110101101101.... + 191 zeros
2 times   : 0011111000101010001111110101101101... + 190 zeros
192 times : 192 zeros + 0011111000101010001111110101101101...

= 0x00000000000000000000000000000000000000000000000000000f8a8fd6d
= 0x00..60zeros00f8a8fd6d

在DUP操作码之后,堆栈看起来像| 0xf8a8fd6d | 0xf8a8fd6d |。

值得注意的是,这就是我们的test()签名,这很正常!函数的签名总是出现在交易数据的前4个字节中。

在以太坊交易中,我们不会直接发送要执行的函数的名称,而只是发送4个字节的签名。

在第31个操作码中,EVM PUSH一个4字节的值到堆栈:0a8e8e01

| 0xa8e8e01 | 0xf8a8fd6d | 0xf8a8fd6d |

并调用EQ,比较(Stack(0)和Stack(1))。

这两个值显然是不相等的:因此我们用0代替它们
| 0x0 | 0xf8a8fd6d |

这样我们就不会JUMP到41(65的十六进制)(后面有指令PUSH1 41和一个JUMPI)。

EVM对0x66e41cb7(操作码41到50)也做了同样的事情,这也不等于0xf8a8fd6d。

最后,EVM用0xf8a8fd6d来执行,由于现在等于0xf8a8fd6d! 所以我们跳到51(十六进制是81),这是test()函数的开始:

081 JUMPDEST |0xf8a8fd6d|
082 PUSH1 57 |0x57|0xf8a8fd6d|
084 PUSH1 5d |0x5d|0x57|0xf8a8fd6d|
086 JUMP |0x57|0xf8a8fd6d|
087 JUMPDEST |0xf8a8fd6d|
088 STOP ||
093 JUMPDEST |0x57|0xf8a8fd6d|
094 JUMP |0xf8a8fd6d|

你可以很容易地分析我们的test()函数中最后执行的8条指令。

它只执行了一系列的JUMP指令,在函数的最后,操作码STOP,它停止了合约的执行而没有产生错误。
所有这些代码的行为就像编程中的一个开关。

0xf8a8fd6d是 "test()"函数的签名
0x0a8e8e01和0x66e41cb7是test2和test3函数的签名。

如果交易数据中的签名与这些签名之一相符,那么通过跳转到函数的代码位置(代码中的41,49和51)来执行函数的代码。

否则。如果交易数据中的签名与代码中的任何函数签名不匹配,EVM将调用回退函数,但在我们的智能合约中没有这样的函数(至少现在没有)!因此:EVM将重新调用回退函数。结果是:EVM回退,故事到此结束。

这是59(函数选择器开关)之后的代码:

060 JUMPDEST
061 PUSH1 00
063 DUP1
064 REVERT

因此,我们可以重构智能合约的完整代码:

mstore(0x40,0x80)                              
if (msg.value > 0) { revert(); }                              
if (msg.data.size < 4) { revert(); }
byte4 selector = msg.data[0x00:0x04]                                
switch (selector) {                               
   case 0x0a8e8e01:   // JUMP to 41 (65 in dec)   stop()
   case 0x66e41cb7:   // JUMP to 49 (73 in dec)   stop()
   case 0xf8a8fd6d:   // JUMP to 51 (85 in dec)   stop()
   default: revert();
stop()

我们完成了!

9. 总结

我们成功地学会了。

  • 一些基本的EVM 汇编。
  • EVM如何执行智能合约。
  • 哪些代码在执行函数之前被执行。
  • LIFO堆栈如何工作。
  • remix调试器的基本使用。
  • 函数选择器。
  • 还有很多...

这个系列的第一篇关于通过调试理解EVM的内容就到此为止。我希望你在这里学到很多东西。

下一部分见!

这是我们关于通过调试理解EVM系列的第1部分,在这里你可以找到之前和接下来的部分。

转载自:https://learnblockchain.cn/article/4913

Solcurity: 合约代码安全建议

Solidity智能合约有关安全和代码质量标准的建议,在 BoringCrypto, Mudit Gupta, Runtime Verification, 和 ConsenSys Diligence 的工作基础上整理。

常规审查方法

  • 阅读项目的文档、规范和白皮书,了解智能合约的作用。
  • 在查看代码之前,先构建一个期望中的合约架构模型。
  • 快速浏览一遍合约,感受项目架构,可以利用Surya这类工具。
  • 将项目架构与期望中的合约架构模型进行比较,仔细检查不符合预期的部分。
  • 创建威胁模型并列出理论上的高级攻击向量。
  • 查看与价值交换相关的地方,尤其是transfer,transferFrom,send,call,delegatecall,和 selfdestruct。优先检查它们,确保安全。
  • 查看与外部合约交互的地方,并确保所有关于它们的假设都是有效的,例如价格只会上涨等等。
  • 对合约进行一般性的逐行审查。
  • 从威胁模型中每个参与者的角度进行另一次审查。
  • 快速浏览项目的测试 + 代码覆盖率,并深入了解缺乏覆盖率的地方。
  • 运行 Slither/Solhint 等工具并审查其输出。
  • 查看相关项目及其审计,以检查是否存在任何类似问题或疏忽。

变量(Variable)

  • V1 - 变量可以是 internal 吗?
  • V2 - 变量可以是 constant 吗?
  • V3 - 变量可以是 immutable 吗?
  • V4 - 是否设置了可见性? (SWC-108)
  • V5 - 变量的用途和其他重要信息是否使用 natspec 标准记录?
  • V6 -可以与相邻的存储变量一起打包吗?
  • V7 - 是否可以和其他变量打包在一个struct中?
  • V8 - 使用完整的 256 位类型,除非与其他变量一起打包。
  • V9 - 如果它是一个public array,是否提供了一个单独的函数来返回完整的数组?
  • V10 - 使用更加灵活的internal,而不是private,除非有意阻止子合约访问变量。

结构体(Struct)

  • S1 - 是否有必要用struct ? 可以仅用原始存储变量打包吗?
  • S2 - 字段是否打包在了一起 (如果可以) ?
  • S3 - struct的用途和所有字段是否使用 natspec 标准进行文档记录 ?

函数(Function)

  • F1 - 函数可以是 external 吗?
  • F2 - 函数是否应该是 internal ?
  • F3 - 函数是否应该是 payable ?
  • F4 - 是否可以与另一个类似的函数合并?
  • F5 - 验证所有参数都在安全范围内,即使该函数只能由受信任的用户调用。
  • F6 - 是否遵循 check-before-effect 模式? (SWC-107)
  • F7 - 检查抢跑的可能性,例如授权方法(approve)。 (SWC-114)
  • F8 - 是否会遭受恶意的 gas 不足攻击 ? (SWC-126)
  • F9 - 是否应用了正确的修改器,例如 onlyOwner/requiresAuth ?
  • F10 - 返回值是否总是有赋值?
  • F11 - 在一个函数能够正确运行之前,写下并测试关于状态的不变性检查(invariants)。
  • F12 - 写下并测试关于函数运行后的返回值或任何状态变化的不变性检查。
  • F13 - 命名函数时要注意,因为人们会根据名称来假设行为。
  • F14 - 如果一个函数是故意unsafe(为了省gas等),使用一个不便的名字来引起人们对其风险的注意。
  • F15 - 所有参数、返回值、副作用和其他信息是否用natspec文档记录?
  • F16 - 如果函数允许对系统中的另一个用户进行操作,不要假设msg.sender是被操作的用户。
  • F17 - 如果函数要求合约处于未初始化的状态,请检查一个明确的initialized变量。不要使用owner == address(0)或其他类似的检查作为替代。
  • F18 - 只使用private来有意防止子合约调用该函数,为了灵活性,最好使用internal。
  • F19 - 在合法(和安全)的情况,可能子合约希望覆盖该函数的行为,则使用virtual。

修改器(Modifier)

  • M1 - 是否没有进行存储更新(重入锁除外)?
  • M2 - 是否避免了外部调用?
  • M3 - 修改器的用途和其他重要信息是否使用 natspec 标准文档记录?

代码

  • C1 - 使用SafeMath或solidity 0.8 检查的数学?(SWC-101)
  • C2 - 是否有任何存储槽被多次读取?
  • C3 - 是否使用了任何可能导致DoS的无界循环/数组? (SWC-128)
  • C4 - 只对长间隔使用block.timestamp。(SWC-116)
  • C5 - 不要使用block.number来表示经过的时间。(SWC-116)
  • C7 - 尽可能避免委托调用,特别是对外部(即使是可信的)合约。(SWC-112)
  • C8 - 在迭代数组时,不要更新其长度。
  • C9- 不要使用blockhash()等来实现随机性。(SWC-120)
  • C10 - 签名是否用nonce和block.chainid防止重放 (SWC-121)
  • C11 - 确保所有签名使用EIP-712。(SWC-117 SWC-122)
  • C12 - 如果需要对大于 2个动态类型进行 hash 时,一般情况下,最好使用abi.encode()而不是abi.encodePacked()的输出进行 hash。(SWC-133)
  • C13 - 谨慎使用汇编,不要使用任何任意数据。(SWC-127)
  • C14 - 不要假设一个特定的ETH余额. (SWC-132)
  • C15 - 避免gas不足攻击。(SWC-126)
  • C16 - 私有数据并不是私有的。(SWC-136)
  • C17 - 在内存中更新结构体/数组不会在存储中修改它。
  • C18 - 永远不要覆盖(shadow)状态变量。(SWC-119)
  • C19 - 不要修改函数参数的值。
  • C20 - 即时计算一个值是否比存储它更便宜?
  • C21 - 所有的状态变量是否从正确的合约中读取(主合约与克隆合约)?
  • C22 - 是否正确使用比较运算符(>, <, >=, <=),特别是防止缺位错误(off-by-one error)?
  • C23 - 是否正确使用逻辑运算符(==, !=, &&, ||, !), 特别是防止缺位错误(off-by-one error)?
  • C24 - 总是先乘后除,除非乘法可能溢出。
  • C25 - 魔术数字是否由一个具有直观名称的常数代替?
  • C26 - 如果ETH的接收者有一个fallback 函数,会不会造成DoS 攻击? (SWC-113)
  • C27 - 使用openzepplin SafeERC20或安全检查返回值 。
  • C28 - 不要在循环中使用msg.value。
  • C29 - 如果可能出现递归委托调用,不要使用msg.value(比如合约继承了Multicall/Batchable)。
  • C30 - 不要假设msg.sender总是一个相关的用户。
  • C31 - 不要使用assert(),除非用于模糊处理或形式验证。(SWC-110)
  • C32 - 不要使用tx.origin进行授权。(SWC-115)
  • C33 - 不要使用address.transfer()或address.send()。使用.call.value(...)("")代替。(SWC-134)
  • C34 - 当使用低级调用时,确保调用前合约存在。
  • C35 - 当调用一个有许多参数的函数时,使用命名参数的语法。
  • C36 - 不要使用汇编来create2。更倾向于使用新式加salt合约创建语法。
  • C37 - 不要使用汇编来访问chainid或合约代码/大小/哈希。更倾向于新式Solidity语法。
  • C38 - 当设置一个变量为零值时(0,false,""等),使用delete关键字。
  • C39 - 尽可能多地注释 "为什么" 要这样做。
  • C40 - 如果使用晦涩的语法或编写非常规的代码,则注释在做 "什么"。
  • C41 - 在复杂和定点数学运算的旁边注释解释+输入/输出的例子。
  • C42 - 在做了优化的地方做注释,并估计它们能节省多少gas。
  • C43 - 在特意避免某些优化的地方进行注释,并估计如果实施这些优化会/不会节省多少gas。
  • C44 - 在溢出/下溢是不可能的,或者溢出/下溢在人类的时间尺度上是不现实的(计数器等),使用unchecked块。在使用unchecked的地方进行注释,同时估计它能节省多少gas(如果相关)。
  • C45 - 不要依赖Solidity的算术运算符优先级规则。括号不仅用来覆盖默认运算符优先级,而且可以用于强调它。
  • C46 - 传递给逻辑/比较运算符(&&/||/>=/=/等)的表达式不应该有副作用。
  • C47 - 凡是进行可能导致精度损失的算术运算,都要确保它对系统中的正确角色有利,并用注释记录下来。
  • C48 - 用注释来记录为什么必须使用重入锁的原因, 不管是行内或@dev 来记录 。
  • C49 - 如果模糊函数仅支持特定范围的参数,使用取模操作限制参数输入范围(如x = x % 10000 + 1来限制在从1到10,000)。
  • C50 - 尽可能使用三元表达式来简化分支逻辑。
  • C51 - 当在一个以上的地址上操作时,问自己如果它们是相同的会怎样。

外部调用

  • X1 -是否真的需要外部合约调用?
  • X2 - 如果运行出现报错,是否会导致 DoS?比如 balanceOf() 回退。 (SWC-113)
  • X3 - 如果调用重新进入当前函数是否有害?
  • X4 - 如果调用重新进入另一个函数是否有害?
  • X5 - 是否检查结果并处理了错误? (SWC-104)
  • X6 - 如果用完所有提供的 gas 会发生什么?
  • X7 - 如果它返回大量数据,会导致调用合约中的 out-of-gas 出错吗?
  • X8 - 如果你调用特定函数时返回了 success,也不意味着该函数存在 (phantom functions) 。

静态调用(Static Call)

  • S1 -是否真的需要外部合约调用?
  • S2 - 在接口中是否应该标记为 view 吗?
  • S3 - 如果运行出现报错,是否会导致 DoS?比如 balanceOf() 回退。 (SWC-113)
  • S4 - 如果调用进入无限循环,是否会导致 DoS?

事件(Event)

  • E1 - 是否应该对任何字段进行索引indexed ?
  • E2 - 相关动作的创建者是否包括在索引字段中 ?
  • E3 - 不要将包括string 和 bytes 的动态变量设为事件的 inedex。
  • E4 - 触发事件的时机和事件变量是否使用 natspec 标准记录文档?
  • E5 - 触发事件的函数中所有被操作用户/ID是否设为indexed字段?
  • E6 - 避免函数调用和事件参数中使用表达式求值,他们的求值顺序是不可预测的。

合约

  • T1 - 使用 SPDX 许可证标识符.
  • T2 - 是否所有会修改 storage 变量的函数都触发了事件?
  • T3 - 检查所有的继承是否正确,保证他们简洁且线性。 (SWC-125)
  • T4 - 如果合约要接收 ETH, 使用 receive() external payable 函数。
  • T5 - 写下并测试关于关联存储变量的不变性。
  • T6 - 合约的目的和与其他合约的交互是否使用 natspec 标准记录文档?
  • T7 - 如果另一个合约必须继承一个合约以解锁其全部功能,则否则应标记为 abstract。
  • T8 - 如果构造函数中设置了非不可变量(non-immutable)的值,或在其他函数中被改变,都应该触发事件。
  • T9 - 避免过度继承,因为它使得事情复杂化并可能鼓励过度抽象。
  • T10 - 始终使用命名的导入语法来明确声明哪些合约是从另一个文件中导入的。
  • T11 - 按文件夹/包将引入进行分组,每组之间空一行,外部依赖组放在开头,然后是模拟/测试合约(如有),最后是本地导入。
  • T12 - 使用 natspec 标准中的 @notice 记录合约的目的和功能,@dev 记录合约如何与项目内部/外部的其他合约交互。

项目

  • P1 - 使用正确的许可证书 (例如如果你依赖GPL协议包,你也要用GPL)。
  • P2 - 单元测试所有内容。
  • P3 - 尽可能多的模糊测试。
  • P4 - 尽可能的使用符号执行。
  • P5 - 运行 Slither/Solhint 并审查所有发现。

DeFi

  • D1 - 检查你对其他合约作用和返回值的假设。
  • D2 - 不要混淆内部计算值与账户实际余额。
  • D3 - 不要将 AMM 的现货价格用作价格预言机。
  • D4 - 如果没达到链下或预言机的价格目标,不要在 AMM 上进行交易。
  • D5 - 使用完备的检查来防止预言机/价格操纵。
  • D6 - 要注意 rebasing 代币。如果它们不受支持,要在文档中明确说明。
  • D7 - 要注意 ERC-777 代币,即使是你信任的代币也可以被重入。
  • D8 - 要注意转账收税的代币,如果它们不受支持,要在文档中明确说明。
  • D9 - 要注意使用太大或太小 decimial 的代币,要在文档中明确支持decimial 的最大值和最小值。
  • D10 - 要注意依赖原始代币余额来确定收益的合约,直接向合约发送资产,可能会打乱依靠地址的原始以太或代币余额的价格计算功能。
  • D11 - 如果你的合约是代币授权的目标地址,请不要根据用户输入随意调用。

原文: https://github.com/transmissions11/solcurity
转载自:https://learnblockchain.cn/article/5329