Post

One to Two and kufvm

sknbCTF 2025

One to Two and kufvm

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}

This post is licensed under CC BY 4.0 by the author.