One to Two and kufvm
sknbCTF 2025
Side Channeling VM Challs for Fun and Profit
One to Two
For this challenge, we were given main and program.txt. The main program depicted a simple VM, and the respective program was filled with characters like: ↠↠↠↠↠↠↠↠↠⇣⇣⇠⇠⇣⇠⇣⇠⇣⇠⇣⇠⇣⇠⇣⇠⇣⇠⇣⇠⇣⇠⇣⇠⇣⇠⇣⇠⇣⇠⇣.
The program itself had a stack, stack pointer, and then line and column variables (which I had to name) that walked through the program in a sort of maze-like pattern:
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
while (true)
if (line s>= 0 && line s< lines && col s>= 0
&& col s< wcslen(*((sx.q(line) << 3) + &data)))
int32_t rax_33 = *(*((sx.q(line) << 3) + &data) + (sx.q(col) << 2))
if (rax_33 == 0x2e)
return 0
if (rax_33 s>= 0x2e && rax_33 s<= 0x21e3 && rax_33 s>= 0x2194
&& rax_33 - 0x2194 u<= 0x4f)
switch (rax_33)
case 0x2194
if (stack[sx.q(sp)] != 0)
col -= 1
continue
else
col += 1
continue
case 0x2195
if (stack[sx.q(sp)] != 0)
line -= 1
continue
else
line += 1
continue
case 0x219e
sp -= 1
col -= 1
if (sp s>= 0)
continue
fwrite(buf: "stack underflow\n", size: 1, count: 0x10,
fp: stderr)
return 1
case 0x219f
stack[sx.q(sp)] += 1
line -= 1
continue
case 0x21a0
sp += 1
col += 1
if (sp s> 0xff)
break
continue
case 0x21a1
stack[sx.q(sp)] -= 1
line += 1
continue
case 0x21d1
int32_t var_20_1 = getchar()
if (var_20_1 == 0xffffffff || var_20_1 == 0xa)
var_20_1 = 0xff
stack[sx.q(sp)] = var_20_1
line -= 1
continue
case 0x21d3
putchar(c: stack[sx.q(sp)])
fflush(fp: __TMC_END__)
line += 1
continue
case 0x21e0
col -= 1
continue
case 0x21e1
line -= 1
continue
case 0x21e2
col += 1
continue
case 0x21e3
line += 1
continue
puts(str: "Invalid inst")
continue
Recreating this in Python was quite simple, and I added debug prints as it ran to show me some of what was happening. Like I expected, it seemed to be just flowing through the maze in a specific order depending on what inputs were given. Only problem was I couldn’t for the life of me figure out what the VM was doing to actually check the flag. I ran it a number of times and just didn’t get anywhere.
As I was running it, I noticed that I could only get a couple characters into the program before my Python code returned with the 0x2e case… which confused me at first but then I had an idea. I was inputting stuv for the start, but it would make it through s then die after t. I went back to the CTF rules (as this was the first challenge I tried) and saw that the flag format also started with s!! So I tried sk and it made it to the input for the 3rd character! That gave me enough of an idea that I could side channel it.
I packaged all of my cases into a function called flag_guess(guess) where I could pass in a guess for the flag and it would run through the VM of the program for it. I edited two of the cases to edit the return:
1
2
3
4
5
6
7
8
9
10
case 0x21d1:
if flag_idx >= len(guess):
return 0
stack[sp] = ord(guess[flag_idx])
flag_idx += 1
# print(stack)
line -= 1
# ...
case 0x2e:
return -1
This covers the two cases I want to cover: an early return without parsing the whole flag guess, and it passing each of the characters I passed. With this, it was enough to try a side channel! Here was the loop I made:
1
2
3
4
5
6
7
8
9
10
11
12
flag = ''
while True:
for i in string.printable:
test_flag = flag + i
result = flag_guess(test_flag)
if result == 0:
flag += i
if i == '}':
print(f"Flag found: {flag}")
exit(0)
print(f"Found character: {i} -> Current flag: {flag}")
break
And I got the flag (with this script)!!!
Flag: sknb{n4rr0w_4rr0w_50rr0w}
Still not sure what the VM did, but it was cool that I could side channel it like that. I was hopped up on the momentum, and when I started the other VM challenge in the CTF, I wondered if I could do another side channel….
kufvm
For this challenge, they gave us a whole docker container, which confused me because it was also a normal VM. We had main, which had the VM code, and chall.bin, which had the program. I reversed the VM again, and it was even simpler this time with only 7 opcodes. And yet again, I had no idea what the logic of the VM was doing. But I wanted to try the side channel. This time, I looked to perf, a linux tool that can give performance details on programs being run. This allows for really easy instruction counting.
For some basic testing, I used the flag format to run through just the first couple characters and see if there was a correlation.
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
macen@fedora:~/ctf/sknbctf/kufvm$ perf stat -e instructions ./main chall.bin
flag:s
incorrect!
Performance counter stats for './main chall.bin':
<not counted> cpu_atom/instructions/u
143,564 cpu_core/instructions/u
7.101202901 seconds time elapsed
0.000000000 seconds user
0.002492000 seconds sys
macen@fedora:~/ctf/sknbctf/kufvm$ perf stat -e instructions ./main chall.bin
flag:t
incorrect!
Performance counter stats for './main chall.bin':
<not counted> cpu_atom/instructions/u
143,493 cpu_core/instructions/u
0.597947868 seconds time elapsed
0.000000000 seconds user
0.002047000 seconds sys
As seen from even just the first character, it seems like it’s taking about 100 extra instructions per character…. which looks promising!
That was a lot of output, so I narrowed it to just ``. But in continuing those efforts I ran into <not counted for the CPU instructions…
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
macen@fedora:~/ctf/sknbctf/kufvm$ perf stat -e cpu_core/instructions/u ./main chall.bin
flag:sk
incorrect!
Performance counter stats for './main chall.bin':
<not counted> cpu_core/instructions/u
Some events weren't counted. Try disabling the NMI watchdog:
echo 0 > /proc/sys/kernel/nmi_watchdog
perf stat ...
echo 1 > /proc/sys/kernel/nmi_watchdog
macen@fedora:~/ctf/sknbctf/kufvm$ perf stat -e cpu_core/instructions/u ./main chall.bin
flag:skn
incorrect!
Performance counter stats for './main chall.bin':
145,914 cpu_core/instructions/u
macen@fedora:~/ctf/sknbctf/kufvm$ perf stat -e cpu_core/instructions/u ./main chall.bin
flag:sknf
incorrect!
Performance counter stats for './main chall.bin':
<not counted> cpu_core/instructions/u
Some events weren't counted. Try disabling the NMI watchdog:
echo 0 > /proc/sys/kernel/nmi_watchdog
perf stat ...
echo 1 > /proc/sys/kernel/nmi_watchdog
macen@fedora:~/ctf/sknbctf/kufvm$ perf stat -e cpu_core/instructions/u ./main chall.bin
flag:sknb
incorrect!
Performance counter stats for './main chall.bin':
147,304 cpu_core/instructions/u
I snipped the output a little bit, but it seems like every once in a while it just wasn’t able to count the CPU instructions run. I figured some sort of optimiation was being added that swapped it between the CPUs at some point, which is pretty easy to mitigate. It gave the suggestion, but I didn’t really want to deal with messing with the watchdog and pinning the process worked great. The updated command is below:
1
taskset -c 0 perf stat -x, -e cpu_core/instructions/u -- ./main chall.bin
I just needed to automate it now, which can be done with Python subprocess. I just took a similar approach to last time where I increment a character at a time, but this time I needed to run each of the characters and take the highest instruction count.
Here was the final program:
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
import subprocess, string
def count_instructions(flag_guess: str):
cmd = "taskset -c 0 perf stat -x, -e cpu_core/instructions/u -- ./main chall.bin".split()
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
# text=True
)
out, err = proc.communicate(flag_guess.encode() + b'\n')
# perf sends statistics to stderr
# this made it work, still not sure why the last iteration ran into trouble
if out.split(b':')[-1].strip() != b'incorrect!':
return 10000000000000 # program quit, invalid flag
insns = int(err.decode().split(',')[0])
return insns
flag = ''
while len(flag) == 0 or flag[-1] != '}':
counts = []
for i in string.printable:
counts.append((i, count_instructions(flag + i)))
best_candidate = sorted(counts, key=lambda x: x[1], reverse=True)[0]
flag += best_candidate[0]
print(f'Best candidate so far: {flag}')
This was a really cool challenge to side-channel. The -x option was added to concisely print the instruction count output, and I had to add the correct check to make sure it picked up the closing }, but it worked!!!
Flag: sknb{This_is_rev_flag!_congrats!!!!_167a14}