Fsvm : ecscCTF 2024

Sejal Koshta Lv2

I recently participated in ecscCTF 2024, mainly to try out the reversing challenges from the CTF. Subsequently, a two-part VM challenge caught my attention, called fsvm and fsvm2.

These two use almost the same instruction set and different code dumps for emulation. The aim was to obtain the disassembly as soon as possible.

A huge shoutout to my teammate, retr0ds , who collaborated with me to solve fsvm by helping me construct the emulator for the task.

Triage

When we run the binary along with the provided bytecode, we encounter a prompt asking for a flag.

fsvm

To understand what’s happening under the binary, I turned to IDA Pro for decompilation.

fsvm

The interpret function seems like the program’s heart, as it takes the bytecode file path as its argument.

Before interpret is called, a directory named regs is created to store the values of the VM’s registers and subsequenntly, this directory is deleted at the end of the program.

Within the interpret function lies a switch case structure, handling various opcodes and their corresponding operations. This is where the real magic happens

fsvm

The bytecode file utilizes only a few specific opcodes, as listed below:

1
['*', '.', '0', '2', '3', '=', '>', '?', '@', 'A', 'B', 'C', 'E', 'Q', 'R', 'S', 'T', 'U', 'V', 'W']

Some important opcodes among these are -

  • Opcode “Q”: Adds values from two registers and stores the result in a third
  • Opcode “?”: Concatenates values from two registers and stores the result in a third
  • Opcode “>”: Converts a register’s value to its character form
  • Opcode “T”: Negates the number in a specified register
  • Opcode “S”: Pops out the last character from a register’s value
  • Opcode “=”: Extracts the last byte of a register’s value and stores it in another register
  • Opcode “*”: Compares values in two registers and jumps to a specific instruction based on the comparison result

I explored two approaches to solve this challenge: building a complete VM emulator and utilizing a GDB script.

Method 1

This approach involved creating a custom emulator to replicate the behavior of the VM used in the challenge.
By examining the interpret function through decompilation, I discovered the presence of eight registers (reg0-reg7).

Emulator

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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def main():
exit_flag = 0
compared = []
flag = []
while True:
if exit_flag == 1:
print("EXIT FLAG WAS SET. PROGRAM EXITED SUCCESSFULLY")
print("Enter 1 to print registers and 2 to run emluator and 3 to restart :")
choice = int(input())
if choice == 1:
print("Register 0: ", reg[0])
print("Register 1: ", reg[1])
print("Register 2: ", reg[2])
print("Register 3: ", reg[3])
print("Register 4: ", reg[4])
print("Register 5: ", reg[5])
print("Register 6: ", reg[6])
print("Register 7: ", reg[7])
elif choice == 2:
fp = open('bytecode','rb')
bytecode = fp.read()
n = len(bytecode)
i = 0

reg = [""] * 8
print(bytecode.count(b"="))
while i < n:
opcode = bytecode[i]

match chr(opcode):
case "@":
reg[0] = "0"
print("CASE @ | reg[0] = 0")
i+=1
case "A":
print("CASE A | reg[1] = 0")
reg[1] = "0"
i+=1
case "B":
print("CASE B | reg[2] = 0")
reg[2] = "0"
i+=1

case "C":
print("CASE C | reg[3] = 0")
reg[3] = "0"
i+=1
case "D":
print("CASE D | reg[4] = 0")
reg[4] = "0"
i+=1
case "E":
print("CASE E | reg[5] = 0")
reg[5] = "0"
i+=1
case "F":
print("CASE F | reg[6] = 0")
reg[6] = "0"
i+=1
case "G":
print("CASE G | reg[7] = 0")
reg[7] = "0"
i+=1
case "R":
reg[bytecode[i+1]] = reg[bytecode[i+1]] + "1"
print(f"CASE R | reg[{bytecode[i+1]}] = reg[{bytecode[i+1]}] + 1 =========> {reg[bytecode[i+1]]}")
i+=2

case "Q":
A = reg[bytecode[i+1]]
B = reg[bytecode[i+2]]
result = int(A) + int(B)
reg[bytecode[i+3]] = str(result)
print(f"CASE Q | reg[{bytecode[i+3]}] = reg[{bytecode[i+1]}] + reg[{bytecode[i+2]}] ==========> reg[{bytecode[i+3]}] = {A} + {B} =======> {result} ")
i+=4
case "?":
A = reg[bytecode[i+1]]
B = reg[bytecode[i+2]]

compared.append(B)
result = str(A) + str(B)
reg[bytecode[i+3]] = result
print(f"CASE ? | reg[{bytecode[i+3]}] = reg[{bytecode[i+1]}] + reg[{bytecode[i+2]}] ==========> reg[{bytecode[i+3]}] = {A} + {B} =======> {result} ")
i+=4
case ">":
A = reg[bytecode[i+1]]
reg[bytecode[i+2]] = chr(int(A))
print(f"CASE > | reg[{bytecode[i+2]}] = chr(reg[{bytecode[i+1]}) ==========> reg[{bytecode[i+2]}] = chr({A}) =======> {chr(int(A))} ")
i+=3

case "T":
A = reg[bytecode[i+1]]
if "-" not in A:
reg[bytecode[i+1]] = str("-" + str(ord(A)))
print(f"CASE T | reg[{bytecode[i+1]}] = -reg[{bytecode[i+1]}] ==========> reg[{bytecode[i+1]}] = - + {A}")
i+=2
else:
print(f"CASE T | reg[{bytecode[i+1]}] = -reg[{bytecode[i+1]}] ==========> reg[{bytecode[i+1]}] = {A}")
i += 2

case "U":
A = reg[4]
print(f"CASE U | print reg[4] =============> ", A)
i+=1

case ",":
reg[0] = 0
print("CASE , | reg[0] = 0")
i+=1
case "-":
reg[1] = 0
print("CASE - | reg[1] = 0")
i+=1
case ".":
reg[2] = 0
print("CASE . | reg[2] = 0")
i+=1
case "/":
reg[3] = 0
print("CASE / | reg[3] = 0")
i+=1
case "0":
reg[4] = 0
print("CASE 0 | reg[4] = 0")
i+=1
case "1":
reg[5] = 0
print("CASE 1 | reg[5] = 0")
i+=1
case "2":
reg[6] = 0
print("CASE 2 | reg[6] = 0")
i+=1
case "3":
reg[7] = 0
print("CASE 3 | reg[7] = 0")
i+=1

case "V":
reg[0] = input("Case V | GET INPUT reg[0] =============> ")
i+=1
case "W":
exit_flag = 1
case "=":
A = reg[bytecode[i+1]]
reg[bytecode[i+2]] = A[-1]
print(f"CASE = | reg[{bytecode[i+2]}] = reg[{bytecode[i+1]}][-1] ==========> reg[{bytecode[i+2]}] = {A}[-1] =======> {A[-1]} ")
flag.append(reg[1])
i += 3
case "S":
A = reg[bytecode[i+1]]
size_A = len(A)
if size_A > 0:
A = list(A)
A.pop()
A = "".join(A)
reg[bytecode[i+1]] = A
print(f"CASE S | reg[{bytecode[i+1]}] = reg[{bytecode[i+1]}][:-1] ==========> reg[{bytecode[i+1]}] = {A}[:-1] =======> {A} ")
i += 2
case "*":
A = reg[5]
B = reg[6]
C = reg[7]
if B != C:
i = int(A)
print(f"CASE * | i = reg[5] =============> ", i)
else:
i+=1
print(f"CASE * | i = i+1 =============> ", i)

case _:
print("DEFAULT CASE")
print(flag)
break
elif choice == 3:
exit(0)


if __name__ == '__main__':
main()

Analysis of the disassembled code revealed a byte-by-byte comparison taking place.
Basically, the program extracts the last character from your input and compares it against a dynamically generated value stored in reg1 each time the “=” opcode is encountered.

To obtain the values that are being compared, we can simply store the value of reg1 read at opcode “=”.

Output:

1
2
DEFAULT CASE
['125', '-430', '52', '99', '55', '56', '0101', '52', '99', '109', '0118', '0121', '0115', '97', '0101', '0114', '0101', '0112', '0117', '0115', '123', '67', '83', '67', '69', '0110', '0101', '0112', '-419']

The remaining characters make the flag, with the exception of the two that spit out an unprintable character. At this point, all I did to get the flag was estimate those two characters.

Flag: openECSC{supereasyvmc4e87c4d}

Method 2

Since we know that on reading the value of reg1 during the execution of opcode “=” gives out the flag characters that are being checked, we can write a gdb script to set a breakpoint at that point and read the values straight from the reg1 file under regs directory created by the binary itself.

This method we utilise the debugging capabilities of GDB to narrow down the computation required to obtain the solution. Our understanding from the previous parts was that the “=” opcode extracts the last character from the flag input and compares it against a value stored in reg1 which is the correct flag byte.

By crafting a GDB script, we could set a breakpoint at the “=” opcode. Upon reaching this breakpoint, the script would directly read the value stored in reg1 within the regs directory created by the program itself.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import gdb
import os

flag = ''

gdb.execute("set pagination off")
gdb.execute("set disassembly-flavor intel")
gdb.execute("file vm")

address = 0x0000555555556ECA
gdb.execute(f"b *{address}")

gdb.execute("run bytecode < flag.txt")

for i in range(29):
x = open(r'regs/reg1')
l = x.read()
flag += (chr(int(str(l))))
x.close()
gdb.execute('c')

print(flag[::-1])
print("Done")

Output:

openECSC{supereasyvmc4e87c4d}


Ciao !!

  • Title: Fsvm : ecscCTF 2024
  • Author: Sejal Koshta
  • Created at : 2024-04-01 18:59:02
  • Updated at : 2024-08-12 22:42:10
  • Link: https://k1n0r4.github.io/2024/04/01/Fsvm-ecscCTF-2024/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments