您正在查看: Ethereum 分类下的文章

通过逆向和调试深入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

如何在Polygon zkEVM 测试网上部署合约

什么是zkEVM?

Polygon zkEVM是第一个开源的zk-Rollup,以太坊提供安全性保证、 完整的EVM操作码等价,确保无摩擦的用户体验。
github: https://polygon.technology/solutions/polygon-zkevm

-- 它是一个与Polygon Matic POS不同的网络吗?
是的,它是一个完全不同的网络,有自己的代币和钱包配置。

-- 这是否意味着它使用自己的代币?
是的,它使用自己的原生代币,而不是Matic或Mumbai Testnet代币。

-- EVM 等价(EVM-equivalence) 是什么意思?
等价指的是Type2 ZK-EVM,Vitalik 博客:不同类型的 ZK-EVMs 对其有更好的定义。

Type2 ZK-EVMs努力做到完全等同于EVM,但不完全等同于以太坊。也就是说,它们 "从内部 "看起来和以太坊完全一样,但它们在外部有一些差异,特别是在数据结构上,如块结构和状态树。
https://vitalik.eth.limo/general/2022/08/04/zkevm.html

-- 对于开发者来说
这意味着你可以部署你现有的solidity代码,而不需要通过任何额外的步骤来编译你的代码,让它在这个网络上工作。与其他ZK-EVM解决方案相比,Type 2提供了一种更简单的方式与该ZK-EVM解决方案结合。

目标是与现有的应用程序完全兼容,但对以太坊做一些小的修改,使开发更容易,并使证明生成更快。

了解了这些, 让我们开始吧

钱包配置

需要注意的是,这些信息有很多已经存在于官方的Polygon Wiki For zkEVM,当zkEVM主网可用时,这些设置可能会改变。
zkEVM的钱包配置在Chainlist.org上吗?很遗憾还没有,因为网络配置和端口号可能会改变。

我们对zkEVM Testnet的配置设置如下:

你可以通过进入网络,手动添加一个网络,将其添加到当前的MetaMask钱包:
MetaMask手动添加一个网络

使用Polygon zkEVM Testnet配置MetaMask

让我们看看在zkEVM浏览器上的钱包信息:

https://explorer.public.zkevm-test.net/address/0xB3f03B93F0bd65B960EE950d9aFC6867D461C33f

从水龙头获取测试代币

与其他Testnet网络相比,zkEVM获得测试代币的方式有点不同。你需要先获得Goerli Testnet代币,然后将它们桥接到zkEVM。
我们将使用QuickNode Goerli Faucet,但你也可以使用从以下任何一个链接获取:


一旦我们有了Goerli Testnet代币,我们需要通过使用https://public.zkevm-test.net/, 将它们桥接到zkEVM Testnet上。

连接你喜欢的钱包,确保网络设置为以太坊 Goerli,输入需要桥接的金额,然后点击继续。

确认桥接交易:

通过切换到zkEVM,然后确认交易,最终完成交易。

如果交易成功,你应该有一个确认屏幕,并在区块浏览器上看到结果。

将ERC20合约部署到zkEVM Testnet上

接下来,我们要用Hardhat来设置和部署ERC20合约到zkEVM。

安装依赖

请确保在你的电脑上事先安装好以下依赖:

  • nvm或node v18.12.1
  • pnpm v7.15.0

设置Hardhat

我们要做的第一件事是创建一个新的项目文件夹,启动pnpm,安装Hardhat,并对其进行配置:

mkdir zkevm-erc20;
cd zkevm-erc20;
git init;
pnpx hardhat;

# Expected Prompts
# 888    888                      888 888               888
# 888    888                      888 888               888
# 888    888                      888 888               888
# 8888888888  8888b.  888d888 .d88888 88888b.   8888b.  888888
# 888    888     "88b 888P"  d88" 888 888 "88b     "88b 888
# 888    888 .d888888 888    888  888 888  888 .d888888 888
# 888    888 888  888 888    Y88b 888 888  888 888  888 Y88b.
# 888    888 "Y888888 888     "Y88888 888  888 "Y888888  "Y888
#
#  Welcome to Hardhat v2.12.3 
#
# ? What do you want to do? …
#   Create a JavaScript project
# ❯ Create a TypeScript project
#   Create an empty hardhat.config.js
#   Quit

# ? Hardhat project root: › /path/to/zkevm-erc20

# ? Do you want to add a .gitignore? (Y/n) › y

# ? Do you want to install this sample project's dependencies with npm (@nomicfoundation/hardhat-toolbox)? (Y/n) › y

pnpm install;

让我们通过运行一个节点,部署默认的合约,然后测试该合约,来仔细检查我们的Hardhat设置是否按预期工作。

在终端1

# FROM: ./zkevm-erc20

./node_modules/.bin/hardhat node;

# Expected Output:
# Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/
# 
# Accounts
# ========
# 
# WARNING: These accounts, and their private keys, are publicly known.
# Any funds sent to them on Mainnet or any other live network WILL BE LOST.
# ...

在终端2

将 "Lock.sol" 合约部署到我们正在运行的本地节点:

# FROM: ./zkevm-erc20

./node_modules/.bin/hardhat run scripts/deploy.ts

# Expected Output:
# Compiled 1 Solidity file successfully
# Lock with 1 ETH and unlock timestamp 1701595951 deployed to 0x5FbDB2315678afecb367f032d93F642f64180aa3

运行由原始脚手架项目生成的测试:

# FROM: ./zkevm-erc20

./node_modules/.bin/hardhat test;

# Expected Output:
#   Lock
#     Deployment
#       ✔ Should set the right unlockTime (894ms)
#       ✔ Should set the right owner
#       ✔ Should receive and store the funds to lock
#       ✔ Should fail if the unlockTime is not in the future
#     Withdrawals
#       Validations
#         ✔ Should revert with the right error if called too soon
#         ✔ Should revert with the right error if called from another account
#         ✔ Shouldn't fail if the unlockTime has arrived and the owner calls it
#       Events
#         ✔ Should emit an event on withdrawals
#       Transfers
#         ✔ Should transfer the funds to the owner
#
#  9 passing (1s)

创建一个ERC20合约

我们将以OpenZeppelin的ERC20 Solidity合约为基础,它将创建初始10,000个代币的代币,并允许所有者铸造更多的代币。

添加依赖

# FROM: ./zkevm-erc20

pnpm add -D @openzeppelin/contracts;

配置ERC20

使用OpenZepplin Wizard来配置ERC20代币:

编写新的合约

重命名现有的Lock.sol为zkerc20.sol,并用我们从OpenZeppeling向导中生成的代码替换它:

# FROM: ./zkevm-erc20

mv ./contracts/Lock.sol ./contracts/zkERC20.sol;

文件: ./contracts/zkERC20.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract ZkERC20 is ERC20, Ownable {
    constructor() ERC20("zkERC20", "ZK20") {
        _mint(msg.sender, 10000 * 10 ** decimals());
    }

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
}

测试ERC20合约

首先我们要为我们的ERC20合约做一个单一的测试,然后验证它是否正常工作。

部署脚本

首先,我们需要修改我们的部署脚本,以考虑到新的合约名称。

文件: ./scripts/deploy.ts。

// Imports
// ========================================================
import { ethers } from "hardhat";

// Main Deployment Script
// ========================================================
async function main() {
  // Make sure in the contract factory that it mateches the contract name in the solidity file
  // Ex: contract ZkERC20
  const zkERC20Contract = await ethers.getContractFactory("ZkERC20");
  const contract = await zkERC20Contract.deploy();

  await contract.deployed();

  console.log(`ZkERC20 deployed to ${contract.address}`);
};

// Init
// ========================================================
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

创建测试

接下来要把Lock.ts测试文件重命名为zkERC20.test.ts,并添加一个测试,确认铸造erc20代币的总余额:

# FROM: ./zkevm-erc20

mv ./test/Lock.ts ./test/zkERC20.test.ts;

文件: ./test/zkERC20.test.ts:

// Imports
// ========================================================
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { expect } from "chai";
import { ethers } from "hardhat";

// Tests
// ========================================================
describe("zkERC20", function () {
  // We define a fixture to reuse the same setup in every test.
  // We use loadFixture to run this setup once, snapshot that state,
  // and reset Hardhat Network to that snapshot in every test.
  async function deployZkERC20() {
    // Contracts are deployed using the first signer/account by default
    const [owner, otherAccount] = await ethers.getSigners();
    // Make sure in the contract factory that it mateches the contract name in the solidity file
    // Ex: contract ZkERC20
    const zkERC20Contract = await ethers.getContractFactory("ZkERC20");
    const zkERC20 = await zkERC20Contract.deploy();

    return { zkERC20, owner, otherAccount };
  };

  /**
   * 
   */
  describe("Deployment", function () {
    /**
     * 
     */
    it("Should deploy with initial 10,000 supply", async function () {
      // Setup
      const { zkERC20 } = await loadFixture(deployZkERC20);

      // Init + Test
      expect(await zkERC20.totalSupply()).to.equal(ethers.utils.parseEther(`10000`).toString());
    });
  });

  /**
   * 
   */
   describe("Minting", function () {
    /**
     * 
     */
    it("Should mint and increase the supply by 137", async function () {
      // Setup
      const { zkERC20, owner } = await loadFixture(deployZkERC20);

      // Init
      await zkERC20.connect(owner).mint(owner.address, ethers.utils.parseUnits('137', 18));

      // Init + Test
      expect(await zkERC20.totalSupply()).to.equal(ethers.utils.parseEther(`10137`).toString());
    });
  });
});

让我们运行Hardhat节点并测试这个合约。

在终端1

运行一个本地节点:

# FROM: ./zkevm-erc20

./node_modules/.bin/hardhat node;

# Expected Output:
# Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/
# 
# Accounts
# ========
# 
# WARNING: These accounts, and their private keys, are publicly known.
# Any funds sent to them on Mainnet or any other live network WILL BE LOST.
# ...

在终端2

运行测试:

# FROM: ./zkevm-erc20

./node_modules/.bin/hardhat test;

# Expected Output:
#   zkERC20
#     Deployment
#       ✔ Should deploy with initial 10,000 supply (803ms)
#     Minting
#       ✔ Should mint and increase the supply by 137
#
#  2 passing (819ms)

部署ERC20合约

需要注意的是,目前Hardhat中不支持zkEVM的部署。为了部署你的合约,你需要使用以太坊 Remix的Injected Provider配置。

确保在remix中创建一个zkERC20.sol文件,其中包含上面的合约代码:


切换到编译部分,点击“编译按钮”,得到绿色的复选标记,表示一切编译正确。

切换到左侧导航栏的部署部分,将环境设置为 "Injected Provider - Metamask"。并确保你的钱包被设置为zkEVM网络。

准备好后,点击部署按钮,在你的钱包中确认交易。

在MetaMask中查看代币

打开MetaMask钱包,点击交易,在区块浏览器上查看该交易。在区块浏览器中点击合约地址,打开合约。在区块浏览器中加载合约后,复制合约地址。

打开你的MetaMask,点击Asset(资产),并点击Import tokens(导入代币):

粘贴合约地址,并根据需要填写其他字段。它们可能会自动弹出,然后点击添加自定义代币:

现在你应该能够按照提示操作,然后在你的钱包里看到新的ZK20代币,并有正确的初始金额。

你已经完全部署了一个ERC20合约到Polygon Hermes zkEVM 测试网上。

完整的代码

zkEVM ERC20 代币的完整代码。请记住,目前还不支持Hardhat部署。
GitHub - codingwithmanny/zkevm-erc20

如果其他 zkEVM 教程,我们会及时更新,欢迎在 Twitter 关注 @登链社区

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

使用Foundry来探索可升级的合约

使用Foundry工具来探索实现可升级的合约,本文介绍了可升级的合约使用的 delegatecall 时遇到的变量冲撞的问题,以及应该如何应对。
这篇文章需要大家对 Solidity的基本知识, 有所了解。

让合约可升级提供了很大的灵活性,但也使代码更难推理。这主要是由于需要绕过Solidity的类型系统,这意味着编译器捕捉错误的能力受到严重限制。最近的Audius攻击就是一个很好的例子,说明在使用可升级合约时很容易犯错,并强调了真正理解抽象概念下发生的事情的重要性。

这篇文章是两部分系列中的第一部分。我们将看到可升级合约是如何实现的,特别是什么地方会出错。然后,第二部分将仔细研究Audius攻击,重现攻击者执行的每一个步骤。即使有了第一部分的知识,如果你不去寻找它,被攻击者利用的错误仍然很难发现。

在整个文章中,我们使用用 Solidity 编写的 Foundry 测试来说明用于使合约可升级的各种技术,并使实验和探索尽可能容易。所有的代码都可以在 此 repo 中找到。

有一点需要注意。这里介绍的代码都不应该在生产中使用,因为开发时没有考虑到这个目的。

序言

区块链的不可更改性与传统的软件开发过程有根本性的冲突,在传统的软件开发过程中,代码被不断地更新以修复错误和增加新功能。但是,即使一个特定的智能合约的代码仍然是不可改变的,仍然有一些技术可以达到与更新代码相同的效果。

这主要是通过两个功能的组合实现的。Solidity中的delegatecallEVM指令和回退函数fallback。

代码和存储

为了理解 "delegatecall",描绘智能合约执行过程中EVM的状态是很有帮助的。


https://takenobu-hs.github.io/downloads/ethereum_evm_illustrated.pdf

我们可以区分持久性和易失性的执行状态。持久性状态在不同的交易中都会被记住,而易失性状态在交易执行后会立即被遗忘。EVM代码(智能合约代码)和存储都是持久性的,而堆栈、内存、程序计数器和剩余Gas都是易失性。在这里,我们主要对代码和存储部分感兴趣,因为它们对于理解可升级合约来说是最重要的。

虽然代码和存储都是持久的,但两者之间有一个根本的区别。存储是可变的,可以被修改,而代码一旦部署就不可改变。为此,代码与存储在内存的只读部分分开保存。(这与典型的冯-诺依曼架构如x86不同,后者的代码和内存共享同一个地址空间)。这种分离使得delegatecall指令成为可能,它使用一个合约的代码和另一个合约的存储(见下一节)。

区分存储和内存是很重要的。存储器是持久的,它将32个字节的地址映射到32个字节的值,这些值被称为槽。另一方面,内存是不稳定的,它将32字节的地址映射为1字节1字节的值。换句话说,存储是按字处理(一个字是32字节),而内存是按字节处理的。

在 Solidity 中,任何在合约层声明的变量都被映射到一个或多个存储槽。例如,考虑下面的合约:

contract Token {
  address immutable owner;
  uint256 public totalSupply;
  mapping(address => uint256) public balanceOf;

  constructor() {
    owner = msg.sender;
  }

  function mint(address user, uint256 amount) external {
    require(msg.sender == owner, "Only owner is allowed to mint");
    balanceOf[user] += amount;
  }
}

第一个变量被映射到槽0,第二个被映射到槽1 [注1]。从原理上讲,我们可以如下图所示表示这个合约。

注1:若占用少于32字节的变量可以存储在同一个槽中。例如,一个槽可以包含两个类型为uint128的变量。
(注意Token::owner是不可变的(immutable),因此不占用任何存储槽)。虽然像 address 和 uint256 这样的简单类型的值最多需要32个字节,因此可以放入一个存储槽,但对于映射和动态数组来说,情况并非如此。由于这个原因,即使balanceOf被映射到槽1,实际上没有任何东西被存储在这个槽里。相反,如果我们想访问balanceOf[addr],相应的槽被计算出来,如下所示:

keccak(
  leftPadTo32Bytes(addr) ++ leftPadTo32Bytes(1)
)

我们把key(这里是:addr)和映射的槽号(这里是:1),把它们都零扩展到32字节,把它们连接起来(用++表示),最后计算结果的keccak哈希值。下面的 Foundry 测试展示了如何用 Solidity 来表达:

Token t = new Token();
t.mint(Alice, 5 ether);

bytes32 aliceBalanceSlot = keccak256(
    abi.encodePacked(uint256(uint160(Alice)), uint256(1))
);

uint256 aliceBalance = uint256(vm.load(address(t), aliceBalanceSlot));

// Check that we have correctly computed the slot at which Alice’s balance is stored
assertEq(aliceBalance, t.balanceOf(Alice));

在这个例子中,我们想检索t.balanceOf(Alice)的值,但我们没有直接这样做,而是手动计算Alice的余额所在的槽。为此,aliceBalanceSlot是按照上面的描述计算的。然后我们使用 Foundry 提供的作弊代码 vm.load() 来加载合约 t中存储在计算槽上的值。最后,我们使用 assertEq() 来确保我们实际上已经加载了正确的值。参见Storage.t.sol以了解完整的例子。

对于像Token这样的简单合约,我们可以很容易地手动计算出合约变量的槽。然而,对于使用继承的更复杂的合约,或者有多个变量被存储在同一个槽中的合约,这项任务就变得更加困难了。幸运的是,Foundry 提供了一个命令来可视化合约的存储布局。例如,要显示 Token 合约的存储布局,你可以使用以下命令。

$ forge inspect Token storage-layout –-pretty

这适用于任何属于当前 Foundry 项目的合约。如果你想分析已经部署的合约,可以看看 sol2uml 工具。

delegatecall(委托调用)

如果代码是不可变的,那么怎么可能升级智能合约并改变其行为呢?这主要是由于delegatecall指令[注2],它使用一个合约的代码,并使用另一个合约的存储来执行它。这可以通过一个简单的例子来说明。

注2: 如果我们一般性地谈论改变合约的行为,那么这已经可以用selfdestruct来实现了,因为它删除了所有代码。虽然这是一种非常有限的(而且不是非常有用的)改变合约行为的形式,但当与create2结合时,就有了更多的可能性。然而,delegatecall仍然是实现可升级合约的主要方式。

contract Counter {
  uint256 number;

  function get() external view returns(uint256) {
    return number;
  }

  function add(uint256 n) external {
    require(n <= 5, "Max increment is 5");
    number += n;
  }
}

contract DelegateCounter {
  uint256 number;

  function get() external view returns(uint256) {
    return number;
  }

  function delegateAdd(Counter c, uint256 n) external {
    bytes memory callData = abi.encodeWithSignature("add(uint256)", n);
    (bool ok,) = address(c).delegatecall(callData);

    if(!ok) revert("Delegate call failed");
  }
}

Counter合约代表了一个每次最多只能增加五的计数器。为此,它定义了一个函数 add() 来执行这个动作,还定义了一个函数 get() 来获取当前的计数器值。除了函数delegateAdd()外,DelegateCounter合约与Counter基本相同。为了解释delegateAdd()是如何工作的,将这两个合约形象化是有帮助的。

直观地说,delegateAdd()使用delegatecall来执行来自合约Counter的函数add(),使用DelegateCounter的存储。为了使其发挥作用,两个合约应该有兼容的存储布局,也就是说,它们应该将相同的变量分配到相同的存储槽中。

delegatecall是Solidity中的一个低级原语,使用起来不如普通函数调用方便。一般来说,每当我们想在一个合约上调用一个函数时,我们需要同时指定我们想调用的函数和我们想传递的论据。这些信息需要以一种众所周知的格式进行编码,以便目标合约知道如何解释它。这种格式也被称为应用二进制接口(ABI),并在 合约ABI规范 中描述。对于正常的函数调用,Solidity为我们做了这个编码,但是当使用delegatecall时,我们需要自己做。这是在delegateAdd()的第一行完成的。

bytes memory callData = abi.encodeWithSignature("add(uint256)", n);

encodeWithSignature()的第一个参数表示我们要调用的函数的签名,其余参数表示我们要传递给该函数的值。在上面的例子中,我们对一个名为add的函数的调用进行了编码,该函数需要一个uint256类型的参数,其值应该是n。如果我们假设n是,例如,4,那么callData将看起来如下:

0x1003e2d20000000000000000000000000000000000000000000000000000000000000004

(你可以通过在delegateAdd()函数中添加console.logBytes(callData)来验证这一点。)

前四个字节代表 "函数选择器",它是通过获取函数签名的 keccak 哈希值中最重要的前四个字节计算出来的。这个函数签名是 "add(uint256)",我们可以使用 Foundry 自带的 cast 命令行工具来计算其 keccak 哈希值。

$ cast keccak "add(uint256)"
0x1003e2d21e48445eba32f76cea1db2f704e754da30edaf8608ddc0f67abca5d0

正如你所看到的,哈希值的四个字节与 "callData" 中最重要的四个字节相匹配。
函数选择器后面是参数("callData" 值部分),这只是表示为 "uint256 "的值4,即32字节的无符号数。
现在我们已经在callData中存储了编码的函数调用,我们可以把它传递给delegatecall。

(bool ok,) = address(c).delegatecall(callData);

这一行在当前合约的上下文中执行函数Counter.add()。特别是,任何由Counter.add()执行的存储访问都将使用调用合约的存储,在此案例中,它是DelegateCounter类型。因此,当Counter.add()函数写到槽0以更新存储变量number时,它更新的是DelegateCounter的存储,而不是Counter的。

delegatecall返回两个值。一个表示调用是否成功的布尔值,和一个包含任何返回数据的字节数组。由于Counter.add()不返回任何东西,delegateAdd()忽略返回数据,只检查调用是否成功。这一点特别重要,因为当使用delegatecall时,被调用的函数中的reverts不会自动传播, [注3]。
注3: 为了简单起见,我们用一个固定的消息来作为revert 消息,而不是传播原始错误。

if(!ok) revert("Delegate call failed");

为了使这一切更具体,这里有一个例子:

Counter c = new Counter();
DelegateCounter d = new DelegateCounter();

// Sanity check: both counters should start at zero
assert(c.get() == 0);
assert(d.get() == 0);

d.delegateAdd(c, 4);

// Check that `d` has been updated and that `c` remains unchanged
assert(c.get() == 0);
assert(d.get() == 4);

我们首先创建了 Counter 和 DelegateCounter 合约的新实例,并使自己相信它们都是从0开始的。然后是有趣的部分,即调用d.delegateAdd(c, 4)。如上所述,delegateAdd()本质上是调用c.add(4),其方式是所有存储访问都指向d而不是c。下面两个断言验证了这一点,它们检查了c仍然为零,而d已经被更新。

现在我们可以清楚地看到delegatecall是如何用来实现可升级的合约的,因为我们可以将任何合约传递给delegateAdd(),它实现了一个签名为add(uint256)的函数。因此,即使 DelegateCounter 保持不变,我们也可以通过向 delegateAdd() 传递一些其他合约来改变其行为。然而,为了完全实现可升级的合约,我们还需要关注一个特性,即回退函数。这将在回退函数一节中介绍。然而,在我们继续之前,看看如何处理delegatecall的第二个返回值,即包含从被调用函数返回的数据的字节数组,是很有用的。

处理返回值

正如我们已经注意到的,使用delegatecall比正常的函数调用要不方便,因为我们必须根据ABI对调用进行手动编码。从调用中返回的数据也是如此。我们只是得到一个原始的字节数组,我们需要根据被调用的函数的返回类型自己解码。为了说明如何做到这一点,我们现在为DelegateCounter实现一个delegateGet()函数。

contract DelegateCounter {
  // ...

  function delegateGet(Counter c) external returns(uint256) {
    bytes memory callData = abi.encodeWithSignature("get()");
    (bool ok, bytes memory retVal) = address(c).delegatecall(callData);

    if(!ok) revert("Delegate call failed");

    return abi.decode(retVal, (uint256));
  }
}

这个实现与delegateAdd()非常相似。我们首先对我们想要执行的调用进行ABI编码,然后使用delegatecall来进行调用。然而,这一次我们也处理了由调用返回的数据,我们将其存储在retVal中。因为get()返回一个uint256,ABI规定像uint256这样的固定宽度类型的值是通过简单的取其big-endian表示并将结果填充到32字节来编码的,返回的数据可以通过简单的将retVal 类型转换为uint256来解码。

return uint256(bytes32(retVal));

然而,对于复杂的类型,解码变得更加复杂。幸运的是,Solidity提供了函数abi.decode(),可以为我们执行解码。使用这个函数,我们可以将返回语句重写如下:

return abi.decode(retVal, (uint256));

函数abi.decode()需要两个参数。一个包含一些ABI编码值的字节数组,以及一个包含编码值类型的元组。

泛化

为了为以后做准备,我们可以对delegateGet()做最后的修改,以便对处理返回数据的方式进行概括。注意,当我们用abi.decode(retVal, (uint256))对返回数据进行解码时,我们对返回类型进行了硬编码。如果我们想在任意函数中使用delegatecall,那么我们也需要能够处理任意的返回数据。这在纯 Solidity 中是不可能的,所以我们需要转向汇编。特别是,我们需要替换:

return abi.decode(retVal, (uint256));

替换为:

assembly {
  let data := add(retVal, 32)
  let size := mload(retVal)
  return(data, size)
}


return(data,size)指令结束当前函数的执行,并返回由data和size给出的内存范围内的数据,其中data表示起始地址,size表示数据的字节大小(详见Yul规范)。在上面的例子中,data和size的计算方式可能不是很明显。要理解这一点,重要的是要知道数组是如何在内存中布局的。首先,请注意,当我们从汇编块中引用像retVal这样的内存变量时,我们实际上是指它的地址。因此,当我们在上面的汇编块中使用retVal时,我们指的是retVal所表示的字节数组在内存中的起始地址。其次,Solidity在内存中排列数组的方式如下 [注4]。首先是数组的长度, 存储为一个32字节的无符号数字, 然后是所有的数组元素. 因此,retVal的数组长度直接存储在retVal的地址(我们通过mload加载),为了得到数组元素的地址,我们需要给retVal增加一个32字节的偏移量。
注4: 这与数组在存储中的布局不同,见存储中状态变量的布局内存中的布局

有了上述汇编,我们可以简单地转发任何来自delegatecall的返回数据,而不需要知道编码后的值的类型。这使得我们可以调用任意的函数而不需要事先知道它们的返回类型。

要想玩转这段代码,请看DelegateCall.t

回退函数

回退函数是实现可升级合约时另一个有用的功能。它们允许开发者指定当一个不存在的函数被调用时应该发生什么。默认的行为是回退,但这可以被改变。

interface Ifc {
  function hello() external;
  function bye() external;
}
contract C {
  event Log(string msg);

  function hello() external {
    emit Log("hello");
  }

  fallback() external {
    emit Log("fallback");
  }
}

上面我们定义了一个带有函数hello()和bye()的简单接口。此外,我们定义了一个合约C,它包含一个函数hello()和一个fallback函数。现在考虑下面的例子:

Ifc ifc = Ifc(address(new C()));
ifc.hello(); // Emits Log("hello")
ifc.bye();   // Emits Log("fallback")

我们创建了一个新的合约C的实例,并将其转换为 Ifc 类型,这使得我们可以同时调用hello()和bye()。当我们调用Bye()时,由于C没有定义,所以会执行回退函数。

一个有用的事实是,我们可以使用msg.data来访问触发回退函数的原始调用数据。例如,如果在C的回退函数中加入console.logBytes(msg.data),那么在调用ifc.bye()时就会产生如下日志信息

0xe71b8b93

正如你所期望的,这只是bye()的函数选择器(因为bye()没有参数,所以没有编码的参数)。这意味着通过检查msg.data我们可以确定用户最初打算调用哪个函数。
完整的例子见Fallback.t.sol

可升级的合约

使用delegatecall和回退函数,我们可以实现一个基于代理 的可升级合约的一般解决方案。其核心思想如下。对于每一个我们希望其代码可以升级的合约,我们实际上部署了两个合约。一个代理合约和一个逻辑合约。代理合约是存储所有数据的合约,而逻辑合约则包含对这些数据进行操作的功能。用户将只与代理合约进行交互。当用户在代理上调用一个函数时,代理会使用一个委托调用将调用转发给逻辑合约。因为代理使用委托调用,执行逻辑合约的函数会影响代理的存储。因此,当使用可升级合约时,代理持有状态,而逻辑合约持有代码。从用户的角度来看,代理的行为与逻辑合约的行为是一样的。升级合约只是意味着代理使用了一个新的逻辑合约。

初次尝试 (不成功)

通过上面的解释,人们也许会被诱惑去实现代理合约,如下所示:

contract FaultyProxy {
  address public implementation;

  function upgradeTo(address newImpl) external {
    implementation = newImpl;
  }

  fallback() external payable {
    (bool ok, bytes memory returnData) = implementation.delegatecall(msg.data);

    if(!ok)
      revert("Calling logic contract failed");

    // Forward the return value
    assembly {
      let data := add(returnData, 32)
      let size := mload(returnData)
      return(data, size)
    }
  }
}

正如它的名字FaultyProxy所示,这个代理在一般情况下是不起作用的。然而,了解为什么它不能工作仍然是有意义的,特别是因为我们后面要看的Audius协议中的错误与上述代理中的错误非常相似。

代理有一个单一的存储变量,implementation,它存储了逻辑合约的地址。通过调用upgradeTo()可以改变逻辑合约,使逻辑(换句话说:代码)可以升级。(现在,任何人都可以调用upgradeTo(),这当然是不可取的。我们稍后会回到这个问题上)。拼图的最后一块是回退函数。它的目的是转发任何对使用delegatecall的逻辑合约的调用。(除了对upgradeTo()和implementation()的调用,这些调用是由代理本身处理的。) 但我们怎么知道用户想调用哪个函数呢?幸运的是,触发回退函数的原始calldata可以通过msg.data [注 5] 访问。由于calldata包含函数签名和参数值,我们可以简单地将msg.data传递给delegatecall。之后,我们检查调用是否成功。如果不成功,我们就还原,否则就转发返回数据。
注 5: 回退函数也可以使用不同的签名,其中calldata被直接作为参数传递。更多信息请参见 Solidity 文档 关于 fallback 函数 的说明。

下面的例子显示了代理应该如何使用:

// (1) 创建逻辑合约
Counter logic = new Counter();

// (2) Create proxy and tell it which logic contract to use
FaultyProxy proxy = new FaultyProxy();
proxy.upgradeTo(address(logic));

// (3) To be able to call functions from the logic contract, we need to
//     cast the proxy to the right type
Counter proxied = Counter(address(proxy));

// (4) Now we treat the proxy as if it were the logic contract
proxied.add(2);

// (5) Did it work? (Spoiler: no!)
console.log(“counter =”, proxied.get()); // Reverts!

前两步分别创建了逻辑和代理合约。第二步还调用了upgradeTo(),这样代理就知道要使用哪个逻辑合约。第三步需要告诉Solidity编译器,我们现在计划使用代理,就像它是逻辑合约一样。第四步是它变得有趣的地方。我们在代理上调用add()函数。由于代理没有定义任何该名称的函数,其回调函数被执行。在回调函数中,msg.data包含以下调用数据:

0x1003e2d20000000000000000000000000000000000000000000000000000000000000002

这代表了对一个签名为 "add(uint256)"、参数为2的函数的调用 。然后,回退函数用上述调用数据执行一个 "delegatecall",使用代理的存储空间执行 "Counter "合约中的 "add() "函数。

最后,在第五步中,我们试图从代理中获取当前的计数器值。然而,执行proxied.get()实际上是回退了! 这个错误的原因可以通过可视化代理和逻辑合约来轻松解释。

当比较两个合约的存储布局时,我们可以注意到它们在0槽中存储了不同的变量。这产生了一个不幸的后果。当Counter.add()使用FaultyProxy的存储空间执行时,它修改了存储槽0,以便更新number。然而,在合约FaultyProxy中,槽0包含implementation的值。因此,当我们在步骤(4)中调用proxied.add(2)时,我们实际上将存储在implementation中的地址增加了2,使得地址无效。更确切地说,现在产生的地址指向一个账户,而这个账户很可能没有被部署过合约。当对一个空账户进行委托时,调用将成功,但没有数据被返回。然而,由于我们确实希望返回一个uint256类型的值,所以测试被还原了。
参见FaultyProxy.t.sol中的代码。

可工作的版本 (但有错误)

我们怎样才能解决代理和逻辑合约之间的存储槽碰撞问题呢?一个简单的方法是在Counter合约中的number前添加一个虚拟存储变量。然后,number将被存储在槽1中,它将不再与FaultyProxy的implementation发生冲突。然而,这并不是一个好的解决方案。它很脆弱,很容易被遗忘,而且如果逻辑合约继承了其他合约,可能很难执行。

那么,代理合约怎样才能在不产生槽冲突的情况下存储逻辑合约的地址呢?有多种方法,见“设计选择”一节。在这里,我们将遵循非结构化存储模式,它被广泛使用(例如OpenZeppelin,也见使用非结构化存储的可升级性),它为这个问题提供了一个不需要对逻辑合约进行任何修改的解决方案。这个想法是让代理将逻辑合约地址存储在某个遥远的插槽中,这样一来,插槽碰撞的几率就可以忽略不计了。有了这个想法,我们可以实现一个新的代理:

// This proxy is working but still has flaws, so don’t use it for anything serious
contract Proxy {
  bytes32 constant IMPLEMENTATION_SLOT =
    bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1);

  function upgradeTo(address newImpl) external {
    bytes32 slot = IMPLEMENTATION_SLOT;
    assembly {
      sstore(slot, newImpl)
    }
  }

  function implementation() public view returns(address impl) {
    bytes32 slot = IMPLEMENTATION_SLOT;
    assembly {
      impl := sload(slot)
    }
  }

  fallback() external payable {
        (bool ok, bytes memory returnData) =
            implementation().delegatecall(msg.data);

        if(!ok)
            revert("Calling logic contract failed");

        // Forward the return value
        assembly {
            let data := add(returnData, 32)
            let size := mload(returnData)
            return(data, size)
        }
    }
}

Proxy和FaultyProxy之间的关键区别是,Proxy没有声明任何存储变量。相反,逻辑合约的地址被存储在槽IMPLEMENTATION_SLOT中,它被计算为 eip1967.proxy.implementation 字符串的keccak散列值减去1[注6] 。顾名思义,这个槽位号在EIP-1967中被标准化。有了一个定义明确的槽来存储逻辑合约,像Etherscan这样的服务可以自动检测合约是否具有代理功能,在此案例中,可以显示代理和逻辑合约的信息。例如,如果你在Etherscan上查看USDC的代码,除了正常的 读/写合约 标签外,还有 作为代理读/写 的选项,它提供了一个指向当前逻辑合约的链接。

注6: 为什么我们要从keccak的哈希值中减去一个?纯粹从功能的角度来看,这没有什么区别。使用不加-1的keccak哈希值也一样可以工作。然而,正如在EIP-1967中提到的,增加-1的偏移量是为了使预像攻击更加困难(见: https://github.com/ethereum/EIPs/pull/1967#issuecomment-489276813

为了升级合约,upgradeTo()函数需要修改IMPLEMENTATION_SLOT给出的槽位上的地址,这可以使用sstore指令。注意,我们需要将IMPLEMENTATION_SLOT复制到一个局部变量中,因为不可能直接从汇编中读取常数。函数implementation()以类似的方式实现,读取存储在槽IMPLEMENTATION_SLOT的地址。最后,回退函数保持不变,只是我们现在使用implementation()函数而不是存储变量来获取逻辑合约地址。

我们使用sstore/sload来访问逻辑合约,而不是使用合约变量,这使得这个代理非结构化,这也解释了非结构化存储模式的名字。我们可以再次直观地看到代理和逻辑合约。

(这里,IMPL_SLOT = IMPLEMENTATION_SLOT) 之前提到,当使用delegatecall时,必须确保调用者合约和被调用者合约都有兼容的存储布局,以防止被调用者弄乱调用者的存储。对于delegatecall部分的Counter和DelegateCounter合约,这很容易验证,因为这两个合约定义的存储变量完全相同。另一方面,Proxy与Counter的存储布局不相同,但由于两个合约使用完全不同的存储槽,因此不会"踩到对方的脚趾",这不是一个问题。(事实上,Proxy是完全独立于所使用的具体逻辑合约,因此这意味着人们只需要写一个可以被所有人使用的代理。)

当然,这只有在IMPLEMENTATION_SLOT表示的槽不会意外地与逻辑合约中的任何存储变量冲突时才安全。这实际上能保证吗?首先,请注意,IMPLEMENTATION_SLOT表示一个相当大的值。由于像 uint256 这样有固定大小的存储变量被分配到从零开始的槽号,现实中我们可以假设它们的槽号比IMPLEMENTATION_SLOT小得多。而且在任何情况下,由于这些槽是在编译时分配的,编译器可以检测到与IMPLEMENTATION_SLOT的碰撞并报告错误。

但是,对于动态大小的类型,如映射和动态数组,情况有点不同,其元素的存储槽是使用keccak hashes计算的(见Solidity文档中的映射和动态数组)。这样计算的槽实际上可能与IMPLEMENTATION_SLOT相冲突。然而,一般的共识是,这种情况发生的几率很小,所以这不被认为是一个问题。

最后,虽然上面的代理实现是可行的,但它仍然有基本的缺陷,因此不应该被用于任何严肃的生产环境,这些缺陷是

  1. 该代理容易受到函数选择器冲突的影响,这可能导致意外的行为(见”设计选择“ 一节)。
  2. upgradeTo()是无权限的,这意味着任何人都可以升级合约。由于升级合约可以极大地改变其行为,这是一个明显的安全问题,任何代理实现都必须解决。我们将在后续的文章中讨论与此直接相关的Audius攻击。

初始化

在迄今为止的例子中,我们只使用了Counter合约作为逻辑合约,它非常简单,甚至没有一个用户定义的构造函数。这让我们成功地忽略了使用代理时产生的一个重要限制。不能使用构造函数。原因是构造函数实际上不是函数,因此不能被delegatecall调用。解决的办法是使用一个单独的初始化函数。让我们修改Counter,这样我们可以用计数器的初始值来初始化它。

contract Counter {
  bool isInitialized;
  uint256 number;


  function initialize(uint256 start) external {
    require(!isInitialized, “Already initialized”);
    number = start;
    isInitialized = true;
  }

  function get() external view returns(uint256) {
    return number;
  }


  function add(uint256 n) external {
    require(n <= 5, "Max increment is 5");
    number += n;
  }
}

我们做了两个改动。我们添加了isInitialized存储变量和initialize()函数。与构造函数相比,initialize()函数只是一个普通的函数,可以被调用任意次数。由于安全敏感的参数经常在初始化过程中被设置,所以防止重新初始化是很重要的,我们在这里借助isInitialized来做到这一点。虽然这在这个简单的例子中是可行的,但对于生产来说,建议使用像OpenZeppelin的Initializable这样的东西,它可以正确处理继承,并支持在升级后重新初始化。

最后一个例子

我们已经谈了很多关于可升级的合约,但到目前为止,我们还没有升级任何东西。让我们创建一个 "CounterV2",它与 "Counter" 类似,但将增量限制从5增加到10。

contract CounterV2 {
  // ...


  function add(uint256 n) external {
    require(n <= 10, "Max increment is 10"); // Increase max increment to 10
    number += n;
  }
}

下面的例子说明了部署和升级一个合约的整个过程:

// (1) Create logic contract
Counter logic = new Counter();

// (2) Create proxy and tell it which logic contract to use
Proxy proxy = new Proxy();
proxy.upgradeTo(address(logic));

// (3) To be able to call functions from the logic contract, we need to
//     cast the proxy to the right type
Counter proxied = Counter(address(proxy));
proxied.initialize(23);

// (4) Now we treat the proxy as if it were the logic contract
proxied.add(2); // Works as expected
// proxied.add(7); Would fail (as expected)

// (5) Upgrade to a new logic contract
CounterV2 logicV2 = new CounterV2();
proxy.upgradeTo(address(logicV2));

// (6) Now adding a value larger than 5 actually works!
proxied.add(7); // Works as expected

注意,在我们的代理实现中,初始化是一个多步骤的过程。在步骤(2)中,我们创建一个新的代理并分配逻辑合约,在步骤(3)中我们调用初始化函数。相比之下,OpenZeppelin的实现可以在一个步骤中完成所有这些工作(见ERC1967Proxy.constructor()),这可以防止前面的攻击,而且更节省Gas。

每一步之后,代理的存储看起来如下(只显示已经被写入的槽):

当代理在第2步创建时,它还没有存储任何来自逻辑合约的状态。这只发生在第3步之后,当逻辑合约被初始化,将槽0(isInitialized)设置为true,槽1(counter)设置为23。

完整的例子见Proxy.t.sol

设计选择

在实现可升级合约的代理时,有两个基本问题需要回答。

  1. 如何防止代理和逻辑合约之间的存储槽碰撞?
  2. 如何处理代理和逻辑合约之间的函数选择器冲突?

在这篇文章中,我们只看了第一个问题,我们的答案是使用非结构化存储模式。然而,还有其他的方法,比如Inherited 存储或 Eternal存储(参见代理模式,了解相关概况)。

关于第二个问题。正如我们所看到的,函数在内部是由函数选择器来识别的,这些选择器有四个字节长,来自函数签名的keccak散列。这使得不同签名的函数有可能映射到同一个函数选择器上,从而导致选择器冲突。

例如,签名proxyOwner()和clash550254402()的函数选择器是一样的,见这里

$ cast keccak "proxyOwner()"
0x025313a28d329398d78fa09178ac78e400c933630f1766058a2d7e26bb05d8ea
$ cast keccak "clash550254402()"
0x025313a2bba9fda619061d44004df81011846caa708c8d9abf09d256021e23ee

通常,这不是一个问题,因为如果函数选择器冲突发生在单个合约的两个函数之间,那么 Solidity 编译器会以错误中止。然而,如果这样的冲突发生在不同合约的两个函数之间,那么就不会报告错误,因为这通常并不重要。但是,使用代理时除外。代理的回退函数会将任何它自己没有定义的函数转发给逻辑合约。现在,如果代理和逻辑合约定义了一个具有相同选择器的函数,那么代理将永远不会把对该函数的调用转发给逻辑合约,而是自己处理调用。更多信息请参见以太坊代理中的恶意后门

对这个问题至少有两种流行的解决方案:透明代理模式和通用可升级代理标准(UUPS)。透明代理模式的工作原理是,根据信息发送者的情况,将所有功能调用转发到逻辑合约,或者完全不转发。如果消息发送者是一个指定的代理管理员,那么我们假设他们只想调用代理本身的功能,而不是逻辑合约。对他们来说,调用是不会被转发的。另一方面,对于任何其他用户,我们假设他们只想调用逻辑合约中的功能,因此他们的调用总是被转发。这就避免了任何源于函数选择器冲突的问题,因为发送者决定了应该使用哪个合约。更多信息,请参阅透明代理模式

UUPS模式描述于EIP-1822。在这里,函数选择器冲突的问题是通过在代理中不定义任何公共函数来避免的。相反,所有管理代理的功能(包括upgradeTo())都在逻辑合约中实现。更多信息请参见Transparent vs UUPS Proxies

你可能已经注意到了,我们的例子代理合约既没有实现透明代理模式也没有实现UUPS。事实上,它根本没有防止函数选择器的冲突,而且完全受到上述问题的影响。请看一下OpenZeppelin Proxy Library的生产就绪的代理实现。

最后,还有其他具有不同权衡的代理模式。例如,有一个Beacon Proxy模式,它引入了另一个层次的间接性,但允许一次升级许多合约。还有Diamond Pattern,它允许将逻辑分散在多个合约中,规避了任何代码大小的限制。

结论和展望

在这篇文章中,我们已经开发了一个基本的代理实现,并讨论了一路走来的各种陷阱。不过,我们的实现还是有两个主要的缺点:

  1. 它很容易受到函数选择器冲突的影响
  2. upgradeTo()是无权限的。

在这个系列中,我们不会解决第一个缺点(关于潜在的解决方案,见设计选择一节)。然而,在下一篇文章中,我们将更仔细地研究第二个问题,因为这将直接导致Audius攻击中被利用的漏洞

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