Writeup | EVM-Puzzles

EVM-Puzzles

挑战仓库地址

EVM操作码

准备工作:

  • 安装hardhat
  • 克隆仓库,进入仓库根目录
  • 准备环境:npm install yarn
  • 开始挑战:npx hardhat play

Puzzle 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
############
# Puzzle 1 #
############

00 34 CALLVALUE
01 56 JUMP
02 FD REVERT
03 FD REVERT
04 FD REVERT
05 FD REVERT
06 FD REVERT
07 FD REVERT
08 5B JUMPDEST
09 00 STOP

? Enter the value to send: (0)

首先,我们需要知道 CALLVALUE 的作用。此操作码获取 wei 中当前调用的值(即事务值),并将该值推送到堆栈的顶部。

即如果我们输入4,堆栈会发生如下变化

1
2
3
[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
--->
[4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

接下来,我们需要知道 JUMP 指令的作用。此操作码消耗堆栈上的顶部值,并跳转到序列中的第 th 条指令。

即如果我们输入4,那么将会执行位于04的操作码

1
04      FD      REVERT

执行REVERT,运行失败

因此我们输入8,将会执行JUMPDEST,跳过所有指令

Puzzle 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
############
# Puzzle 2 #
############

00 34 CALLVALUE
01 38 CODESIZE
02 03 SUB
03 56 JUMP
04 FD REVERT
05 FD REVERT
06 5B JUMPDEST
07 00 STOP
08 FD REVERT
09 FD REVERT

? Enter the value to send: (0)

我们的目标是使栈顶的值为6,使程序跳过所有REVERT

CODESIZE 获取在当前环境中运行的代码的大小,并入栈。在此示例中,我们可以通过查看序列中有多少操作码来手动检查代码的大小。每个操作码是1个字节,在这个谜题中,我们有10个操作码,这意味着代码的大小是10个字节。注意,EVM 使用十六进制数来表示字节码。所以这里是0a

SUB 会把第一个堆栈元素减去第二个堆栈元素,并将结果推送到堆栈的顶部。

此时的堆栈情况

1
[input 0a 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

即 0a - input = 6

所以输入4

Puzzle 3

1
2
3
4
5
6
7
8
9
10
11
12
13
############
# Puzzle 3 #
############

00 36 CALLDATASIZE
01 56 JUMP
02 FD REVERT
03 FD REVERT
04 5B JUMPDEST
05 00 STOP

? Enter the calldata:

CALLDATASIZE 会获取调用数据的大小(以字节为单位),并将其推送到堆栈上。

并且题目要求我们输入calldata

所以我们输入四个指令的字节码,使 CALLDATASIZE 指令在堆栈上推送4

这里我输入了 0x5B5B5B5B

Puzzle 4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
############
# Puzzle 4 #
############

00 34 CALLVALUE
01 38 CODESIZE
02 18 XOR
03 56 JUMP
04 FD REVERT
05 FD REVERT
06 FD REVERT
07 FD REVERT
08 FD REVERT
09 FD REVERT
0A 5B JUMPDEST
0B 00 STOP

这里的 CODESIZE 为12,即0c

此时堆栈状态

1
[0c input 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

XOR 以二进制表示形式计算两个数字,两者相等为0,不等为1

假设我们在堆栈的顶部有两个数字。

1
[5 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

在执行 XOR 指令时,我们可以想象二进制表示中的两个数字是这样的。

1
2
00000000000000000000000000000101
00000000000000000000000000000011

得到

1
00000000000000000000000000000110

而要想通过,我们需要得到0a

1
2
0c   XOR input = 0a
1100 XOR input = 1010

所以输入0110,即6

Puzzle 5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
############
# Puzzle 5 #
############

00 34 CALLVALUE
01 80 DUP1
02 02 MUL
03 610100 PUSH2 0100
06 14 EQ
07 600C PUSH1 0C
09 57 JUMPI
0A FD REVERT
0B FD REVERT
0C 5B JUMPDEST
0D 00 STOP
0E FD REVERT
0F FD REVERT

? Enter the value to send: (0)

DUP1 会把堆栈上的第一个位置上的值复制,并将副本推送到堆栈的顶部。

所以现在在前两条指令之后,我们的堆栈看起来像这样。

1
[input input 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

MUL 获取堆栈上的前两个值,将它们相乘,然后将结果推送到堆栈的顶部。

因此,在本例中,MUL以后生成的堆栈如下所示。

1
[mulResult 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

PUSH2 会将指定的 2 字节值推送到堆栈的顶部。这里指定了0100(十六进制)

1
[0100 mulResult 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

EQ 会获取堆栈上的前两个值,将其进行比较并将结果推送到堆栈顶部。如果前两个值相等,则为1,否则为0。

JUMPI 将有条件地更改程序计数器。此指令查看第二个堆栈元素以了解它是否应该跳转,具体取决于第二个堆栈元素是 1 还是 0 。然后使用第一个堆栈元素来知道要跳转到哪个位置。

因此此时我们的堆栈应该是这样

1
[0c 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

即跳转到第12条命令,通过挑战

所以为了使 EQ 的结果为1,我们需要使 input * input 的结果等于十六进制的0100,也就是256

所以输入16

Puzzle 6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
############
# Puzzle 6 #
############

00 6000 PUSH1 00
02 35 CALLDATALOAD
03 56 JUMP
04 FD REVERT
05 FD REVERT
06 FD REVERT
07 FD REVERT
08 FD REVERT
09 FD REVERT
0A 5B JUMPDEST
0B 00 STOP

? Enter the calldata:

CALLDATALOAD 从调用数据的给定偏移量开始的 32 字节值,不足 32 bytes 的自动补0

例如

1
2
3
calldata 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
i 31
output 0xFF00000000000000000000000000000000000000000000000000000000000000

而如果输入 0x0A 会变成 0A00000000000000000000000000000000000000000000000000000000000000

所以应该输入0x000000000000000000000000000000000000000000000000000000000000000a

Puzzle 7

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
############
# Puzzle 7 #
############

00 36 CALLDATASIZE
01 6000 PUSH1 00
03 80 DUP1
04 37 CALLDATACOPY
05 36 CALLDATASIZE
06 6000 PUSH1 00
08 6000 PUSH1 00
0A F0 CREATE
0B 3B EXTCODESIZE
0C 6001 PUSH1 01
0E 14 EQ
0F 6013 PUSH1 13
11 57 JUMPI
12 FD REVERT
13 5B JUMPDEST
14 00 STOP

CALLDATASIZE

1
[calldatasize 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

PUSH1 00

1
[0 calldatasize 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

DUP1

1
[0 0 calldatasize 0 0 0 0 0 0 0 0 0 0 0 0 0]

CALLDATACOPY destOffset offset size

从第 offset 的位置复制长度为 size 的 calldata,并从 destOffset 写进 memory。

在此过程将消耗堆栈上的所有三个顶部元素

运行后的结果和之前没有变化

1
[0 0 calldatasize 0 0 0 0 0 0 0 0 0 0 0 0 0]

CALLDATASIZE

PUSH1 00

PUSH1 00

1
[0 0 calldatasize 0 0 calldatasize 0 0 0 0 0 0 0 0 0 0]

CREATE value offset size

建立(部署)新合约,会开启子 context 并执行初始化 code,执行完后会继续 context。

如果部署成功,新地址的 code 会是执行初始化 code 的回传结果。

合约地址的计算公式:

1
address = keccak256(rlp([sender_address,sender_nonce]))[12:]

value

发送给合约的 value。

offset|size

将 memory 里第 offset 长度为 size 的资料作为初始化 code 赋给 EVM 执行。

因此运行结果

1
[address_deployed 0 0 calldatasize 0 0 0 0 0 0 0 0 0 0 0 0]

EXTCODESIZE 会将堆栈顶部的地址的代码大小返回

1
[address_code_size 0 0 calldatasize 0 0 0 0 0 0 0 0 0 0 0 0]

PUSH1 01

EQ

1
[1 address_code_size 0 0 calldatasize 0 0 0 0 0 0 0 0 0 0 0 ]

然后比较01和address_code_size是否相等

若相等则可以成功跳转至第13条命令,通过挑战

因此现在的目标是使输入的calldata的calldatasize为1

RETURN 的作用:从堆栈中弹出 2 个值以将它们用作以下操作的输入

  • offset:从哪里开始读取的内存偏移量
  • size:要读取和返回的内存大小(以字节为单位)

因此,无论它在内存中是什么,我们都只想返回 1 条 1 字节的指令。我们的目标是执行RETURN(offset=0, size=1)

所以一个简单的智能合约

1
2
3
4
5
6
6000  PUSH1  00 // 00 是 STOP 
6000 PUSH1 00 // 这将被用作 MSTORE8 的偏移量,在内存中存储 STOP
53 MSTORE8 // 将从偏移量 0 开始在内存中存储 `00` 值(来自第一个 PUSH1
6001 PUSH1 01 // 必须返回 1 字节
6000 PUSH1 00 // 从 0 返回这些字节
F3 RETURN

翻译为字节码即为 600060005360016000F3

Puzzle 8

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
############
# Puzzle 8 #
############

00 36 CALLDATASIZE
01 6000 PUSH1 00
03 80 DUP1
04 37 CALLDATACOPY
05 36 CALLDATASIZE
06 6000 PUSH1 00
08 6000 PUSH1 00
0A F0 CREATE
0B 6000 PUSH1 00
0D 80 DUP1
0E 80 DUP1
0F 80 DUP1
10 80 DUP1
11 94 SWAP5
12 5A GAS
13 F1 CALL
14 6000 PUSH1 00
16 14 EQ
17 601B PUSH1 1B
19 57 JUMPI
1A FD REVERT
1B 5B JUMPDEST
1C 00 STOP

? Enter the value to send: (0)

前面都和第七题一样

1
2
3
4
5
6
7
8
9
10
00      36        CALLDATASIZE
01 6000 PUSH1 00
03 80 DUP1
04 37 CALLDATACOPY
05 36 CALLDATASIZE
06 6000 PUSH1 00
08 6000 PUSH1 00
0A F0 CREATE

[address_deployed 0 0 calldatasize 0 0 0 0 0 0 0 0 0 0 0 0]

接下来的几条指令

1
2
3
4
5
6
7
0B      6000      PUSH1 00
0D 80 DUP1
0E 80 DUP1
0F 80 DUP1
10 80 DUP1

[0 0 0 0 0 address_deployed 0 0 calldatasize 0 0 0 0 0 0 0]

SWAP5 用于交换第 1 个和第 6 个堆栈项。

1
[address_deployed 0 0 0 0 0 0 0 calldatasize 0 0 0 0 0 0 0]

GAS 会返回仍然可用的总气体量

1
[gas address_deployed 0 0 0 0 0 0 0 calldatasize 0 0 0 0 0 0]

以上基本是为 CALL 做准备,如果调用失败,则操作码将 0 压入堆栈,否则为 1。

注意:如果被调用的账户没有代码,它会返回成功为true

堆栈参数依次为

  1. gas:要发送到子上下文以执行的 gas 量。
  2. address:要执行的上下文的帐户。
  3. value:wei 中的值发送到该地址
  4. argsOffset:内存中的字节偏移量,以字节数表示
  5. argsSize:使用先前指定的偏移量从内存中复制的字节大小
  6. retOffset:内存中的字节偏移量(以字节为单位),用于存储子上下文的返回数据。
  7. retSize:要复制的字节大小(返回数据的大小)。

而后续需要使 CALL 结果等于 00

1
2
3
4
14      6000      PUSH1 00
16 14 EQ
17 601B PUSH1 1B
19 57 JUMPI

因此我们需要使合约调用失败,即 REVERT

可以再次构造一个 REVERT 的合约

1
2
3
4
5
6
60FD  PUSH1  FD // FD 是 REVERT 
6000 PUSH1 00 // 这将被用作 MSTORE8 的偏移量,在内存中存储 REVERT
53 MSTORE8 // 将从偏移量 0 开始在内存中存储 `FD` 值(来自第一个 PUSH1
6001 PUSH1 01 // 必须返回 1 字节
6000 PUSH1 00 // 从 0 返回这些字节
F3 RETURN

即 0x60FD60005360016000F3

Puzzle 9

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
############
# Puzzle 9 #
############

00 36 CALLDATASIZE
01 6003 PUSH1 03
03 10 LT
04 6009 PUSH1 09
06 57 JUMPI
07 FD REVERT
08 FD REVERT
09 5B JUMPDEST
0A 34 CALLVALUE
0B 36 CALLDATASIZE
0C 02 MUL
0D 6008 PUSH1 08
0F 14 EQ
10 6014 PUSH1 14
12 57 JUMPI
13 FD REVERT
14 5B JUMPDEST
15 00 STOP

? Enter the value to send: (0)
? Enter the calldata:

CALLDATASIZE

PUSH1 03

1
[03 calldatasize 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

LT 对前两个堆栈值运行比较,以查看第一个堆栈元素是否小于第二个堆栈元素。如果计算结果为 true,则在堆栈上推送1,否则改为推送0。

由于这里我们需要得到一个为 1 的结果,所以 calldatasize > 3

随后运行以下指令

1
2
3
4
5
6
7
8
09      5B        JUMPDEST
0A 34 CALLVALUE
0B 36 CALLDATASIZE
0C 02 MUL
0D 6008 PUSH1 08
0F 14 EQ
10 6014 PUSH1 14
12 57 JUMPI

也就是说需要满足以下条件

1
2
CALLDATASIZE > 3
CALLVALUE * CALLDATASIZE == 8

这里我输入的是

1
2
? Enter the value to send: 2
? Enter the calldata: 0x00000001

Puzzle 10

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
#############
# Puzzle 10 #
#############

00 38 CODESIZE
01 34 CALLVALUE
02 90 SWAP1
03 11 GT
04 6008 PUSH1 08
06 57 JUMPI
07 FD REVERT
08 5B JUMPDEST
09 36 CALLDATASIZE
0A 610003 PUSH2 0003
0D 90 SWAP1
0E 06 MOD
0F 15 ISZERO
10 34 CALLVALUE
11 600A PUSH1 0A
13 01 ADD
14 57 JUMPI
15 FD REVERT
16 FD REVERT
17 FD REVERT
18 FD REVERT
19 5B JUMPDEST
1A 00 STOP

? Enter the value to send: (0)

CODESIZE

CALLVALUE

SWAP1

1
[1b callvalue 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

GT 用来计算大于

因此需要满足 callvalue < 1b,即十进制中的27

接下来执行以下指令

1
2
3
4
5
6
7
8
9
10
08      5B          JUMPDEST
09 36 CALLDATASIZE
0A 610003 PUSH2 0003
0D 90 SWAP1
0E 06 MOD
0F 15 ISZERO
10 34 CALLVALUE
11 600A PUSH1 0A
13 01 ADD
14 57 JUMPI

CALLDATASIZE

PUSH2 0003

SWAP1

1
[calldatasize 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

MOD 用于计算第一个堆栈元素和第二个堆栈元素的模,即 a % b

1
[calldatasize%3 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

ISZERO,如果堆栈上的最高值为 0 ,则该指令将推送 1 到堆栈上。如果任何其他数字位于堆栈顶部,则将推送 0 到堆栈。

1
[ISZERO(calldatasize%3) 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

CALLVALUE

PUSH1 0A

ADD 前两个值相加

然后需要跳转到 0x19 处,也就是说需要满足条件

1
2
calldatasize % 3 == 0
0x0a + callvalue = 0x19 // 10 + 15 = 25

所以我输入

1
2
? Enter the value to send: 15
? Enter the calldata: 0x000000

至此,所有 Puzzle 都完成了


Writeup | EVM-Puzzles
http://sissice.github.io/2022/09/30/EVM-Puzzles-wp/
作者
Sissice
发布于
2022年9月30日
许可协议