idekCTF - lazy-vm
0x1 - 尝试
题目描述:flag.txt在程序运行目录下。
连接进入服务器,提示输入一些指令,随意尝试一些flag, help, exit之类的指令,有些会提示含有非法字符,有些则显示未知指令
1
2
3
4
5
6
7
8
9
== proof-of-work: disabled ==
Please enter your code:
flag
Found a forbidden character. Exit
== proof-of-work: disabled ==
Please enter your code:
exit
Unknown instruction at ip=0x0
[ + ]是不是应该使用 9A 2F这种十六进制的指令?尝试后并没有发现什么变化
下面利用提示信息来探索
0x2 - 指令探索
既然有非法字符,那我们就尝试爆破所有合法字符(当然目前还不知道指令读取的逻辑,可能是逐个字符读取,也可能是单词读取?)
用可见字符作为charset,编写python脚本交互,发现合法字符为:
1
bcdehijkmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-=!@#$%^&*()_+`~[]\;',./{}|:"<>?
可以发现flag这四个字符被过滤了,后续应该要进行绕过
接着是第二个报错信息:Unknown instruction 尝试将上述所有合法字符作为第一个指令与服务器交互,只有 i 指令返回了一些有趣的东西:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
== proof-of-work: disabled ==
Please enter your code:
i
============== REGISTER ==================
R0 = 0x0
R1 = 0x0
R2 = 0x0
R3 = 0x0
R4 = 0x0
R5 = 0x0
R6 = 0x0
R7 = 0x0
ip: 0x0
sp: 0x64
=================== STACK =====================
0x0
0x0
0x0
0x0
0x0
=================== MEMORY =====================
The pay is only $5. Too lazy to implement this
Unknown instruction at ip=0x1
显然i是一个调试指令,有趣的是最后出现了Unknown instructionat ip=0x1, 应该是换行符也被读取进去了。
到目前为止,只有一个打印指令,显然是没有任何操作空间的。有两种思路:
- 或许是通过单词读入?(可能性较低)但是通过实验,输入
i i(中间插入一个空格),发现空格也是非法的,而其他的换行符概率很小,暂时排除这种可能 - 如果是逐字符读取,或许是自己的charset局限于可打印字符导致的,使用脚本用
0x0-0xFF的字符,发现0x0-0x8中出现了特殊输出!1 2 3 4 5 6 7 8 9
(b'\x00', b'Thanks for playing\n') (b'\x01', b'Thanks for playing\n') (b'\x02', b'reg index out of range\n') (b'\x03', b'reg index out of range\n') (b'\x04', b'reg index out of range\n') (b'\x05', b'reg index out of range\n') (b'\x06', b'reg index out of range\n') (b'\x07', b'Thanks for playing\n') (b'\x08', b'Unknown instruction at ip=0x1\n')
转机出现,接下来就是研究这些指令的作用了
0x3 - 指令猜测
下面都用xxx来代替一个 i指令输出的结果
指令0x0
1
2
3
4
5
6
7
input: 69 00 69
output:
xxx (一个i的结果)
input: 00 69 00 69 69
output:
Thanks for playing
猜测0x0是停机指令
指令0x1
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
46
47
48
49
50
51
52
input: 69 01 69
output:
xxx
input: 69 01 00 69
output:
xxx
xxx
// 看样子似乎是跳过了后面的指令
input 69 01 02 69
output:
============== REGISTER ==================
R0 = 0x0
R1 = 0x0
R2 = 0x0
R3 = 0x0
R4 = 0x0
R5 = 0x0
R6 = 0x0
R7 = 0x0
ip: 0x0
sp: 0x64
=================== STACK =====================
0x0
0x0
0x0
0x0
0x0
=================== MEMORY =====================
The pay is only $5. Too lazy to implement this
============== REGISTER ==================
R0 = 0x0
R1 = 0x0
R2 = 0x0
R3 = 0x0
R4 = 0x0
R5 = 0x0
R6 = 0x0
R7 = 0x0
ip: 0x3
sp: 0x63
=================== STACK =====================
0x2
0x0
0x0
0x0
0x0
=================== MEMORY =====================
// 发现0x2进栈,
// 同时sp - 0x1 , 这也说明这里栈指针也是向下增长的
// push x
发现 0x1会跳过紧挨在后面的一个指令,应该是将其解释为操作数。而且发现了栈指针的变化,推测是push指令
指令0x2
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
input: 69 02 00 69
output:
============== REGISTER ==================
R0 = 0x0
R1 = 0x0
R2 = 0x0
R3 = 0x0
R4 = 0x0
R5 = 0x0
R6 = 0x0
R7 = 0x0
ip: 0x0
sp: 0x64
=================== STACK =====================
0x0
0x0
0x0
0x0
0x0
=================== MEMORY =====================
The pay is only $5. Too lazy to implement this
============== REGISTER ==================
R0 = 0x0
R1 = 0x0
R2 = 0x0
R3 = 0x0
R4 = 0x0
R5 = 0x0
R6 = 0x0
R7 = 0x0
ip: 0x3
sp: 0x65
=================== STACK =====================
0x0
0x0
0x0
0x0
0x0
=================== MEMORY =====================
The pay is only $5. Too lazy to implement this
// 可以发现sp从0x64变成了0x65, 怀疑是进行了pop,只不过目前寄存器的数值是0,看的不太明显
// pop reg
这里应该是进行pop,而且0x2后面的数字如果超过7会显示reg index out of range
指令0x3
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
input: 69 03 00 69
output:
Please enter your code:
============== REGISTER ==================
R0 = 0x0
R1 = 0x0
R2 = 0x0
R3 = 0x0
R4 = 0x0
R5 = 0x0
R6 = 0x0
R7 = 0x0
ip: 0x0
sp: 0x64
=================== STACK =====================
0x0
0x0
0x0
0x0
0x0
=================== MEMORY =====================
The pay is only $5. Too lazy to implement this
============== REGISTER ==================
R0 = 0x0
R1 = 0x0
R2 = 0x0
R3 = 0x0
R4 = 0x0
R5 = 0x0
R6 = 0x0
R7 = 0x0
ip: 0x3
sp: 0x63
=================== STACK =====================
0x0
0x0
0x0
0x0
0x0
=================== MEMORY =====================
The pay is only $5. Too lazy to implement this
Unknown instruction at ip=0x4
//同理,只不过sp从0x64变成了0x63
//应该是push reg
指令0x4
经过实验,操作数为一个寄存器,大概作用似乎是 mov $R0, reg
指令0x5
有一个操作数,将所有寄存器都填入一些数据,但是规律并不明显,有一些奇怪的事情:
- 确实会改变一些寄存器的值,比如
0x5 0x13会改变寄存器R1,0x5 0x74会改变R7 - 一些特殊的操作数也会提升
reg out of range,比如0x18,0xA2 - 改变的数值不是线性的,规律似乎很难捉摸
根据数据范围大胆猜测是将一个数字的前后4个比特各自对应一个寄存器,然后对其数据进行操作。这样来看,立刻就发现是XOR !
到这里正式出现了能够改变数据的指令,意味着能够对绕过flag的拦截!(只是目前还没有出现对文件交互的方法)
指令0x6
1
0x6 reg x
经试验,功能未知
指令0x7
1
0x7 x reg
经试验,功能未知
指令0x8
不需要任何操作数,但是对R0 - R7的寄存器都设置为一些随机值后提示:
1
Unknown syscall
意味着这是一个系统调用!通过测试,发现 R0 是系统调用号。 可是,如何传递参数呢?需要进一步测试,选择 open 系统调用,不断调整各个寄存器的值,发现R2的值决定是否输出Unknown flag,因此猜测从R1开始往后就是参数了
0x4 - 构造payload
本以为到这里应该就已经拿下了,使用xor进行绕过,将栈指针作为open的第一个参数。然而,最后返回的fd总是-1
比赛结束后看了其他大佬的wr, 发现系统调用的这个地址其实是在Mem部分,而不是Stack。 这才发现自己忽视了Mem这个元素,如果当时往这方面联想的话, 应该是能猜出指令0x6 0x7的作用的,它们的结构一个是instr r, x,一个是instr x, r,是将寄存器上的值加载到内存中/从内存中读取。 这样以来,有两个思路,一个是在栈上构造,然后load进Memory,使用系统调用;另一种是直接用read函数调用,将数据读取到内存中,更加短小精悍,这也是那位大佬的做法。
附上大佬的脚本:
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
from pwn import *
class VM_interpreter:
def __init__(self):
self.bytecode = []
def push_imm(self, imm):
self.bytecode.append("01")
self.bytecode.append(hex(imm)[2:].zfill(2))
def pop_reg(self, reg):
self.bytecode.append("02")
self.bytecode.append(hex(reg)[2:].zfill(2))
def push_reg(self, reg):
self.bytecode.append("03")
self.bytecode.append(hex(reg)[2:].zfill(2))
def add_reg(self, reg):
self.bytecode.append("04")
self.bytecode.append(hex(reg)[2:].zfill(2))
def xor_reg(self, reg):
self.bytecode.append("05")
self.bytecode.append(hex(reg)[2:].zfill(2))
def move_reg(self, src_reg, mem_addr):
self.bytecode.append("06")
self.bytecode.append(hex(src_reg)[2:].zfill(2))
self.bytecode.append(hex(mem_addr)[2:].zfill(2))
def load_reg(self, mem_addr, src_reg):
self.bytecode.append("07")
self.bytecode.append(hex(mem_addr)[2:].zfill(2))
self.bytecode.append(hex(src_reg)[2:].zfill(2))
def syscall(self):
self.bytecode.append("08")
def print_state(self):
self.bytecode.append("69")
self.bytecode.append("00")
def get_bytecode(self):
hex_string = "".join(self.bytecode)
return bytes.fromhex(hex_string)
def print_vm_state(output):
lines = output.decode().split('\n')
print("\n" + "="*50)
print("VM STATE:")
print("="*50)
section = ""
for line in lines:
if "REGISTER" in line or "STACK" in line or "MEMORY" in line:
section = line.strip("= ")
print(f"\n{section}:")
print("-"*50)
elif line.strip():
print(line)
print("="*50 + "\n")
def solve_flag():
vm = VM_interpreter()
FILENAME_BUF=0x10
FLAG_BUF=0x30
vm.push_imm(0) # syscall: read
vm.pop_reg(0)
vm.push_imm(0) # fd: 0 (stdin)
vm.pop_reg(1)
vm.push_imm(FILENAME_BUF) # buf address (0x30)
vm.pop_reg(2)
vm.push_imm(8) # count: 8 (to ignore the newline from sendline)
vm.pop_reg(3)
vm.syscall()
# --- Step 2: Open the file ---
# syscall: open(filename=FILENAME_BUF, flags=0)
vm.push_imm(2)
vm.pop_reg(0)
vm.push_imm(FILENAME_BUF)
vm.pop_reg(1)
vm.push_imm(0)
vm.pop_reg(2)
vm.syscall()
vm.move_reg(4, 0)
# syscall: read(fd=R4, buf=FLAG_BUF, count=40)
vm.push_imm(0) # syscall: read
vm.pop_reg(0)
vm.push_reg(4) # fd: the one we just got from open
vm.pop_reg(1)
vm.push_imm(FLAG_BUF) # buf for the flag content (0x10)
vm.pop_reg(2)
vm.push_imm(50) # count: read up to 40 bytes
vm.pop_reg(3)
vm.syscall()
# --- Step 4: Write the flag from FLAG_BUF to stdout ---
# syscall: write(fd=1, buf=FLAG_BUF, count=40)
vm.push_imm(1) # syscall: write
vm.pop_reg(0)
vm.push_imm(1) # fd: 1 (stdout)
vm.pop_reg(1)
vm.push_imm(FLAG_BUF) # buf containing the flag (0x10)
vm.pop_reg(2)
vm.push_imm(50) # count
vm.pop_reg(3)
vm.syscall()
return vm
a = remote("lazy-vm.chal.idek.team", 1337)
vm = solve_flag()
a.recvuntil(b"Please enter your code:\n")
print("Sending bytecode:", vm.get_bytecode())
a.sendline(vm.get_bytecode())
# Send "/flag" as input for syscall 0 to read
print("Sending '/flag' as input")
a.sendline(b"flag.txt")
print_vm_state(a.recv())
a.close()
# 0: unknown
# 1: push imm
# 2: pop reg
# 3: push reg
# 4: add reg (adds reg to r0)
# 5: xor reg (xors r0 with reg)
# 6: unknown
# 7: unknown imm reg
# 8: syscall r0: sysnum
# 0x69: print state
0x5 - 反思总结
这一题很好玩,虽然最后很可惜没有解出来,但还是让我找回了逆向的乐趣
在刚开始一无所有的时候,我会尽力探索,有了很多发现。
然而随着逐渐深入,取得了一定进展时,我却太过于沉迷已有的东西,没有了刚开始的那种探索欲,当思路受限时,便放弃了——尤其是以为自己距离胜利很近,但却发现这条路行不通时,尤其容易给人一种别无他法的错觉
然而,真的是这样吗?自己尝试了open调用失败后,其实完全可以尝试read指令,这样以来如果发现栈上没有数据,或许也能启发自己想起来Mem这个机制。
另外,这一题我在一开始没有还原出指令的时候,直接编辑二进制进行尝试;然而在弄清楚一部分指令后,由于思维惯性,忘记了脚本这个东西,还是手动构造,尽显基米精神,导致浪费很多时间