Post

One Shot

QnQSec 2025

One Shot

Don’t worry, you can only fail once in this challenge.

Author: .effie

Files:

This challenge got 14 solves, worth 448 points at the end of the competition.

Solve

This challenge was a really cool deep-dive into Python bytecode for me. I’d interacted with it before in challenges involving .pyc files, but mostly only as far as PyLingual could get me. Since this file used Python 3.13., there are a number of unsupported opcodes as it turns out, and PyLingual doesn’t give adequate decompilation…

First off, we get the file oneshot.exe, which I threw into Binary Ninja and almost immediately found some strings denoting that the binary was the result of a PyInstaller packing (stuff like PYINSTALLER_RESET_ENVIRONMENT, PYINSTALLER_STRICT_UNPACK_MODE, and Could not load PyInstaller's embedded PKG archive from the executable (%s) to name a few). Running it through the nice tool pyinstxtractor, we can get an output directory of oneshot.exe_extracted, which contains the pyc files packed into the binary. The source was a file called chall.pyc.

As with any pyc challenge, I opened up PyLingual and uploaded the pyc file. However, it crapped the bed almost immediately. This was the output Python code it gave me:

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
# Decompiled with PyLingual (https://pylingual.io)
# Internal filename: chall.py
# Bytecode version: 3.13.0rc3 (3571)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)

import winreg
import subprocess
import sys
import os
import random
import string
import json

class RegistryManager:
    def __init__(self):
        self.root = winreg.HKEY_CURRENT_USER
        self.base_path = 'Software\\OneShot'
        self._ensure_base_exists()

    def _ensure_base_exists(self):
        try:
            winreg.CreateKey(self.root, self.base_path)
        except:
            return None

    def set(self, name, value):
        try:
            key = winreg.CreateKey(self.root, self.base_path)
            try:
                if isinstance(value, int):
                    if (-2147483648) <= value <= 2147483647:
                        break
                    winreg.SetValueEx(key, name, 0, winreg.REG_DWORD, value & 4294967295)
                winreg.CloseKey(key)
        except WindowsError as e:
            raise WindowsError(f'Failed to set {name}: {e}')

    def u2s(self, v):
        if v > 2147483647:
            return v - 4294967296

    def get(self, name, default=None):
        try:
            key = winreg.OpenKey(self.root, self.base_path, 0, winreg.KEY_READ)
            try:
                value, regtype = winreg.QueryValueEx(key, name)
                if regtype == winreg.REG_DWORD:
                    value = self.u2s(value)
                return value
                winreg.CloseKey(key)
        except WindowsError:
            return default

    def delete(self, name):
        """Delete a registry value"""  # inserted
        try:
            key = winreg.OpenKey(self.root, self.base_path, 0, winreg.KEY_WRITE)
            winreg.DeleteValue(key, name)
            winreg.CloseKey(key)
        except:
            return None

def main():
    reg = RegistryManager()
    if reg.get('r8') == '.effie':
        print()
        print('You\'ve done it, my man.')
        print('The sun now glows a little brighter.')
        print('\"Thank you for playing.\"')
        print()
        destruction(reg, False)
    else:  # inserted
        if any((reg.get('r' + str(k)) is not None for k in range(8))):
            print()
            print('You return to the same room.')
            print('Niko is still there. Waiting.')
            print('\"You failed last time. We\'re stuck in a loop now.\"')
            print('\"Like Sisyphus pushing the boulder. Like Bill Murray in Groundhog Day.\"')
            print('\"We\'re trapped here. Forever. Rolling the same rock up the same hill.\"')
            print('\"There\'s no way out anymore. You had one chance.\"')
            print('[The program terminates. The loop is sealed.]')
            print()
            destruction(reg, False)
        else:  # inserted
            print()
            print('Niko is trapped in a puzzle.')
            print('Scratches on the walls read:')
            print()
            print('   THIS IS YOUR ONLY CHANCE.   ')
            print('ONE SHOT, OR STUCK HERE TRYING,')
            print('            FOREVER.           ')
            print()
            print('\"Please... you have to help me. I don\'t understand this.\"')
            print('Niko looks at you with desperate hope.')
            print()
            try:
                reg.set('r0', input('\"Do you know the way out?\"\n> ').strip().encode())
                if len(reg.get('r0'))!= 162:
                    destruction(reg)
    reg.set('r1', [reg.get('r0')[i * 27:(i + 1) * 27] for i in range(6)])
    else:  # inserted
        reg.set('r0', 0)
    pass
    if reg.get('r0') == 0:
        reg.set('r3', [121, 105, 121, 122, 103, 98, 70, 112, 41, 112, 102, 40, 105, 44, 112, 127, 47, 42, 112, 46, 105, 117, 112, 125, 46, 123, 41])
        reg.set('r2', 0)
        if reg.get('r2') < 27:
            reg.set('r1', reg.get('r1')[:reg.get('r0')] + reg.get('r1')[reg.get('r0')][:reg.get('r2')] + [bytes([reg.get('r1')[reg.get('r0')][reg.get('r2')][reg.get('r1')[reg.get('r0')][reg.get('r2') + 1:]]) + reg.get('r1')[reg.get('r0') + 1:])
            destruction(reg) if reg.get('r1')[0][reg.get('r2')]!= reg.get('r3')[reg.get('r2')] else None
        else:  # inserted
            reg.set('r2', reg.get('r2') + 1)
        reg.set('r0', reg.get('r0') + 1)
    except EOFError:
        destruction(reg)

def destruction(reg, mes=True):
    if mes:
        print()
        print('Niko\'s hope shatters.')
        print('\"No... we were so close...\"')
        print('Everything collapses. The loop seals shut.')
        print('Niko is trapped. Forever.')
        print('[System shutting down]')
        print()
    import time
    time.sleep(3)
    sys.exit(1)
if __name__ == '__main__':
    main()

This DOES give us a lot of useful information. For example, the total length of the flag is 162, we have a clear view of the RegistryManager class (shoutout to the way that this program runs. I thought it was really funny that it uses the Registry as registers). However, the program isn’t able to successfully decompile the whole program. It’s got a piece of the first check, but in reality there are 5 more checks after that still compiled in the pyc. PyLingual did, however, give us the disassembly of the program, so I had to do it manually.

Pyc Bytecode crash course

A pyc file is a compiled Python file. This means that the program has been converted to Python bytecode, which can be run much faster by the Python interpreter than from a source file. This just cuts out the middle-man that converts your Python source file into bytecode every time you run it. Just like with normal compilation, it’s possible to work backwards from that bytecode to a good guess about what the original Python looked like. Because of the way Python structures bytecode, you can do this really well by parsing the program with a Tree-esque algorithm. I didn’t know about this tool before the CTF, but there’s an open-source decompiler for Python bytecode written in C++ called pycdc that can give you a good idea of what this algorithm looks like (see ASTree.cpp).

You can disassemble the bytecode from Python using the dis module and dis.dis(obj), which can give you disassembly of the code. This kinda works most of the time, but it’s not very reliable, especially since it doesn’t auto-resolve symbols like PyLingual does for us. If you’re disassembling from a pyc file, you’ll need to skip the header, which is 16 bytes, to get to the raw code object.

The first couple lines of the program from dis.dis() gives us:

1
2
3
4
5
6
7
8
            LOAD_CONST               0
            LOAD_CONST               1
            IMPORT_NAME              0
            STORE_NAME               0
            LOAD_CONST               0
            LOAD_CONST               1 
            IMPORT_NAME              1
            STORE_NAME               1 

Whereas PyLingual is nice enough to give us some dereferences to those loads:

1
2
3
4
5
6
7
8
9
0 LOAD_CONST 0 (0)
2 LOAD_CONST 1 (None)
4 IMPORT_NAME 0 (winreg)
6 STORE_NAME 0 (winreg)

8 LOAD_CONST 0 (0)
10 LOAD_CONST 1 (None)
12 IMPORT_NAME 1 (subprocess)
14 STORE_NAME 1 (subprocess)

Without those, I wouldn’t have been able to solve the challenge, so thank goodness. I’ve learned some tips and tricks that I can talk about at the end, though, that would have helped me solve this even faster.

With the disassembly in hand, the next piece to learn is the general flow of bytecode. Each code object has a number of attributes, from which it pulls for things like the IMPORT_NAME opcode. These can be accessed by ‘unmarshaling’ the code (a ‘marshaled’ object is just the compiled bytecode) with the marshal library in Python. Some example code for this analysis is:

1
2
3
4
5
6
7
8
9
10
11
12
13
import marshal, dis

with open("oneshot.exe_extracted/chall.pyc", "rb") as f:
    f.read(16)  # skip header
    code = marshal.load(f)

print(code.co_varnames) # variable names
print(code.co_names) # global variables, attributes, and functions
print(code.co_consts) # all constants used in the bytecode
print(code.co_filename) # name of the file
print(code.co_name) # name of the function or method

dis.dis(code) # give disassembly

That’ll give us a lot of useful information, and PyLingual was nice enough to package it all up for us nicely with the disassembly. I just copy-pasted the whole marked-up version with symbols into a text file to analyze. Python bytecode works by adding objects to a ‘stack’ of sorts and then calling them or operating on them. This works using “Reverse Polish Notation” (shoutout CS111), which places both operands first and then the operator, like 1 1 + instead of 1 + 1. We’ll see that more with our code later.

An example of this is the disassembly for that first check of our flag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
444 LOAD_DEREF 1 (reg)
446 LOAD_ATTR 13 (NULL|self + set)

448 LOAD_CONST 26 ("r1")
450 LOAD_DEREF 1 (reg)
452 LOAD_ATTR 3 (NULL|self + get)
454 LOAD_CONST 26 ("r1")
456 CALL 1
458 LOAD_CONST 0 (None)
460 LOAD_DEREF 1 (reg)
462 LOAD_ATTR 3 (NULL|self + get)
464 LOAD_CONST 23 ("r0")
466 CALL 1
468 BINARY_SLICE

We start with LOAD_DEREF, which loads the reg object and then that’s followed by LOAD_ATTR, which loads the set function of that class. That just sits on the stack for now. Then we load the constant "r1", which also is added to the stack. Next we use LOAD_DEREF, and LOAD_ATTR again to get reg.get() onto the stack, then add "r1" again. At this point the stack looks like:

1
2
3
4
- reg.set
- "r1"
- reg.get
- "r1"

Now we CALL 1. This calls with one positional argument. Basically, we pop the first item off the stack to use as an argument ("r1"), and then we call the next item, reg.get. This runs reg.get("r1") and adds the result back onto the stack. Later, we will pop enough to call that original reg.set, but a lot more is added. I basically had to manually recreate the stack for each of the checks and then reverse that to get the correct result.

Part 1 & 2

These were the easy ones, both were just a series of operations. Both used the same format of editing your input in place to then check against a static list. Each section looked something like this in the disassembly:

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
430 LOAD_DEREF 1 (reg)
432 LOAD_ATTR 3 (NULL|self + get)
434 LOAD_CONST 33 ("r2")
436 CALL 1
438 LOAD_CONST 28 (27)
440 COMPARE_OP 18 (<)
442 POP_JUMP_IF_FALSE 469 (to 730)

...

692 LOAD_DEREF 1 (reg)
694 LOAD_ATTR 13 (NULL|self + set)
696 LOAD_CONST 33 ("r2")
698 LOAD_DEREF 1 (reg)
700 LOAD_ATTR 3 (NULL|self + get)
702 LOAD_CONST 33 ("r2")
704 CALL 1
706 LOAD_CONST 29 (1)
708 BINARY_OP 0 (+)
710 CALL 2
712 POP_TOP

714 LOAD_DEREF 1 (reg)
716 LOAD_ATTR 3 (NULL|self + get)
718 LOAD_CONST 33 ("r2")
720 CALL 1
722 LOAD_CONST 28 (27)
724 COMPARE_OP 18 (<)
726 POP_JUMP_IF_FALSE 3 (to 730)
728 JUMP_BACKWARD 469 (to 444)

There was a check at the beginning on r2 (basically i), which then determined the control flow. The idea of each check is it would make a change and then check it against the stored value. This meant that I could pretty easily throw it into z3 for these:

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
from z3 import *

SIZE_FLAG = 27*6

# Define unknown variable
r1 = [BitVec(f'v{i}', 8) for i in range(SIZE_FLAG)]

# Create solver
solver = Solver()

for i in range(SIZE_FLAG):
    solver.add(r1[i] >= 0x20, r1[i] <= 0x7e)  # Printable ASCII range

# Add conditions here
# first part of flag
r3 = [121, 105, 121, 122, 103, 98, 70, 112, 41, 112, 102, 40, 105, 44, 112, 127, 47, 42, 112, 46, 105, 117, 112, 125, 46, 123, 41]
for r2 in range(27):
    solver.add(r1[r2] ^ (r1[r2] >> 1) == r3[r2])

# second part of flag
r4 = [59, 60, 82, 46, 58, 91, 53, 75, 98, 75, 91, 94, 54, 67, 82, 50, 79, 57, 51, 47, 92, 91, 68, 56, 77, 57, 80]
for r2 in range(27):
    r3 = (r2 - 13) //2
    if r2 & 1 == 0:
        r3 *= -1
    solver.add(r1[27+r2] + r3 == r4[r2])

I ran each of these with a shorter SIZE_FLAG to test, and it worked: b'QNQSEC{_1_D0N7_U53_4NY_V4R14BL35_1N_MY_5CR1P75,_WH3R3V'. Just 4 more parts to go.

Part 3

This is where it started to get more complex. Luckily, ChatGPT is better at pattern recognition than I am. It was able to correctly pin each of these before I was able to (minus part 6), which was really handy.

The Python equivalent for this section looked something like this:

1
2
r5 = (r3 // ((len(r4) - 1) - r2)**69) % 69
r3 = r3 - (r5 * (((len(r4) - 1) - r2)**69))

This was super funky when I first saw it, but I tossed it to Chat and apparently this is what base conversion can look like in code. This meant that this code was base69, which was easy enough to decode:

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
def base69_to_bytes(digits):
    """
    Convert a list of base-69 digits to a byte sequence.

    digits: list of integers, each 0..68, most significant digit first
    returns: bytes
    """
    base = 69
    # Convert base-69 digits to a single integer
    value = 0
    for d in digits:
        if not (0 <= d < base):
            raise ValueError(f"Digit out of range: {d}")
        value = value * base + d

    # Determine how many bytes are needed to represent the number
    n_bytes = (value.bit_length() + 7) // 8
    return value.to_bytes(n_bytes, byteorder='big')


# third part of flag
r4 = [63, 43, 39, 12, 19, 36, 61, 6, 34, 50, 63, 61, 54, 53, 24, 37, 59, 44, 46, 66, 55, 57, 13, 59, 41, 11, 13, 15, 53, 23, 51, 22, 64, 0, 32]
b = base69_to_bytes(r4)
for i in range(len(b)):
    solver.add(r1[27*2 + i] == b[i])

This is useful in recognizing this for the future, since base-encodings are rather common.

Part 4

This section took a long string that looked something like:

1
r4 = b'\n========================\n  THE E†IGMA OF HEAVEN\n========================\n\nPRAISE THE LORD! The air conditioner †eeps them away it s'...

Notice the character. It took your input and checked each character by making sure that character existed at each increment of your character, basically like this:

1
2
3
for char in user_in:
    r3 += char
    assert r4[char] == ''

Which meant we could reverse it pretty easily with:

1
2
3
4
5
6
7
indices = [i for i, c in enumerate(r4) if c == ""]
print(len(indices))
print(indices)
r3 = 0
for r2 in range(27):
    solver.add(r1[27*3 + r2] + r3 - 16 == indices[r2])
    r3 = indices[r2]

Part 5

Part 5 was by far the hardest part. The disassembly was soooo long and looked like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
r4 = [1611216, 946813, 716893, 716989, 488303, 939977, 1175675, 1391467, 1391323]
for r2 in range(9):
    r6 = (r1[27*4 + r2*3] | (r1[27*4 + r2*3] << 16)) & 50331903
    r6 = (r6 | (r6 << 8)) & 50393103
    r6 = (r6 | (r6 << 4)) & 51130563
    r6 = (r6 | (r6 << 2)) & 153391689
    r5 = r6
    r6 = (r1[27*4 + r2*3 + 1] | (r1[27*4 + r2*3 + 1] << 16)) & 50331903
    r6 = (r6 | (r6 << 8)) & 50393103
    r6 = (r6 | (r6 << 4)) & 51130563
    r6 = (r6 | (r6 << 2)) & 153391689
    r5 = r5 | (r6 << 1)
    r6 = (r1[27*4 + r2*3 + 2] | (r1[27*4 + r2*3 + 2] << 16)) & 50331903
    r6 = (r6 | (r6 << 8)) & 50393103
    r6 = (r6 | (r6 << 4)) & 51130563
    r6 = (r6 | (r6 << 2)) & 153391689
    r5 = r5 | (r6 << 2)

A bunch of gibberish, yeah. I moved on to the sixth part when z3 couldn’t figure it out and then came back later. Once again, I threw it to ChatGPT and just said “what is this?” It came back with an actually useful answer again and said that it was something called “Morton Encoding” which is a way of encoding x,y,z coordinates into a single number. Apparently the shifts and ands make it so that it does “bit interleaving” and you just need to extract the right bits to pull out the original coordinates:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def compact3(v):
    v &= 0x09249249  # binary: 00001001001001001001001001001001
    v = (v | (v >> 2)) & 0x030c30c3
    v = (v | (v >> 4)) & 0x0300f00f
    v = (v | (v >> 8)) & 0xff
    return v

def unmorton3(m):
    x = compact3(m)
    y = compact3(m >> 1)
    z = compact3(m >> 2)
    return x, y, z

r4 = [1611216, 946813, 716893, 716989, 488303, 939977, 1175675, 1391467, 1391323]
fleg = []
for v in r4:
    for i in unmorton3(v):
        fleg.append(i)

for i in range(27):
    solver.add(r1[27*4 + i] == fleg[i])

Part 6

The last part was also really long. The Python code ended up looking like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
r2 = []
r3 = [(0, 26, 0)]
while len(r3) > 0:
    r4, r5, r6 = r3[-1]
    r7 = (r4 + r5) // 2
    if r4 >= r5:
        r3 = r3[:-1]
        continue
    if r6 == 0:
        r3 = r3[:-1] + [(r4, r5, 1)]
        r3 = r3 + [(r4, r7, 0)]
        continue
    if r6 == 1:
        r3 = r3[:-1] + [(r4, r5, 2)]
        r3 = r3 + [(r7 + 1, r5, 0)]
        continue
    if r6 == 2:
        if r1[r0][r5] < r1[r0][r7]:
            r1 = r1[:r0] + [r1[r0][:r7] + [r1[r0][r5]] + r1[r0][r7+1:r5] + [r1[r0][r7]] + r1[r0][r5 + 1:]] + r1[r0 + 1:]
            r2 = r2 + [(r7, r5)]
        r3 = r3[:-1] + [(r4, r5, 3)]
    if r6 == 3:
        r3 = r3[:-1] + [(r4, r5-1, 0)]

I recognized this as a sorting algorithm mostly from that r6 == 2 step where it swaps two bytes if one is less than the other. The other sections didn’t make a ton of sense, but the final checks were that the list matched this:

1
final = [30, 30, 32, 40, 42, 45, 45, 45, 46, 67, 70, 70, 70, 70, 73, 76, 76, 76, 76, 94, 109, 136, 136, 136, 136, 136, 181]

and then it checked that the swap record set by r6 == 2 using r2 = r2 + [(r7, r5)] to make sure the swaps happened in the right order.

This made it pretty easy to reverse:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
r2 = [...] # the list of swaps
r8 = [30, 30, 32, 40, 42, 45, 45, 45, 46, 67, 70, 70, 70, 70, 73, 76, 76, 76, 76, 94, 109, 136, 136, 136, 136, 136, 181]

r1_original = r8[:]
for i, j in reversed(r2):
    r1_original[i], r1_original[j] = r1_original[j], r1_original[i]
print(r1_original)
tmp = [int((i + 7) * (2/3)) for i in r1_original]

for i in range(len(tmp)):
    if tmp[i] < 0x30:
        tmp[i] = int(r1_original[i] * 2 -12)

print(bytes(tmp))

It also did an operation on the final part that matched r1[r0][r2] +/- ((r1[r0][r2] - 13) // 2), which I was able to undo with some basic algebra for the last part of the flag.

Conclusion

I thought this was a really neat challenge since it taught me a lot about Python bytecode. It ended up being solved by 14 teams by the end of the CTF. Another solve that I read about was someone had taken pycdc and just patched ASTree.cpp to add support for unsupported opcodes. I used that same approach for another challenge since this one, which worked really well and taught me even more about this.

It was also cool to see a code implementation of several algorithms like the sorting algorithm, morton encoding, and base69. Rev authors very rarely implement their own algorithms for stuff like that, so it’s useful to be familiar with what that looks like in code so that you can recognize it. Neat challenge overall, sad I couldn’t solve the revenge this time, but next pyc file I see I’ll be ready.

Solve: solve.py

Flag: QNQSEC{_1_D0N7_U53_4NY_V4R14BL35_1N_MY_5CR1P75,_WH3R3V3R_1_N33D_70_570R3_4_V4LU3_1_54V3_17_70_7H3_R36157RY,_4ND_R37R13V3_17_L473R_1N_7H3_C0MP0N3N7_7H47_N33D5_17_}

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