狠人!用Python从零敲出一个以太坊虚拟机-2

[TOC]

📕介绍

在这一部分,我们将拓展指令集,重点在于:

  • 分支指令
  • 循环和条件
  • 跳转目的验证
  • 堆栈操作指令(DUP,SWAP,PUSH

让我们开始吧!

🌳分支指令

这些指令允许我们操作程序计数器,即下一条要运行的指令在代码缓冲区中的位置:

JUMP 从堆栈中弹出一项并且将程序设置成这个新值。

JUMPI如果第二个参数(一个条件)不为零,则执行相同的操作,否则执行道吓一跳指令。

用我们简单明了的lambda风格来写这些是很诱人的:

1
2
3
4
5
JUMP = instruction(
0x56,
"JUMP",
(lambda ctx: ctx.set_program_counter(ctx.stack.pop()),
)

然而,有一个陷阱!请记住,代码缓冲区是一个字节可寻址的ROM,我们的分支指令让我们跳转到…….缓冲区的一个字节。谁能说有一条有效的指令在哪个偏移量?如果每条指令都呢个整齐的放入一个字节,我们就不会有问题,但EVM上的情况并非如此。值得注意的是,PUSH指令的参数直接编码在代码缓冲区中,这使得它们的大小可变。


最好用一个例子来解释的话,这是我们的代码缓冲区:

它将被字节填充满:

所对应的指令为:

但是有些指令可以跨越多个字节:

所以如果你在指令的中间jump的话,这将会出错:

在这点上,你可能想知道为什么这很糟糕,我至少看到两个原因:

  • 这可能因为jump本身的问题,所以与其执行jump造成随机的穿插,还不如让其立刻奔溃;举个例子,如果你的代码中有一个PUSH1 FF,并且有人可以利用您的合约跳转到该FF字节,这将导致您的合约自毁;
  • jump也可以用来隐藏代码,封装在其他指令的数据组文件中。这将击溃etherscan中的合同点验证(可以绕过已经发布的Solidity源码来执行任意代码)

考虑到至少有这两个情况发生并且并没有好的理由来支持随意地使用jump,以太坊地开发者们决定让其非法化,在下一部分我们可以看到它们是如何做的。


🌳跳跃指向合理性

基于我们现在已经知道的,黄皮书的以下部分将听起来很熟悉:

我们可以将此规范转换成一个简单的过程,在该过程中使用代码缓冲区并在该缓冲区中返回一组有效的跳跃目的地:

1
2
3
4
5
6
7
8
9
10
11
12
def valid_jump_destinations(code: bytes) -> set[int]:
jumpdests = set()
i = 0
while i < len(code):
current_op = code[i]
if current_op == JUMPDEST.opcode:
jumpdests.add(i)
elif PUSH1.opcode <= current_op <= PUSH32.opcode:
i += current_op - PUSH1.opcode + 1

i += 1
return jumpdests

JUMPEST 是我们目前为止还没有遇到过的指令,它是干什么的呢?啥也不是!

本质上,JUMPEST只是有效跳转目的地的标记。但值得注意的是,我们不能旨在运行时检查我们降落在代码缓冲区中恰好时JUMPEST(又称为0x5b)的位置上——我们需要提前从偏移量为0开始扫描代码缓冲区以查找这些操作码。在运行时,在跳转之后,我们需要检查我们降落的目的地是否是我们之前确定的有效目的地集的一部分。

总之,到目前为止,我们已经了解了关于jump指令的一些有趣的事情:

  • 我们只能跳转到指令边界处由JUMPEST标记的偏移量
  • 我们不能评估一个JUMP1指令跳转目的地的有效性(即cond==0
  • 尽管我们可以使用常规指令落空代码缓存区的结尾(下一条指令隐式的为STOP),但我们不能跳过代码缓冲区的结尾

🥇第一个无限循环

我们终于有了跳跃,可以开始做一些有趣的事情了,比如条件和循环。让我们编写第一个无限循环来庆祝吧!!

tests/test_jumps.py

1
2
3
4
5
6
7
8
9
def test_infinite_loop():
# 5b600056
code = assemble([
JUMPDEST,
PUSH1, 0,
JUMP
])
with pytest.raises(ExecutionLimitReached) as excinfo:
run(code, max_steps=100)

聪明的你应该会注意到,我引入了max_steps参数,以便实际限制我们运行代码的时间。当然,在真正的EVM上,这将会收到气体的限制,但我们的目前的EVM还没有这一点。


🥈条件循环

作为最后一个挑战,让我们来尝试实现一下迭代计算四平方的小算法:

1
2
3
4
5
6
7
8
9
n = 4
loops = n
result = 0
while loops != 0:
result += n
loops -= 1

# should be n^2
return result

如果用原有的指令字节码实现这个算法是很有挑战性的,所以为了让我们更加轻松,需要在原来的基础上再补充一个新的指令DUP

  • DUP1 是一个指令,代表着复制堆栈最顶部的元素,DUP2代表着复制堆栈第二个元素,依此类推一直到DUP16。这对于读取深层堆栈变量很有用。
1
DUP1 =  register_instruction(0x80,"DUP1",lamdba ctx:ctx.stack.push(ctx.stack.peek(0)))
  • 堆栈的peek函数
1
2
3
4
5
def peek(self, i) -> int:
if len(self.stack) <= i:
raise StackUnderflow()

return self.stack[-(i + 1)]
  • SWAP1 是堆栈的函数,将堆栈顶部与堆栈的第二个元素交换,SWAP2 将堆栈顶部和堆栈第三个元素交换,依此类推一直到SWAP16。这对于读取深层堆栈变量很有用。
1
2
3
4
5
def swap(self,i:int) -> None:
if len(self.stack) < i:
raise StackUnderflow()

self.stack[-1],self.stack[-i-1] = self.stack[-i-1],self.stack[-1]
  • SUB 这样我们可以减少循环计数:
1
SUB = register_instruction(0x03,"SUB",lambda ctx: ctx.stack.pop() - ctx.stack.pop())

测试代码如下:

tests/test_jumps.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def test_four_squared():
# 60048060005b8160125760005360016000f35b8201906001900390600556
code = assemble([
# stack
PUSH1, 4, # n=4
DUP1, # n=4, loops=4
PUSH1, 0, # n=4, loops=4, result=0

# loop_cond
# if loops != 0, jump to loop_body
JUMPDEST,
DUP2, # n, loops, result, loops
PUSH1, 18, # n, loops, result, loops, loop_body
JUMPI, # n, loops, result

# return result
PUSH1, 0, # n, loops, result, m_result
MSTORE8, # n, loops
PUSH1, 1, # n, loops, mem_length
PUSH1, 0, # n, loops, mem_length, mem_offset
RETURN,

# loop_body
JUMPDEST,

# result += n
DUP3, # n, loops, result, n
ADD, # n, loops, result'=n+result

# loops -= 1
SWAP1, # n, result', loops
PUSH1, 1, # n, result', loops, 1
SWAP1, # n, result', 1, loops
SUB, # n, result', loops'=loops-1

# restore stack
SWAP1, # n, loops', result'

# jump to loop_cond
PUSH1, 5, # n, loops', result', loop_cond
JUMP, # -> back to loop_cond
])

ret = run(code, verbose=True, max_steps=200)
assert int.from_bytes(ret, 'big') == 4 * 4

📕值得注意的几点:

  1. 现在我们有了一组可行的堆栈指令,我们可以将变量留在堆栈上,并使用复制/交换指令(DUP/SWAP)对它们进行操作。我们访问内存的唯一时间是存储返回数据。
  2. 我们没有标签,所以我们必须计算并手动硬编码跳转偏移量(例如,最后的PUSH15,jump返回loop_cond块)
  3. 当我们想推送一个值时,我们必须根据参数(PUSH1、PUSH2、…PUSH32)的字节

实际上:我们到到目前位置所构建的(仅在字节数组级别之上的原始指令上的薄包装器)与Yul进行比较时,仍会感觉到Yul是一种相当高级的语言,以Yul文档中的这段摘录为例:

没有为SWAPDUPJUMPDESTJUMPJUMPI提供显式语句,因为前两个使数据流模糊,后两个使控制流模糊。

🥉接下来的内容

我们已经开始感到我们需要某些东西比如calldata来为我们的小项目建模输入。

同样我们也没有建立预想中的返回。在那之后我们很有可能做一些非凡的事情比如内部交易或者是合约创建。我们第三部分见吧!


狠人!用Python从零敲出一个以太坊虚拟机-2
http://nangbowan.github.io/2022/11/11/狠人!用Python从零敲出一个以太坊虚拟机-2/
作者
Science_Jun
发布于
2022年11月11日
许可协议