# QnQSec CTF 2025: OneShotRevenge [rev] writeup Writeup author: fsharp ## Introduction I had some free time during the weekend and played QnQSec CTF 2025 with [`DeadSec`](https://deadsec.team). I solved several reverse engineering challenges, including `OneShotRevenge`, which had just 3 solves when the contest ended. We were the second team to solve it! ## Problem description Author: .effie Patch the restrict mode, change some opcode, exploit audit logs, you name it! (File provided: Revengeee.exe) ![qnqsec_ctf_2025_oneshotrevenge_solved](https://hackmd.io/_uploads/rkBiQ2d0lx.png) ## Initial analysis I'll try running the program to get a feel of what it does. ``` C:\Users\fsharp\Desktop\qnqsec\OneShotRevenge>Revengeee.exe Niko is trapped in a puzzle. Scratches on the walls read: THIS IS YOUR ONLY CHANCE. ONE SHOT, OR STUCK HERE TRYING, FOREVER. "Please... you have to help me. I don't understand this." Niko looks at you with desperate hope. "Do you know the way out?" > idontknow Niko's hope shatters. "No... we were so close..." Everything collapses. The loop seals shut. Niko is trapped. Forever. [System shutting down] ``` If I try to run the program after inputting an incorrect flag once: ``` C:\Users\fsharp\Desktop\qnqsec\OneShotRevenge>Revengeee.exe You return to the same room. Niko is still there. Waiting. "You failed last time. We're stuck in a loop now." "Like Sisyphus pushing the boulder. Like Bill Murray in Groundhog Day." "We're trapped here. Forever. Rolling the same rock up the same hill." "There's no way out anymore. You had one chance." [The program terminates. The loop is sealed.] ``` It doesn't look like the program will accept flags anymore, so I've missed the one shot I had... or have I? Let's begin reverse engineering it. One of the first things I do when reverse engineering is to figure out what kind of program I'm looking at. My go-to tool for that is [Detect It Easy](https://github.com/horsicq/Detect-It-Easy). Using it on this challenge, I get: ![revengeee_exe_detect_it_easy](https://hackmd.io/_uploads/ry4sMJtAge.png) The Windows executable is packed by [PyInstaller](https://pyinstaller.org/en/stable/). According to its manual, the packed executable is a Python application and its dependencies all put into one file. This is so that the application's users do not need to install any Python-related software to run it, such as a Python interpreter and the packages the program depends on. There are ways to unpack the contents of such programs. My favourite way is to use [pyinstxtractor-ng](https://github.com/pyinstxtractor/pyinstxtractor-ng), where you run a Python script on the PyInstaller program you want unpacked and you don't need to worry about the Python version you're using to do so: ``` C:\Users\fsharp\Desktop\qnqsec\OneShotRevenge>python pyinstxtractor_ng.py Revengeee.exe [+] Processing Revengeee.exe [+] Pyinstaller version: 2.1+ [+] Python version: 3.13 [+] Length of package: 7170611 bytes [+] Found 22 files in CArchive [+] Beginning extraction...please standby [+] Possible entry point: pyiboot01_bootstrap.pyc [+] Possible entry point: pyi_rth_inspect.pyc [+] Possible entry point: revenge.pyc [+] Found 113 files in PYZ archive [+] Successfully extracted pyinstaller archive: Revengeee.exe You can now use a python decompiler on the pyc files within the extracted directory ``` Several files are extracted into a `Revengeee.exe_extracted` folder, including [compiled Python (PYC) files](https://docs.python.org/3/library/py_compile.html). PYCs contain the bytecode of their corresponding Python scripts, and [such files are created and packed by PyInstaller into the program](https://pyinstaller.org/en/stable/advanced-topics.html#python-imports-in-a-bundled-app). From the pyinstxtractor-ng output above, a possible entry point of the program is `revenge.pyc`, which looks interesting and relevant to the challenge. So, the next step is to decompile that PYC. PYCs are dependent on the Python version used, with new bytecode introduced in newer versions. The Python version used for this challenge is 3.13, and this can be an issue. Most of the PYC decompilers I've encountered only work reliably for certain Python versions, and they usually do not work from Python 3.13 onwards due to lacking support for newer bytecode. In such cases, I will first try uploading the PYC into the [PyLingual](https://pylingual.io/) website, which [uses natural language processing techniques](https://doi.org/10.1109/SP61157.2025.00052) to perform decompilation: ![revenge_pyc_pylingual_decompilation](https://hackmd.io/_uploads/Bkl2xVh_Agg.png) PyLingual successfully decompiled the PYC, which means it generated Python code that compiles to the same bytecode as in the PYC file. However, there's a problem: the program has been obfuscated with [Pyarmor](https://github.com/dashingsoft/pyarmor), and it's impossible to tell what it does just by looking at its Python code. So, it needs to be deobfuscated. Deobfuscating Pyarmor programs can be quite involved depending on whether a free trial, basic, or pro version of Pyarmor was used, [with paid versions offering more obfuscation capabilities](https://pyarmor.readthedocs.io/en/latest/licenses.html). ## Deobfuscating the Pyarmored PYC To proceed further, I need to find a way to deobfuscate the Pyarmored `revenge.pyc`. After searching online for Pyarmor deobfuscation tools, I stumbled upon the [Pyarmor Static Unpack One-Shot Tool](https://github.com/Lil-House/Pyarmor-Static-Unpack-1shot) on GitHub. There are a few interesting tidbits from its project description: ``` This project aims to convert armored data back to bytecode assembly and (experimentally) source code. Warning: Disassembly results are accurate, but decompiled code can be incomplete and incorrect. You don't need to execute the encrypted script. We decrypt them using the same algorithm as pyarmor_runtime. This is useful when the scripts cannot be trusted. ``` Sounds promising! The tool claims it can decrypt the armored data without having to run the Pyarmor script, which might be malicious. It also looks like it can disassemble and attempt to decompile the decrypted PYC. Following the tool's usage instructions, I downloaded [its latest release for Windows](https://github.com/Lil-House/Pyarmor-Static-Unpack-1shot/releases/download/v0.2.1/pyarmor-1shot-v0.2.1-windows-x86_64.zip), extracted the zip archive, and ran the tool as follows: ``` C:\Users\fsharp\Desktop\qnqsec\OneShotRevenge>python pyarmor-1shot-v0.2.1-windows-x86_64\oneshot\shot.py --output-dir output Revengeee.exe_extracted ____ ____ ( __ ) ( __ ) | |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~| | | | ____ _ ___ _ _ | | | | | _ \ _ _ __ _ _ __ _ _ __ ___ _ _ / / __|| |_ ___ | |_ | | | | | |_) | || |/ _` | '__| ' ` \ / _ \| '_| | \__ \| ' \ / _ \| __| | | | | | __/| || | (_| | | | || || | (_) | | | |__) | || | (_) | |_ | | | | |_| \_, |\__,_|_| |_||_||_|\___/|_| |_|___/|_||_|\___/ \__| | | | | |__/ | | |__|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|__| (____) v0.2.1 (____) For technology exchange only. Use at your own risk. GitHub: https://github.com/Lil-House/Pyarmor-Static-Unpack-1shot INFO 2025-10-24 18:53:12,939 Found data in binary: revenge.pyc INFO 2025-10-24 18:53:12,987 Found new runtime: 000000 (Revengeee.exe_extracted\pyarmor_runtime_000000\pyarmor_runtime.pyd) ======================== Pyarmor Runtime (Trial) Information: Product: non-profits AES key: ab738f35ffce23b13ae73d5a2c17a896 Mix string AES nonce: 692e6e6f6e2d70726f666974 ======================== INFO 2025-10-24 18:53:13,558 Using executable: pyarmor-1shot.exe INFO 2025-10-24 18:53:13,558 Decrypting: 000000 (revenge.pyc) ERROR 2025-10-24 18:53:13,618 PYCDC: Wrong block type 0 for END_FOR (revenge.pyc) ``` The tool's output messages tell me good news and bad news. The good news is that this challenge used a free trial Pyarmor version, which means I only had to deal with the simplest obfuscation techniques Pyarmor uses. Yay! The bad news is that there's an error from [the pycdc decompiler](https://github.com/zrax/pycdc) indicating decompilation failed due to not fully supporting [the `END_FOR` opcode introduced in Python 3.12](https://docs.python.org/3/library/dis.html#opcode-END_FOR). Oh no! Even though the PYC couldn't be decompiled, the tool should've outputted the deobfuscated PYC's bytecode disassembly, so not all hope is lost. Going into the tool's output directory, I see `revenge.pyc.1shot.cdc.py`, which contains the decompilation results: ```python # File: revenge.pyc.1shot.seq (Python 3.13) # Source generated by Pyarmor-Static-Unpack-1shot (v0.2.1), powered by Decompyle++ (pycdc) # Note: Decompiled code can be incomplete and incorrect. # Please also check the correct and complete disassembly file: revenge.pyc.1shot.das '__pyarmor_enter_2273__(...)' __assert_armored__ = '__pyarmor_assert_2272__' import winreg import subprocess import sys import os import random import string import json class RegistryManager: __firstlineno__ = 9 '__pyarmor_enter_2276__(...)' __assert_armored__ = '__pyarmor_assert_2275__' def __init__(self): '__pyarmor_enter_2279__(...)' __assert_armored__ = '__pyarmor_assert_2278__' self.root = winreg.HKEY_CURRENT_USER self.base_path = 'Software\\OneShot' None(self._ensure_base_exists) '__pyarmor_exit_2280__(...)' def _ensure_base_exists(self): '__pyarmor_enter_2282__(...)' __assert_armored__ = '__pyarmor_assert_2281__' winreg.CreateKey(self.root, self.base_path) '__pyarmor_exit_2283__(...)' def set(self, name, value): '__pyarmor_enter_2285__(...)' __assert_armored__ = '__pyarmor_assert_2284__' _var_var_0 = winreg.CreateKey(self.root, self.base_path) if None(isinstance, value, int): if <= -2147483648, value or -2147483648, value <= 2147483647: pass else: winreg.SetValueEx(_var_var_0, name, 0, winreg.REG_DWORD, value & 0xFFFFFFFF) winreg.SetValueEx(_var_var_0, name, 0, winreg.REG_SZ, None(hex, value)) winreg.CloseKey(_var_var_0) '__pyarmor_exit_2286__(...)' def u2s(self, v): '__pyarmor_enter_2288__(...)' __assert_armored__ = '__pyarmor_assert_2287__' if v > 2147483647: pass else: None(v - 0x100000000) return None '__pyarmor_exit_2289__(...)' return v - 0x100000000 def get(self, name): '__pyarmor_enter_2291__(...)' __assert_armored__ = '__pyarmor_assert_2290__' _var_var_0 = winreg.OpenKey(self.root, self.base_path, 0, winreg.KEY_READ) # WARNING: Decompyle incomplete def delete(self, name): '''Delete a registry value''' '__pyarmor_enter_2294__(...)' __assert_armored__ = '__pyarmor_assert_2293__' _var_var_0 = winreg.OpenKey(self.root, self.base_path, 0, winreg.KEY_WRITE) winreg.DeleteValue(_var_var_0, name) winreg.CloseKey(_var_var_0) '__pyarmor_exit_2295__(...)' __static_attributes__ = ('base_path', 'root') '__pyarmor_exit_2277__(...)' def main(): '__pyarmor_enter_2297__(...)' __assert_armored__ = '__pyarmor_assert_2296__' _var_var_7 = None(RegistryManager) if None(_var_var_7.get, 'r8') == '.effie': None(print) None(print, "You've done it, my man.") None(print, 'The sun now glows a little brighter.') None(print, '"Thank you for playing."') None(print) None(destruction, _var_var_7, False) if None(any, (lambda .0: pass# WARNING: Decompyle incomplete )(None(range, 8))): None(print) None(print, 'You return to the same room.') None(print, 'Niko is still there. Waiting.') None(print, '"You failed last time. We\'re stuck in a loop now."') None(print, '"Like Sisyphus pushing the boulder. Like Bill Murray in Groundhog Day."') None(print, '"We\'re trapped here. Forever. Rolling the same rock up the same hill."') None(print, '"There\'s no way out anymore. You had one chance."') None(print, '[The program terminates. The loop is sealed.]') None(print) None(destruction, _var_var_7, False) None(print) None(print, 'Niko is trapped in a puzzle.') None(print, 'Scratches on the walls read:') None(print) None(print, ' THIS IS YOUR ONLY CHANCE. ') None(print, 'ONE SHOT, OR STUCK HERE TRYING,') None(print, ' FOREVER. ') None(print) None(print, '"Please... you have to help me. I don\'t understand this."') None(print, 'Niko looks at you with desperate hope.') None(print) None(_var_var_7.set, 'r0', None(None(None(input, '"Do you know the way out?"\n> ').strip).encode)) if None(len, None(_var_var_7.get, 'r0')) != 162: pass None(destruction, _var_var_7) # WARNING: Decompyle incomplete def child_process(parent_pid, exe_path): ''' Child process: wait a bit, then attempt to delete parent exe. Called as: python script.py --child <parent_pid> <exe_path> ''' '__pyarmor_enter_2300__(...)' __assert_armored__ = '__pyarmor_assert_2299__' import time as _var_var_11 None(_var_var_11.sleep, 2) for _var_var_12 in None(range, 5): os.remove(exe_path) None(range, 5) '__pyarmor_exit_2301__(...)' def destruction(reg, mes): '__pyarmor_enter_2303__(...)' __assert_armored__ = '__pyarmor_assert_2302__' if mes: None(print) None(print, "Niko's hope shatters.") None(print, '"No... we were so close..."') None(print, 'Everything collapses. The loop seals shut.') None(print, 'Niko is trapped. Forever.') None(print, '[System shutting down]') None(print) import time as _var_var_11 None(_var_var_11.sleep, 3) _var_var_13 = sys.executable if None(getattr, sys, 'frozen', False) else sys.argv[0] subprocess.Popen([ sys.executable, __file__, '--child', None(str, parent_pid), _var_var_13]) os.remove(_var_var_13) sys.exit(1) '__pyarmor_exit_2304__(...)' if __name__ == '__main__': if len(sys.argv) > 1 and sys.argv[1] == '--child': child_process(int(sys.argv[2]), sys.argv[3]) else: main() '__pyarmor_exit_2274__(...)' ``` I can see part of the program's logic from the Python script. The `RegistryManager` class looks like it creates the [Windows registry](https://en.wikipedia.org/wiki/Windows_Registry) key `HKEY_CURRENT_USER\Software\OneShot`, then stores different kinds of values under that key, including 32-bit integers (`winreg.REG_DWORD`) and null-terminated strings (`winreg.REG_SZ`). In the `main()` function, if the `r8` registry value is `.effie`, a few congratulatory messages are printed. If some other unknown condition is met, the messages I get from missing my one shot are printed. Otherwise, the messages I get from running the program for the first time are printed, my inputted flag is stored into the `r0` registry value, and there's a check on whether it is 162 characters long. I can't see what happens afterwards though. Aside from that, the `destruction` function can be called in the program's main logic, which prints the messages I get for inputting an incorrect flag if a boolean is set, sleeps for 3 seconds, and spawns a child process that tries to delete the program. As the pycdc error message, the note in the Python script, and the various `WARNING: Decompyle incomplete` comments imply, the decompilation is both incomplete and incorrect. The comment below the note says the correct and complete disassembly file can be found in `revenge.pyc.1shot.das`, so let's check that out: ``` # File: revenge.pyc.1shot.seq (Python 3.13) # Disassembly generated by Pyarmor-Static-Unpack-1shot (v0.2.1), powered by pycdas # ================================ # Pyarmor notes: # - Pyarmor bytecode and code objects match standard Python, but special calls to Pyarmor runtime functions exist. # - Calls on strings are not mistakes but markers, which are processed by Pyarmor at runtime. # # Decompilation guidance (without runtime): # 1. Ignore encrypted bytes after `#`; use only the string before `#`. # 2. Remove `"__pyarmor_enter_xxx__"(b"<COAddr>...")` and `"__pyarmor_leave_xxx__"(b"<COAddr>...")` (prologue/epilogue). # 3. `"__pyarmor_assert_xxx__"(A)` is not a real assert statement. # - If `A` is a name or readable string: replace with `A`. # - If `A` is `(X, "Y")`: replace with `X.Y`. # - If `A` is `(X, "Y", Z)`: replace with `X.Y = Z`. # - Otherwise: choose the most reasonable replacement. # 4. `"__pyarmor_bcc_xxx__"(...)` indicates native code; function body is not available. Add a comment. # ================================ [Code] File Name: <frozen revenge> ... [Disassembly] 0 NOP 2 NOP 4 LOAD_CONST 2: '__pyarmor_enter_2279__' 6 PUSH_NULL 8 LOAD_CONST 3: b'<COAddr>\x01\x00\x00\x1an\x00\x00\x00\x00\x00\x00\x00' 10 BUILD_TUPLE 1 12 CALL_FUNCTION_EX 0 14 POP_TOP 16 RESUME 0 18 LOAD_CONST 1: '__pyarmor_assert_2278__' 20 STORE_FAST 1: __assert_armored__ 22 NOP 24 NOP 26 NOP 28 LOAD_GLOBAL 0: winreg 38 LOAD_ATTR 2: HKEY_CURRENT_USER 58 LOAD_FAST 0: self 60 STORE_ATTR 2: root 70 LOAD_CONST 4: 'Software\\OneShot' 72 LOAD_FAST 0: self 74 STORE_ATTR 3: base_path 84 LOAD_FAST 0: self 86 LOAD_ATTR 9: _ensure_base_exists 106 CALL 0 114 POP_TOP 116 LOAD_CONST 0: None 118 NOP 120 NOP 122 NOP 124 JUMP_FORWARD 19 (to 164) 132 POP_TOP 134 RETURN_CONST 0: None 136 PUSH_EXC_INFO 138 LOAD_CONST 5: '__pyarmor_exit_2280__' 140 NOP 142 PUSH_NULL 144 LOAD_CONST 3: b'<COAddr>\x01\x00\x00\x1an\x00\x00\x00\x00\x00\x00\x00' 146 CALL 1 154 POP_TOP 156 RERAISE 0 158 COPY 3 160 POP_EXCEPT 162 RERAISE 1 164 LOAD_CONST 5: '__pyarmor_exit_2280__' 166 PUSH_NULL 168 LOAD_CONST 3: b'<COAddr>\x01\x00\x00\x1an\x00\x00\x00\x00\x00\x00\x00' 170 BUILD_TUPLE 1 172 CALL_FUNCTION_EX 0 174 POP_TOP 176 RETURN_VALUE ... ``` I see some strings from the disassembly that are present in the incorrect decompilation. It looks like I can use the disassembly to help me figure out what the program's doing! ## Simplifying the PYC disassembly The disassembly file I got contains 3336 lines and is somewhat big. However, I've got a good guess on what the `RegistryManager` class does, and I only want to know what other checks on the flag there are after its length check, so I don't actually need to read all 3336 lines. Let's try to simplify the disassembly. The file contains different sections: ``` [Code] [Constants] [Disassembly] [Exception Table] [Locals+Names] [Names] ``` `[Code]` sections describe each part of the Python script the program came from. For example, each function defined (`destruction`, `child_process` etc.) has its own `[Code]` section. The bytecode disassembly is inside the `[Disassembly]` sections. Modules, local variables, constants and the like from the four other types of sections are already included and referenced in the bytecode disassembly. If I kept just the `[Disassembly]` section of the `main()` function and ignored everything else, only 1344 lines will remain, and that should be enough for me to work off of. The start of the simplified disassembly looks like this: ``` 0 MAKE_CELL 3: _var_var_7 2 NOP 4 NOP 6 LOAD_CONST 2: '__pyarmor_enter_2297__' 8 PUSH_NULL 10 LOAD_CONST 3: b'<COAddr>\x03\x00\x00\x1cD\x1f\x00\x00\x00\x00\x00\x00' 12 BUILD_TUPLE 1 14 CALL_FUNCTION_EX 0 16 POP_TOP 18 RESUME 0 20 LOAD_CONST 1: '__pyarmor_assert_2296__' 22 STORE_FAST 0: __assert_armored__ 24 NOP 26 NOP 28 NOP 30 LOAD_GLOBAL 1: NULL + RegistryManager 40 CALL 0 48 STORE_DEREF 3: _var_var_7 50 LOAD_DEREF 3: _var_var_7 52 LOAD_ATTR 3: get 72 LOAD_CONST 4: 'r8' 74 CALL 1 82 LOAD_CONST 5: '.effie' 84 COMPARE_OP 88 (==) 88 POP_JUMP_IF_FALSE 65 (to 220) 92 LOAD_GLOBAL 5: NULL + print 102 CALL 0 110 POP_TOP 112 LOAD_GLOBAL 5: NULL + print 122 LOAD_CONST 6: "You've done it, my man." 124 CALL 1 132 POP_TOP 134 LOAD_GLOBAL 5: NULL + print 144 LOAD_CONST 7: 'The sun now glows a little brighter.' 146 CALL 1 154 POP_TOP 156 LOAD_GLOBAL 5: NULL + print 166 LOAD_CONST 8: '"Thank you for playing."' 168 CALL 1 176 POP_TOP 178 LOAD_GLOBAL 5: NULL + print 188 CALL 0 196 POP_TOP 198 LOAD_GLOBAL 7: NULL + destruction 208 LOAD_DEREF 3: _var_var_7 210 LOAD_CONST 9: False ... 746 LOAD_DEREF 3: _var_var_7 748 LOAD_ATTR 13: set 768 LOAD_CONST 26: 'r0' 770 LOAD_GLOBAL 15: NULL + input 780 LOAD_CONST 27: '"Do you know the way out?"\n> ' 782 CALL 1 790 LOAD_ATTR 17: strip 810 CALL 0 818 LOAD_ATTR 19: encode 838 CALL 0 846 CALL 2 854 POP_TOP 856 LOAD_GLOBAL 21: NULL + len 866 LOAD_DEREF 3: _var_var_7 868 LOAD_ATTR 3: get 888 LOAD_CONST 26: 'r0' 890 CALL 1 898 CALL 1 906 LOAD_CONST 28: 162 908 COMPARE_OP 119 (!=) 912 POP_JUMP_IF_FALSE 11 (to 936) 916 LOAD_GLOBAL 7: NULL + destruction 926 LOAD_DEREF 3: _var_var_7 928 CALL 1 936 POP_TOP 938 LOAD_DEREF 3: _var_var_7 ... ``` The first hundred or so lines contain disassembly that match the partial decompilation of the `main()` function I got! The start contains disassembly for initializing a `RegistryManager` instance in the variable `_var_var_7` and printing the congratulatory messages. At opcode positions 746 - 936, the inputted flag is stored in `r0`, and if it isn't 162 characters long, `destruction` is called. For simplicity, I'll replace `_var_var_7` with `reg` so that it's easier to remember that the variable manages registry values. With the disassembly, I can now look at what happens beyond the flag length check. The [`POP_JUMP_IF_FALSE` opcode](https://docs.python.org/3/library/dis.html#opcode-POP_JUMP_IF_FALSE) at position 912 tells us that if the flag length is 162, then the virtual machine will jump to position 936 and execute opcodes from there. Let's see what happens from that position then: ``` 938 LOAD_DEREF 3: reg 940 LOAD_ATTR 13: set 960 LOAD_CONST 29: 'r1' 962 LOAD_GLOBAL 11: NULL + range 972 LOAD_CONST 30: 6 974 CALL 1 982 GET_ITER 984 LOAD_FAST_AND_CLEAR 1: _var_var_8 986 SWAP 2 988 BUILD_LIST 0 990 SWAP 2 992 GET_ITER 994 FOR_ITER 32 (to 1060) 998 STORE_FAST 1: _var_var_8 1000 LOAD_DEREF 3: reg 1002 LOAD_ATTR 3: get 1022 LOAD_CONST 26: 'r0' 1024 CALL 1 1032 LOAD_FAST 1: _var_var_8 1034 LOAD_CONST 31: 27 1036 BINARY_OP 5 (*) 1040 LOAD_FAST 1: _var_var_8 1042 LOAD_CONST 32: 1 1044 BINARY_OP 0 (+) 1048 LOAD_CONST 31: 27 1050 BINARY_OP 5 (*) 1054 BINARY_SLICE 1056 LIST_APPEND 2 1058 JUMP_BACKWARD 34 (to 992) 1062 END_FOR 1064 POP_TOP 1066 SWAP 2 1068 STORE_FAST 1: _var_var_8 1070 CALL 2 1078 POP_TOP 1080 LOAD_DEREF 3: reg 1082 LOAD_ATTR 13: set 1102 LOAD_CONST 26: 'r0' 1104 LOAD_CONST 33: 0 1106 CALL 2 1114 POP_TOP ... ``` Without partial decompilation to refer to, this feels hard to reverse. But if one understands how Python bytecode is executed and how values are stored, then this shouldn't be that bad. ## Understanding PYC disassembly Python bytecode is executed by the [Python Virtual Machine](https://leanpub.com/insidethepythonvirtualmachine/read#leanpub-auto-the-view-from-30000ft), which is stack-based. This means opcodes can push values onto and pop values from the stack. The very first opcode after the length check is `LOAD_DEREF reg` (position 938). What does [Python's documentation](https://docs.python.org/3/library/dis.html) say about it? ``` LOAD_DEREF(i) Loads the cell contained in slot i of the “fast locals” storage. Pushes a reference to the object the cell contains on the stack. ``` In this case, `LOAD_DEREF reg` pushes a reference of the registry manager onto the stack. If I represented the stack as a Python list (`[]`) and the rightmost element in the list represents the top of the stack, then after executing that opcode, I'd get `[reg]`. I can put that as a comment in the disassembly: ``` 938 LOAD_DEREF 3: reg # [reg] ``` The next opcode is `LOAD_ATTR set`. In this case, the topmost stack element (`reg`) is replaced by the `set` method from `reg`: ``` 940 LOAD_ATTR 13: set # [reg.set] ``` After that, `LOAD_CONST 'r1'` pushes the string `'r1'` onto the stack: ``` 960 LOAD_CONST 29: 'r1' # [reg.set, 'r1'] ``` Similarly, `LOAD_GLOBAL NULL + range` pushes `range` (it technically pushes `NULL` too, but that can be ignored), and `LOAD_CONST 6` pushes the integer `6`. `CALL 1` applies a callable function on the topmost argument in the stack. We now have: ``` 962 LOAD_GLOBAL 11: NULL + range # [reg.set, 'r1', range] 972 LOAD_CONST 30: 6 # [reg.set, 'r1', range, 6] 974 CALL 1 # [reg.set, 'r1', range(6)] ``` Then there's an interesting sequence of opcodes from `GET_ITER` in position 982 to `SWAP` in position 990. A [StackOverflow answer](https://stackoverflow.com/a/77494995) indicates this is related to list comprehension and illustrates that in the following example: ``` >>> import dis >>> dis.dis("[True for _ in ()]") 0 0 RESUME 0 1 2 LOAD_CONST 0 (()) 4 GET_ITER 6 LOAD_FAST_AND_CLEAR 0 (_) 8 SWAP 2 10 BUILD_LIST 0 12 SWAP 2 >> 14 FOR_ITER 4 (to 26) 18 STORE_FAST 0 (_) 20 LOAD_CONST 1 (True) 22 LIST_APPEND 2 24 JUMP_BACKWARD 6 (to 14) >> 26 END_FOR 28 SWAP 2 30 STORE_FAST 0 (_) 32 RETURN_VALUE >> 34 SWAP 2 36 POP_TOP 38 SWAP 2 40 STORE_FAST 0 (_) 42 RERAISE 0 ExceptionTable: 10 to 26 -> 34 [2] ``` In our case, each value in the iterable `range(6)` is being used and stored into the variable `_var_var_8`. Let's rename that variable to `i`. Since there is a `FOR_ITER` and a corresponding `END_ITER`, I'd guess there is a `for` loop iterating over `range(6)`. So, I can indent the disassembly between `FOR_ITER` and `END_FOR` to indicate a `for` loop. Using this intuition, I can annotate more of the disassembly as follows: ``` 982 GET_ITER 984 LOAD_FAST_AND_CLEAR 1: i 986 SWAP 2 988 BUILD_LIST 0 # Push an empty list for list comprehension (let's call that some_list) 990 SWAP 2 992 GET_ITER # for i in range(6): 994 FOR_ITER 32 (to 1060) 998 STORE_FAST 1: i 1000 LOAD_DEREF 3: reg # [reg.set, 'r1', ..., some_list, reg] 1002 LOAD_ATTR 3: get # [reg.set, 'r1', ..., some_list, reg.get] 1022 LOAD_CONST 26: 'r0' # [reg.set, 'r1', ..., some_list, reg.get, 'r0'] 1024 CALL 1 # [reg.set, 'r1', ..., some_list, reg.get('r0') --> r0] 1032 LOAD_FAST 1: i # [reg.set, 'r1', ..., some_list, r0, i] 1034 LOAD_CONST 31: 27 # [reg.set, 'r1', ..., some_list, r0, i, 27] 1036 BINARY_OP 5 (*) # [reg.set, 'r1', ..., some_list, r0, i * 27] 1040 LOAD_FAST 1: i # [reg.set, 'r1', ..., some_list, r0, i * 27, i] 1042 LOAD_CONST 32: 1 # [reg.set, 'r1', ..., some_list, r0, i * 27, i, 1] 1044 BINARY_OP 0 (+) # [reg.set, 'r1', ..., some_list, r0, i * 27, i + 1] 1048 LOAD_CONST 31: 27 # [reg.set, 'r1', ..., some_list, r0, i * 27, i + 1, 27] 1050 BINARY_OP 5 (*) # [reg.set, 'r1', ..., some_list, r0, i * 27, (i + 1) * 27] 1054 BINARY_SLICE # [reg.set, 'r1', ..., some_list, r0[i * 27 : (i + 1) * 27]] 1056 LIST_APPEND 2 # some_list.append(r0[i * 27 : (i + 1) * 27]) 1058 JUMP_BACKWARD 34 (to 992) 1062 END_FOR 1064 POP_TOP 1066 SWAP 2 1068 STORE_FAST 1: i # [reg.set, 'r1', some_list] 1070 CALL 2 # [reg.set('r1', some_list) --> r1 = some_list] 1078 POP_TOP # [] ``` So the disassembly from position 938 to 1078 is just doing: ```python r1 = [r0[i * 27 : (i + 1) * 27] for i in range(6)] ``` Remember, from the partial decompilation, that our inputted flag is initially stored in the registry value `r0`. This line of code splits our flag into 6 parts of 27 (`162 // 6`) characters and stores that into `r1`. Very cool! ## Solving for each part of the flag At this point, it's apparent that the program uses the Windows registry in place of Python variables. So, `reg.get(r<x>)` statements can be simplified into a variable called `r<x>`, and `reg.set(r<x>, <val>)` statements can be turned into `r<x> = <val>`. Let's continue from where we left off. ``` 1080 LOAD_DEREF 3: reg 1082 LOAD_ATTR 13: set 1102 LOAD_CONST 26: 'r0' 1104 LOAD_CONST 33: 0 1106 CALL 2 # r0 = 0 1114 POP_TOP 1116 NOP 1118 LOAD_DEREF 3: reg 1120 LOAD_ATTR 3: get 1140 LOAD_CONST 26: 'r0' 1142 CALL 1 1150 LOAD_CONST 33: 0 # [r0, 0] 1152 COMPARE_OP 88 (==) # [r0 == 0] 1156 POP_JUMP_IF_FALSE 848 (to 2856) # r0 != 0? ... ``` `r0` is set to `0` and is then checked if it matches `0`. If it doesn't, the VM jumps to position 2856 instead. What's located there? ``` 2858 LOAD_DEREF 3: reg 2860 LOAD_ATTR 3: get 2880 LOAD_CONST 26: 'r0' 2882 CALL 1 2890 LOAD_CONST 32: 1 2892 COMPARE_OP 88 (==) 2896 POP_JUMP_IF_FALSE 621 (to 4142) # r0 != 1? ... ``` This time, if `r0` is not `1`, another jump is taken. Following all the jumps, I see: ``` 4144 LOAD_DEREF 3: reg 4146 LOAD_ATTR 3: get 4166 LOAD_CONST 26: 'r0' 4168 CALL 1 4176 LOAD_CONST 48: 2 4178 COMPARE_OP 88 (==) 4182 POP_JUMP_IF_FALSE 529 (to 5244) # r0 != 2? ... 5246 LOAD_DEREF 3: reg 5248 LOAD_ATTR 3: get 5268 LOAD_CONST 26: 'r0' 5270 CALL 1 5278 LOAD_CONST 43: 3 5280 COMPARE_OP 88 (==) 5284 POP_JUMP_IF_FALSE 342 (to 5972) # r0 != 3? ... 5974 LOAD_DEREF 3: reg 5976 LOAD_ATTR 3: get 5996 LOAD_CONST 26: 'r0' 5998 CALL 1 6006 LOAD_CONST 39: 4 6008 COMPARE_OP 88 (==) 6012 POP_JUMP_IF_FALSE 477 (to 6970) # r0 != 4? ... 6972 LOAD_DEREF 3: reg 6974 LOAD_ATTR 3: get 6994 LOAD_CONST 26: 'r0' 6996 CALL 1 7004 LOAD_CONST 58: 5 7006 COMPARE_OP 88 (==) 7010 POP_JUMP_IF_FALSE 330 (to 7674) # r0 != 5? ... 7674 JUMP_FORWARD 104 (to 7884) 7676 LOAD_DEREF 3: reg 7678 LOAD_ATTR 3: get 7698 LOAD_CONST 26: 'r0' 7700 CALL 1 7708 LOAD_CONST 30: 6 7710 COMPARE_OP 88 (==) 7714 POP_JUMP_IF_FALSE 72 (to 7860) # r0 != 6? 7718 LOAD_GLOBAL 5: NULL + print 7728 CALL 0 7736 POP_TOP 7738 LOAD_GLOBAL 5: NULL + print 7748 LOAD_CONST 62: "Niko's eyes light up. The puzzle dissolves." 7750 CALL 1 7758 POP_TOP 7760 LOAD_GLOBAL 5: NULL + print 7770 LOAD_CONST 63: '"We did it! We actually made it!"' 7772 CALL 1 7780 POP_TOP 7782 LOAD_GLOBAL 5: NULL + print 7792 LOAD_CONST 64: 'The way forward opens. Niko is free.' 7794 CALL 1 7802 POP_TOP 7804 LOAD_GLOBAL 5: NULL + print 7814 CALL 0 7822 POP_TOP 7824 LOAD_DEREF 3: reg 7826 LOAD_ATTR 13: set 7846 LOAD_CONST 4: 'r8' 7848 LOAD_CONST 5: '.effie' 7850 CALL 2 7858 POP_TOP 7860 JUMP_FORWARD 14 (to 7890) ... ``` There are checks for integers `2` to `6` as well. If `r0 == 6`, messages indicating a puzzle is solved get printed. So, the goal seems to be to get `r0` to `6`! Let's check what gets executed if `r0 == 5`: ``` 7016 LOAD_DEREF 3: reg 7018 LOAD_ATTR 13: set 7038 LOAD_CONST 37: 'r4' 7040 BUILD_LIST 0 7042 LOAD_CONST 59: (33, 20, 23, 94, 127, 14, 26, 30, 58, 27, 84, 68, 27, 51, 78, 72, 80, 62, 2, 71, 92, 65, 64, 43, 89, 87, 83) 7044 LIST_EXTEND 1 7046 CALL 2 # r4 = the list above 7054 POP_TOP 7056 LOAD_DEREF 3: reg 7058 LOAD_ATTR 13: set 7078 LOAD_CONST 34: 'r3' 7080 BUILD_LIST 0 7082 LOAD_CONST 60: (73, 32, 97, 109, 32, 104, 111, 112, 101, 108, 101, 115, 115, 108, 121, 32, 99, 97, 112, 116, 105, 118, 97, 116, 101, 100, 46) 7084 LIST_EXTEND 1 7086 CALL 2 # r3 = the list above 7094 POP_TOP 7096 LOAD_DEREF 3: reg 7098 LOAD_ATTR 13: set 7118 LOAD_CONST 36: 'r2' 7120 LOAD_CONST 48: 2 7122 CALL 2 # r2 = 2 7130 POP_TOP 7132 LOAD_DEREF 3: reg 7134 LOAD_ATTR 3: get 7154 LOAD_CONST 36: 'r2' 7156 CALL 1 7164 LOAD_CONST 61: 110 7166 COMPARE_OP 18 (<) 7170 POP_JUMP_IF_FALSE 214 (to 7600) # if r2 >= 110, go to position 7600 7174 LOAD_DEREF 3: reg 7176 LOAD_ATTR 3: get 7196 LOAD_CONST 29: 'r1' 7198 CALL 1 # [r1] 7206 LOAD_DEREF 3: reg 7208 LOAD_ATTR 3: get 7228 LOAD_CONST 26: 'r0' 7230 CALL 1 # [r1, r0] 7238 BINARY_SUBSCR # [r1[r0]] 7242 LOAD_DEREF 3: reg 7244 LOAD_ATTR 3: get 7264 LOAD_CONST 36: 'r2' 7266 CALL 1 # [r1[r0], r2] 7274 LOAD_CONST 48: 2 7276 BINARY_OP 10 (-) # [r1[r0], r2 - 2] 7280 LOAD_CONST 39: 4 7282 BINARY_OP 2 (//) # [r1[r0], (r2 - 2) // 4] 7286 BINARY_SUBSCR # [r1[r0][(r2 - 2) // 4]] 7290 LOAD_DEREF 3: reg 7292 LOAD_ATTR 3: get 7312 LOAD_CONST 37: 'r4' 7314 CALL 1 # [r1[r0][(r2 - 2) // 4], r4] 7322 LOAD_DEREF 3: reg 7324 LOAD_ATTR 3: get 7344 LOAD_CONST 36: 'r2' 7346 CALL 1 # [r1[r0][(r2 - 2) // 4], r4, r2] 7354 LOAD_CONST 48: 2 7356 BINARY_OP 10 (-) # [r1[r0][(r2 - 2) // 4], r4, r2 - 2] 7360 LOAD_CONST 39: 4 7362 BINARY_OP 2 (//) # [r1[r0][(r2 - 2) // 4], r4, (r2 - 2) // 4] 7366 BINARY_SUBSCR # [r1[r0][(r2 - 2) // 4], r4[(r2 - 2) // 4]] 7370 BINARY_OP 12 (^) # [r1[r0][(r2 - 2) // 4] ^ r4[(r2 - 2) // 4]] 7374 LOAD_DEREF 3: reg 7376 LOAD_ATTR 3: get 7396 LOAD_CONST 34: 'r3' 7398 CALL 1 # [r1[r0][(r2 - 2) // 4] ^ r4[(r2 - 2) // 4], r3] 7406 LOAD_DEREF 3: reg 7408 LOAD_ATTR 3: get 7428 LOAD_CONST 36: 'r2' 7430 CALL 1 # [r1[r0][(r2 - 2) // 4] ^ r4[(r2 - 2) // 4], r3, r2] 7438 LOAD_CONST 48: 2 7440 BINARY_OP 10 (-) # [r1[r0][(r2 - 2) // 4] ^ r4[(r2 - 2) // 4], r3, r2 - 2] 7444 LOAD_CONST 39: 4 7446 BINARY_OP 2 (//) # [r1[r0][(r2 - 2) // 4] ^ r4[(r2 - 2) // 4], r3, (r2 - 2) // 4] 7450 BINARY_SUBSCR # [r1[r0][(r2 - 2) // 4] ^ r4[(r2 - 2) // 4], r3[(r2 - 2) // 4]] 7454 COMPARE_OP 119 (!=) 7458 POP_JUMP_IF_FALSE 11 (to 7482) # if r1[r0][(r2 - 2) // 4] ^ r4[(r2 - 2) // 4] == r3[(r2 - 2) // 4], go to position 7482 7462 LOAD_GLOBAL 7: NULL + destruction 7472 LOAD_DEREF 3: reg 7474 CALL 1 # destruction(reg) 7482 POP_TOP # [] 7484 LOAD_DEREF 3: reg 7486 LOAD_ATTR 13: set 7506 LOAD_CONST 36: 'r2' # [reg.set, 'r2'] 7508 LOAD_DEREF 3: reg 7510 LOAD_ATTR 3: get 7530 LOAD_CONST 36: 'r2' 7532 CALL 1 # [reg.set, 'r2', r2] 7540 LOAD_CONST 39: 4 7542 BINARY_OP 0 (+) # [reg.set, 'r2', r2 + 4] 7546 CALL 2 # r2 = r2 + 4 7554 POP_TOP 7556 LOAD_DEREF 3: reg 7558 LOAD_ATTR 3: get 7578 LOAD_CONST 36: 'r2' 7580 CALL 1 # [r2] 7588 LOAD_CONST 61: 110 7590 COMPARE_OP 18 (<) # [r2 < 110] 7594 POP_JUMP_IF_FALSE 2 (to 7600) # If r2 >= 110, go to position 7600 7598 JUMP_BACKWARD 214 (to 7172) # Otherwise, go to position 7172 7602 LOAD_DEREF 3: reg 7604 LOAD_ATTR 13: set 7624 LOAD_CONST 26: 'r0' # [reg.set, 'r0'] 7626 LOAD_DEREF 3: reg 7628 LOAD_ATTR 3: get 7648 LOAD_CONST 26: 'r0' 7650 CALL 1 # [reg.set, 'r0', r0] 7658 LOAD_CONST 32: 1 7660 BINARY_OP 0 (+) # [reg.set, 'r0', r0 + 1] 7664 CALL 2 # r0 = r0 + 1 7672 POP_TOP # [] 7674 JUMP_FORWARD 104 (to 7884) # Go to position 7884 ... 7884 JUMP_BACKWARD 3386 (to 1116) # Go to position 1116 ... ``` Here, `r1[r0]` is being referred to. Since `r0` is `5` in this case, `r1[r0]` represents the last part of the flag. If a condition is met, `destruction(reg)` is called and the challenge is 'failed.' If conditions are met so that the `destruction` function is never called, then `r0` is incremented by `1`, and the VM jumps to position 7884, which contains an opcode that just makes it jump to position 1116: ``` 1116 NOP 1118 LOAD_DEREF 3: reg 1120 LOAD_ATTR 3: get 1140 LOAD_CONST 26: 'r0' 1142 CALL 1 1150 LOAD_CONST 33: 0 # [r0, 0] 1152 COMPARE_OP 88 (==) # [r0 == 0] 1156 POP_JUMP_IF_FALSE 848 (to 2856) # r0 != 0? ... ``` Since execution goes back to the start of the `r0` value checks, this means each of the 6 flag parts has its own checker and they are checked sequentially! In fact, all 6 checkers essentially have the same structure, with each checking the corresponding flag part differently. I will now go through the 6 parts in order. For each part, I'll show its Python equivalent and the commented disassembly for part 1 so that you can see how I came up with its Python code. (No commented disassembly for other parts... darn it, free plan character limits...) ### Part 1 <details> <summary>Commented disassembly</summary> 1162 LOAD_DEREF 3: reg 1164 LOAD_ATTR 13: set 1184 LOAD_CONST 34: 'r3' 1186 BUILD_LIST 0 1188 LOAD_CONST 35: (213, 102, 213, 85, 64, 198, 119, 243, 85, 179, 64, 85, 230, 183, 115, 179, 85, 179, 85, 179, 247, 179, 230, 102, 179, 102, 85) 1190 LIST_EXTEND 1 1192 CALL 2 # r3 = the list above 1200 POP_TOP 1202 LOAD_DEREF 3: reg 1204 LOAD_ATTR 13: set 1224 LOAD_CONST 36: 'r2' 1226 LOAD_CONST 33: 0 1228 CALL 2 # r2 = 0 1236 POP_TOP 1238 LOAD_DEREF 3: reg 1240 LOAD_ATTR 3: get 1260 LOAD_CONST 36: 'r2' 1262 CALL 1 # [r2] 1270 LOAD_CONST 31: 27 # [r2, 27] 1272 COMPARE_OP 18 (<) # [r2 < 27] 1276 POP_JUMP_IF_FALSE 750 (to 2780) # while r2 < 27: 1282 LOAD_DEREF 3: reg 1284 LOAD_ATTR 13: set 1304 LOAD_CONST 37: 'r4' # [reg.set, 'r4'] 1306 LOAD_DEREF 3: reg 1308 LOAD_ATTR 3: get 1328 LOAD_CONST 29: 'r1' 1330 CALL 1 # [reg.set, 'r4', r1] 1338 LOAD_DEREF 3: reg 1340 LOAD_ATTR 3: get 1360 LOAD_CONST 26: 'r0' 1362 CALL 1 # [reg.set, 'r4', r1, r0] 1370 BINARY_SUBSCR # [reg.set, 'r4', r1[r0]] 1374 LOAD_DEREF 3: reg 1376 LOAD_ATTR 3: get 1396 LOAD_CONST 36: 'r2' 1398 CALL 1 # [reg.set, 'r4', r1[r0], r2] 1406 BINARY_SUBSCR # [reg.set, 'r4', r1[r0][r2]] 1410 CALL 2 # r4 = r1[r0][r2] 1418 POP_TOP 1420 LOAD_DEREF 3: reg 1422 LOAD_ATTR 13: set 1442 LOAD_CONST 38: 'r6' 1444 LOAD_CONST 33: 0 1446 CALL 2 # r6 = 0 1454 POP_TOP 1456 LOAD_DEREF 3: reg 1458 LOAD_ATTR 3: get 1478 LOAD_CONST 38: 'r6' 1480 CALL 1 # [r6] 1488 LOAD_CONST 39: 4 # [r6, 4] 1490 COMPARE_OP 18 (<) # [r6 < 4] 1494 POP_JUMP_IF_FALSE 251 (to 1998) # while r6 < 4: 1498 LOAD_DEREF 3: reg 1500 LOAD_ATTR 13: set 1520 LOAD_CONST 40: 'r5' # [reg.set, 'r5'] 1522 LOAD_DEREF 3: reg 1524 LOAD_ATTR 3: get 1544 LOAD_CONST 37: 'r4' 1546 CALL 1 # [reg.set, 'r5', r4] 1554 LOAD_CONST 39: 4 1556 BINARY_OP 9 (>>) # [reg.set, 'r5', r4 >> 4] 1560 LOAD_CONST 41: 15 1562 BINARY_OP 1 (&) # [reg.set, 'r5', (r4 >> 4) & 15] 1566 CALL 2 # r5 = (r4 >> 4) & 15 1574 POP_TOP 1576 LOAD_DEREF 3: reg 1578 LOAD_ATTR 13: set 1598 LOAD_CONST 38: 'r6' # [reg.set, 'r6'] 1600 LOAD_DEREF 3: reg 1602 LOAD_ATTR 3: get 1622 LOAD_CONST 37: 'r4' 1624 CALL 1 # [reg.set, 'r6', r4] 1632 LOAD_CONST 41: 15 1634 BINARY_OP 1 (&) # [reg.set, 'r6', r4 & 15] 1638 CALL 2 # r6 = r4 & 15 1646 POP_TOP 1648 LOAD_DEREF 3: reg 1650 LOAD_ATTR 13: set 1670 LOAD_CONST 38: 'r6' # [reg.set, 'r6'] 1672 LOAD_DEREF 3: reg 1674 LOAD_ATTR 3: get 1694 LOAD_CONST 38: 'r6' 1696 CALL 1 # [reg.set, 'r6', r6] 1704 LOAD_DEREF 3: reg 1706 LOAD_ATTR 3: get 1726 LOAD_CONST 40: 'r5' 1728 CALL 1 # [reg.set, 'r6', r6, r5] 1736 LOAD_CONST 42: 7 1738 BINARY_OP 5 (*) # [reg.set, 'r6', r6, r5 * 7] 1742 LOAD_DEREF 3: reg 1744 LOAD_ATTR 3: get 1764 LOAD_CONST 38: 'r6' 1766 CALL 1 # [reg.set, 'r6', r6, r5 * 7, r6] 1774 LOAD_CONST 43: 3 1776 BINARY_OP 5 (*) # [reg.set, 'r6', r6, r5 * 7, r6 * 3] 1780 BINARY_OP 0 (+) # [reg.set, 'r6', r6, r5 * 7 + r6 * 3] 1784 LOAD_DEREF 3: reg 1786 LOAD_ATTR 3: get 1806 LOAD_CONST 40: 'r5' 1808 CALL 1 # [reg.set, 'r6', r6, r5 * 7 + r6 * 3, r5] 1816 LOAD_CONST 32: 1 1818 BINARY_OP 3 (<<) # [reg.set, 'r6', r6, r5 * 7 + r6 * 3, r5 << 1] 1822 BINARY_OP 12 (^) # [reg.set, 'r6', r6, (r5 * 7 + r6 * 3) ^ (r5 << 1)] 1826 LOAD_CONST 41: 15 1828 BINARY_OP 1 (&) # [reg.set, 'r6', r6, ((r5 * 7 + r6 * 3) ^ (r5 << 1)) & 15] 1832 BINARY_OP 12 (^) # [reg.set, 'r6', r6 ^ (((r5 * 7 + r6 * 3) ^ (r5 << 1)) & 15)] 1836 CALL 2 # r6 ^= ((r5 * 7 + r6 * 3) ^ (r5 << 1)) & 15 1844 POP_TOP 1846 LOAD_DEREF 3: reg 1848 LOAD_ATTR 13: set 1868 LOAD_CONST 37: 'r4' # [reg.set, 'r4'] 1870 LOAD_DEREF 3: reg 1872 LOAD_ATTR 3: get 1892 LOAD_CONST 38: 'r6' 1894 CALL 1 # [reg.set, 'r4', r6] 1902 LOAD_CONST 39: 4 1904 BINARY_OP 3 (<<) # [reg.set, 'r4', r6 << 4] 1908 LOAD_DEREF 3: reg 1910 LOAD_ATTR 3: get 1930 LOAD_CONST 40: 'r5' 1932 CALL 1 # [reg.set, 'r4', r6 << 4, r5] 1940 BINARY_OP 7 (|) # [reg.set, 'r4', (r6 << 4) | r5] 1944 CALL 2 # r4 = (r6 << 4) | r5 1952 POP_TOP 1954 LOAD_DEREF 3: reg 1956 LOAD_ATTR 3: get 1976 LOAD_CONST 38: 'r6' 1978 CALL 1 1986 LOAD_CONST 39: 4 1988 COMPARE_OP 18 (<) 1992 POP_JUMP_IF_FALSE 2 (to 1998) 1996 JUMP_BACKWARD 251 (to 1496) 2000 LOAD_DEREF 3: reg 2002 LOAD_ATTR 13: set 2022 LOAD_CONST 29: 'r1' # [reg.set, 'r1'] 2024 LOAD_DEREF 3: reg 2026 LOAD_ATTR 3: get 2046 LOAD_CONST 29: 'r1' 2048 CALL 1 # [reg.set, 'r1', r1] 2056 LOAD_CONST 0: None # [reg.set, 'r1', r1, None] 2058 LOAD_DEREF 3: reg 2060 LOAD_ATTR 3: get 2080 LOAD_CONST 26: 'r0' 2082 CALL 1 # [reg.set, 'r1', r1, None, r0] 2090 BINARY_SLICE # [reg.set, 'r1', r1[:r0]] 2092 LOAD_DEREF 3: reg 2094 LOAD_ATTR 3: get 2114 LOAD_CONST 29: 'r1' 2116 CALL 1 # [reg.set, 'r1', r1[:r0], r1] 2124 LOAD_DEREF 3: reg 2126 LOAD_ATTR 3: get 2146 LOAD_CONST 26: 'r0' 2148 CALL 1 # [reg.set, 'r1', r1[:r0], r1, r0] 2156 BINARY_SUBSCR # [reg.set, 'r1', r1[:r0], r1[r0]] 2160 LOAD_CONST 0: None # [reg.set, 'r1', r1[:r0], r1[r0], None] 2162 LOAD_DEREF 3: reg 2164 LOAD_ATTR 3: get 2184 LOAD_CONST 36: 'r2' 2186 CALL 1 # [reg.set, 'r1', r1[:r0], r1[r0], None, r2] 2194 BINARY_SLICE # [reg.set, 'r1', r1[:r0], r1[r0][:r2]] 2196 LOAD_GLOBAL 25: NULL + bytes# [reg.set, 'r1', r1[:r0], r1[r0][:r2], bytes] 2206 LOAD_DEREF 3: reg 2208 LOAD_ATTR 3: get 2228 LOAD_CONST 37: 'r4' 2230 CALL 1 # [reg.set, 'r1', r1[:r0], r1[r0][:r2], bytes, r4] 2238 BUILD_LIST 1 # [reg.set, 'r1', r1[:r0], r1[r0][:r2], bytes, [r4]] 2240 CALL 1 # [reg.set, 'r1', r1[:r0], r1[r0][:r2], bytes([r4])] 2248 BINARY_OP 0 (+) # [reg.set, 'r1', r1[:r0], r1[r0][:r2] + bytes([r4])] 2252 LOAD_DEREF 3: reg 2254 LOAD_ATTR 3: get 2274 LOAD_CONST 29: 'r1' 2276 CALL 1 # [reg.set, 'r1', r1[:r0], r1[r0][:r2] + bytes([r4]), r1] 2284 LOAD_DEREF 3: reg 2286 LOAD_ATTR 3: get 2306 LOAD_CONST 26: 'r0' 2308 CALL 1 # [reg.set, 'r1', r1[:r0], r1[r0][:r2] + bytes([r4]), r1, r0] 2316 BINARY_SUBSCR # [reg.set, 'r1', r1[:r0], r1[r0][:r2] + bytes([r4]), r1[r0]] 2320 LOAD_DEREF 3: reg 2322 LOAD_ATTR 3: get 2342 LOAD_CONST 36: 'r2' 2344 CALL 1 # [reg.set, 'r1', r1[:r0], r1[r0][:r2] + bytes([r4]), r1[r0], r2] 2352 LOAD_CONST 32: 1 2354 BINARY_OP 0 (+) # [reg.set, 'r1', r1[:r0], r1[r0][:r2] + bytes([r4]), r1[r0], r2 + 1] 2358 LOAD_CONST 0: None # [reg.set, 'r1', r1[:r0], r1[r0][:r2] + bytes([r4]), r1[r0], r2 + 1, None] 2360 BINARY_SLICE # [reg.set, 'r1', r1[:r0], r1[r0][:r2] + bytes([r4]), r1[r0][r2 + 1:]] 2362 BINARY_OP 0 (+) # [reg.set, 'r1', r1[:r0], r1[r0][:r2] + bytes([r4]) + r1[r0][r2 + 1:]] 2366 BUILD_LIST 1 # [reg.set, 'r1', r1[:r0], [r1[r0][:r2] + bytes([r4]) + r1[r0][r2 + 1:]]] 2368 BINARY_OP 0 (+) # [reg.set, 'r1', r1[:r0] + [r1[r0][:r2] + bytes([r4]) + r1[r0][r2 + 1:]]] 2372 LOAD_DEREF 3: reg 2374 LOAD_ATTR 3: get 2394 LOAD_CONST 29: 'r1' 2396 CALL 1 # [reg.set, 'r1', r1[:r0] + [r1[r0][:r2] + bytes([r4]) + r1[r0][r2 + 1:]], r1] 2404 LOAD_DEREF 3: reg 2406 LOAD_ATTR 3: get 2426 LOAD_CONST 26: 'r0' 2428 CALL 1 # [reg.set, 'r1', r1[:r0] + [r1[r0][:r2] + bytes([r4]) + r1[r0][r2 + 1:]], r1, r0] 2436 LOAD_CONST 32: 1 2438 BINARY_OP 0 (+) # [reg.set, 'r1', r1[:r0] + [r1[r0][:r2] + bytes([r4]) + r1[r0][r2 + 1:]], r1, r0 + 1] 2442 LOAD_CONST 0: None # [reg.set, 'r1', r1[:r0] + [r1[r0][:r2] + bytes([r4]) + r1[r0][r2 + 1:]], r1, r0 + 1, None] 2444 BINARY_SLICE # [reg.set, 'r1', r1[:r0] + [r1[r0][:r2] + bytes([r4]) + r1[r0][r2 + 1:]], r1[r0 + 1:]] 2446 BINARY_OP 0 (+) # [reg.set, 'r1', r1[:r0] + [r1[r0][:r2] + bytes([r4]) + r1[r0][r2 + 1:]] + r1[r0 + 1:]] 2450 CALL 2 # r1 = r1[:r0] + [r1[r0][:r2] + bytes([r4]) + r1[r0][r2 + 1:]] + r1[r0 + 1:] 2458 POP_TOP 2460 LOAD_DEREF 3: reg 2462 LOAD_ATTR 3: get 2482 LOAD_CONST 29: 'r1' 2484 CALL 1 # [r1] 2492 LOAD_DEREF 3: reg 2494 LOAD_ATTR 3: get 2514 LOAD_CONST 26: 'r0' 2516 CALL 1 # [r1, r0] 2524 BINARY_SUBSCR # [r1[r0]] 2528 LOAD_DEREF 3: reg 2530 LOAD_ATTR 3: get 2550 LOAD_CONST 36: 'r2' 2552 CALL 1 # [r1[r0], r2] 2560 BINARY_SUBSCR # [r1[r0][r2]] 2564 LOAD_DEREF 3: reg 2566 LOAD_ATTR 3: get 2586 LOAD_CONST 34: 'r3' 2588 CALL 1 # [r1[r0][r2], r3] 2596 LOAD_DEREF 3: reg 2598 LOAD_ATTR 3: get 2618 LOAD_CONST 36: 'r2' 2620 CALL 1 # [r1[r0][r2], r3, r2] 2628 BINARY_SUBSCR # [r1[r0][r2], r3[r2]] 2632 COMPARE_OP 119 (!=) 2636 POP_JUMP_IF_FALSE 11 (to 2660) # if r1[r0][r2] != r3[r2]: 2640 LOAD_GLOBAL 7: NULL + destruction 2650 LOAD_DEREF 3: reg 2652 CALL 1 # destruction(reg) 2660 POP_TOP 2662 LOAD_DEREF 3: reg 2664 LOAD_ATTR 13: set 2684 LOAD_CONST 36: 'r2' # [reg.set, 'r2'] 2686 LOAD_DEREF 3: reg 2688 LOAD_ATTR 3: get 2708 LOAD_CONST 36: 'r2' 2710 CALL 1 # [reg.set, 'r2', r2] 2718 LOAD_CONST 32: 1 2720 BINARY_OP 0 (+) # [reg.set, 'r2', r2 + 1] 2724 CALL 2 # r2 += 1 2732 POP_TOP 2734 LOAD_DEREF 3: reg 2736 LOAD_ATTR 3: get 2756 LOAD_CONST 36: 'r2' 2758 CALL 1 2766 LOAD_CONST 31: 27 2768 COMPARE_OP 18 (<) 2772 POP_JUMP_IF_FALSE 3 (to 2780) 2776 JUMP_BACKWARD 750 (to 1280) 2782 LOAD_DEREF 3: reg 2784 LOAD_ATTR 13: set 2804 LOAD_CONST 26: 'r0' # [reg.set, 'r0'] 2806 LOAD_DEREF 3: reg 2808 LOAD_ATTR 3: get 2828 LOAD_CONST 26: 'r0' 2830 CALL 1 # [reg.set, 'r0', r0] 2838 LOAD_CONST 32: 1 2840 BINARY_OP 0 (+) # [reg.set, 'r0', r0 + 1] 2844 CALL 2 # Increment r0 by 1 if the flag part is correct 2852 POP_TOP 2854 JUMP_FORWARD 2513 (to 7884) </details> Python equivalent: ```python r3 = [213, 102, 213, 85, 64, 198, 119, 243, 85, 179, 64, 85, 230, 183, 115, 179, 85, 179, 85, 179, 247, 179, 230, 102, 179, 102, 85] for r2 in range(27): r4 = r1[r0][r2] r6 = 0 while r6 < 4: r5 = (r4 >> 4) & 0xf r6 = r4 & 0xf r6 ^= ((r5 * 7 + r6 * 3) ^ (r5 << 1)) & 15 r4 = (r6 << 4) | r5 r1 = r1[:r0] + [r1[r0][:r2] + bytes([r4]) + r1[r0][r2 + 1:]] + r1[r0 + 1:] if r1[r0][r2] != r3[r2]: destruction(reg) ``` The `r1 = r1[:r0] + ...` line just replaces `r1[r0][r2]` with `r4`, so this can be simplified further: ```python r3 = [213, 102, 213, 85, 64, 198, 119, 243, 85, 179, 64, 85, 230, 183, 115, 179, 85, 179, 85, 179, 247, 179, 230, 102, 179, 102, 85] for r2 in range(27): r4 = r1[r0][r2] r6 = 0 while r6 < 4: r5 = (r4 >> 4) & 0xf r6 = r4 & 0xf r6 ^= ((r5 * 7 + r6 * 3) ^ (r5 << 1)) & 15 r4 = (r6 << 4) | r5 if r4 != r3[r2]: destruction(reg) ``` Let's try to solve for this part! ```python alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_{' r3 = [213, 102, 213, 85, 64, 198, 119, 243, 85, 179, 64, 85, 230, 183, 115, 179, 85, 179, 85, 179, 247, 179, 230, 102, 179, 102, 85] part_1 = "" for r3_val in r3: for c in alphabet: r4 = ord(c) r6 = 0 while r6 < 4: r5 = (r4 >> 4) & 0xf r6 = r4 & 0xf r6 ^= ((r5 * 7 + r6 * 3) ^ (r5 << 1)) & 15 r4 = (r6 << 4) | r5 if r4 == r3_val: part_1 += c break print(part_1) # QfQSacs1S0aSbq50S0S0p0bf0fS ``` Oh dear. Why does it look so weird? Are there multiple accepted characters for each number in `r3`? ```python alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_{' r3 = [213, 102, 213, 85, 64, 198, 119, 243, 85, 179, 64, 85, 230, 183, 115, 179, 85, 179, 85, 179, 247, 179, 230, 102, 179, 102, 85] for r3_val in r3: accepted_chars = "" for c in alphabet: r4 = ord(c) r6 = 0 while r6 < 4: r5 = (r4 >> 4) & 0xf r6 = r4 & 0xf r6 ^= ((r5 * 7 + r6 * 3) ^ (r5 << 1)) & 15 r4 = (r6 << 4) | r5 if r4 == r3_val: accepted_chars += c print(f"{r3_val}: {accepted_chars}") ``` ``` 213: QUY 102: fhn 213: QUY 85: SW_ 64: aeim 198: ck 119: st{ 243: 129 85: SW_ 179: 03478 64: aeim 85: SW_ 230: bdjl 183: qruvyz 115: 56 179: 03478 85: SW_ 179: 03478 85: SW_ 179: 03478 247: pwx 179: 03478 230: bdjl 102: fhn 179: 03478 102: fhn 85: SW_ ``` Unfortunately, there are. I can tell that I'm on the right track though as I can see that the first 7 characters of the expected flag format (`QnQSec{...}`) are included in the first 7 lines. Right now, it's hard for me to guess what part 1 is saying, so I'll come back to it after solving part 2. ### Part 2 Python equivalent: ```python r4 = [152, 245, 229, 38, 25, 181, 212, 42, 27, 185, 33, 161, 163, 166, 229, 114, 162, 239, 0, 215, 13, 152, 169, 181, 40, 122, 200] r3 = 66 for r2 in range(27): r3 = ((r3 * 17) ^ r2) & 0xff r1 = r1[:r0] + [r1[r0][:r2] + bytes([(r1[r0][r2] + r3) & 255]) + r1[r0][r2 + 1:]] + r1[r0 + 1:] if r1[r0][r2] != r4[r2]: destruction(reg) ``` Solver: ```python r4 = [152, 245, 229, 38, 25, 181, 212, 42, 27, 185, 33, 161, 163, 166, 229, 114, 162, 239, 0, 215, 13, 152, 169, 181, 40, 122, 200] r3 = 66 part_2 = "" for r2 in range(27): r3 = ((r3 * 17) ^ r2) & 0xff part_2 += chr((r4[r2] - r3) & 0xff) print(part_2) # 6r4d3r_h16h_5ch00l_57ud3n7_ ``` ### Part 1 revisited Since part 2 is `6r4d3r_h16h_5ch00l_57ud3n7_`, there should be a number before `6r4d3r`. After some guessing, I realized the first part is `QnQSec{1_4m_ju57_4_7w3lf7h_`. ### Part 3 Python equivalent: ```python r3 = [] r2 = int.from_bytes(r1[r0]) while r2 > 0: r3 = [r2 % 69] + r3 r2 //= 69 r2 = 0 for r3_elem in r3: r2 = 69 * r2 + r3_elem r3 = [] while r2 > 0: r3 = [r2 % 73] + r3 r2 //= 73 if r3 != [9, 41, 37, 34, 21, 5, 29, 42, 16, 37, 31, 5, 53, 18, 72, 65, 5, 23, 67, 26, 30, 53, 64, 7, 55, 19, 27, 22, 68, 62, 18, 1, 39, 6, 50]: destruction(reg) ``` Solver: ```python r3 = [9, 41, 37, 34, 21, 5, 29, 42, 16, 37, 31, 5, 53, 18, 72, 65, 5, 23, 67, 26, 30, 53, 64, 7, 55, 19, 27, 22, 68, 62, 18, 1, 39, 6, 50] r2 = 0 for r3_elem in r3: r2 = 73 * r2 + r3_elem part_3 = r2.to_bytes((r2.bit_length() + 7) // 8, "big").decode() print(part_3) # 4nd_1_4m_45k3d_70_m4k3_7h35 ``` ### Part 4 Python equivalent: ```python r4 = '\n========================\n THE ENIGMA OF HEAVEN\n=====================†==\n\nPRAISE THE LORD! The air conditioner keeps them away it sings gospels and\nPRA†SE THE LORD! Finding faith in whit† noise.\nPRAISE THE LORD! The messages are coming in loud and clear and\nI hear them and I see them in the sky the towers are sending m†ssages and\nI hear them and I see t†em.\nPRAISE THE LORD! The people in the parking lot can\'t hurt me anymore\nthey can\'t hurt me †ny†ore their words are weak an† the lord is strong.\nPRAIS† THE LORD† The bibl††shows the way and†the way protects me and\nI\'ve seen the messages and I†heart them and † see them and they can\'t\nhurt me anymore†\nPRAISE THE LORD!\n\nCHAPTERS: † ╔═══════════════╗\n————————— ║ ║\nI. The Eni†ma of Hea†en: ║† H†AVEN ║\n 9†999,999 Channels,†Finding Faith† † ╟───────────────╢\n in White Noise... The God Stimulation! ║ ║\nII. The Hierarchy of Equality: ║ †ADIATION ║\n Angelic Voices Echo Through the Halls ╟───────────────╢\n of Heaven, Under the Railroad Bridge ║ ║ "And I have\nIII. The Paradox of Faith: ║ RADIO ║ told you\n There\'s a Knocking at the Door! ╟───────────────╢ the TRUTH,\n God is in, God is in! ║ ║\nIV. The Senselessness of Endlessness: ║ TELEVISION ║ for you\n Returning to an Empty Apartment ║ ║ are my child,\n with a Grocery Store Guardian Angel ╚═══════════════╝ and you have\n seen my face"\n\nAn EP titled "The Enigma of Heaven and Other Daily De†usions" ' r3 = 0 for r2 in range(27): r3 += (r1[r0][r2] - 16) * (2 if r2 & 1 == 0 else -1) if r4[r3] != '†': destruction(reg) ``` Like part 1, this checker accepts multiple flag parts. My solver is based on ensuring that each cross is only visited once: ```python r4 = '\n========================\n THE ENIGMA OF HEAVEN\n=====================†==\n\nPRAISE THE LORD! The air conditioner keeps them away it sings gospels and\nPRA†SE THE LORD! Finding faith in whit† noise.\nPRAISE THE LORD! The messages are coming in loud and clear and\nI hear them and I see them in the sky the towers are sending m†ssages and\nI hear them and I see t†em.\nPRAISE THE LORD! The people in the parking lot can\'t hurt me anymore\nthey can\'t hurt me †ny†ore their words are weak an† the lord is strong.\nPRAIS† THE LORD† The bibl††shows the way and†the way protects me and\nI\'ve seen the messages and I†heart them and † see them and they can\'t\nhurt me anymore†\nPRAISE THE LORD!\n\nCHAPTERS: † ╔═══════════════╗\n————————— ║ ║\nI. The Eni†ma of Hea†en: ║† H†AVEN ║\n 9†999,999 Channels,†Finding Faith† † ╟───────────────╢\n in White Noise... The God Stimulation! ║ ║\nII. The Hierarchy of Equality: ║ †ADIATION ║\n Angelic Voices Echo Through the Halls ╟───────────────╢\n of Heaven, Under the Railroad Bridge ║ ║ "And I have\nIII. The Paradox of Faith: ║ RADIO ║ told you\n There\'s a Knocking at the Door! ╟───────────────╢ the TRUTH,\n God is in, God is in! ║ ║\nIV. The Senselessness of Endlessness: ║ TELEVISION ║ for you\n Returning to an Empty Apartment ║ ║ are my child,\n with a Grocery Store Guardian Angel ╚═══════════════╝ and you have\n seen my face"\n\nAn EP titled "The Enigma of Heaven and Other Daily De†usions" ' alphabet = bytes(range(33, 127)).decode() candidates = [[0, '', []]] while len(candidates) != 0: r3, part_4, visited_crosses = candidates.pop() if len(part_4) == 27: print(part_4) continue r2 = len(part_4) for c in alphabet: r3_new = r3 + (ord(c) - 16) * (2 if r2 & 1 == 0 else -1) if (r4[r3_new] != '†') or (r3_new in visited_crosses): continue candidates.append([r3_new, part_4 + c, visited_crosses + [r3_new]]) ``` While the solver prints multiple answers, this time it's a lot easier to find the correct one: `3_r3v3r51n6_ch4ll3n635,_50b` ### Part 5 Python equivalent: ```python r3 = 0 r4 = 0 r5 = [(9, 5), (14, 8), (19, 6), (29, 4), (33, 8), (43, 3), (54, 5), (65, 3), (70, 4), (75, 6), (80, 9), (86, 0), (95, 5), (105, 5), (110, 3), (119, 8), (130, 8), (135, 6), (141, 1), (150, 6), (161, 5), (166, 4), (177, 4), (187, 4), (191, 8), (201, 3), (204, 6)] for r2 in range(27): r3 = (10 * r3 + r4 + r1[r0][r2]) // 10 r4 = (r4 + r1[r0][r2]) % 10 if r5[r2] != (r3, r4): destruction(reg) ``` Solver: ```python r5 = [(9, 5), (14, 8), (19, 6), (29, 4), (33, 8), (43, 3), (54, 5), (65, 3), (70, 4), (75, 6), (80, 9), (86, 0), (95, 5), (105, 5), (110, 3), (119, 8), (130, 8), (135, 6), (141, 1), (150, 6), (161, 5), (166, 4), (177, 4), (187, 4), (191, 8), (201, 3), (204, 6)] nums = [0] + [10 * a + b for (a, b) in r5] part_5 = bytes([nums[i + 1] - nums[i] for i in range(len(nums) - 1)]).decode() print(part_5) # _50b,_pl3453_d0_n07_m1nd,_! ``` ### Part 6 Python equivalent: ```python r4 = [33, 20, 23, 94, 127, 14, 26, 30, 58, 27, 84, 68, 27, 51, 78, 72, 80, 62, 2, 71, 92, 65, 64, 43, 89, 87, 83] r3 = [73, 32, 97, 109, 32, 104, 111, 112, 101, 108, 101, 115, 115, 108, 121, 32, 99, 97, 112, 116, 105, 118, 97, 116, 101, 100, 46] for r2 in range(2, 110, 4): if r1[r0][(r2 - 2) // 4] ^ r4[(r2 - 2) // 4] != r3[(r2 - 2) // 4]: destruction(reg) ``` Solver: ```python r4 = [33, 20, 23, 94, 127, 14, 26, 30, 58, 27, 84, 68, 27, 51, 78, 72, 80, 62, 2, 71, 92, 65, 64, 43, 89, 87, 83] r3 = [73, 32, 97, 109, 32, 104, 111, 112, 101, 108, 101, 115, 115, 108, 121, 32, 99, 97, 112, 116, 105, 118, 97, 116, 101, 100, 46] part_6 = bytes([a ^ b for (a, b) in zip(r4, r3)]).decode() print(part_6) # h4v3_fun_w17h_7h3_r357!_<3} ``` ## Solving the challenge Piecing all 6 parts together, the flag should be `QnQSec{1_4m_ju57_4_7w3lf7h_6r4d3r_h16h_5ch00l_57ud3n7_4nd_1_4m_45k3d_70_m4k3_7h353_r3v3r51n6_ch4ll3n635,_50b_50b,_pl3453_d0_n07_m1nd,_!h4v3_fun_w17h_7h3_r357!_<3}`. But how can I check whether this flag is accepted by the program if I've missed my one shot at it already? Going back to the disassembly, I see this after the `r8 == '.effie'` check: ``` 222 LOAD_GLOBAL 9: NULL + any 232 LOAD_FAST 3: reg 234 BUILD_TUPLE 1 236 LOAD_CONST 10: <CODE> <genexpr> 238 MAKE_FUNCTION 240 SET_FUNCTION_ATTRIBUTE 8 (MAKE_FUNCTION_CLOSURE) 242 LOAD_GLOBAL 11: NULL + range 252 LOAD_CONST 11: 8 254 CALL 1 262 GET_ITER 264 CALL 0 272 CALL 1 280 TO_BOOL 288 POP_JUMP_IF_FALSE 109 (to 508) 292 LOAD_GLOBAL 5: NULL + print 302 CALL 0 310 POP_TOP 312 LOAD_GLOBAL 5: NULL + print 322 LOAD_CONST 12: 'You return to the same room.' 324 CALL 1 ... ``` There is a generator object used here that determines if I've taken my one shot. Its disassembly is excluded from my simplified disassembly and it looks like this: ``` 0 COPY_FREE_VARS 1 2 RETURN_GENERATOR 4 POP_TOP 6 RESUME 0 8 LOAD_FAST 0: .0 10 GET_ITER 12 FOR_ITER 36 (to 86) 16 STORE_FAST 1: _var_var_10 18 LOAD_DEREF 2: _var_var_7 20 LOAD_ATTR 1: get 40 LOAD_CONST 0: 'r' 42 LOAD_GLOBAL 3: NULL + str 52 LOAD_FAST 1: _var_var_10 54 CALL 1 62 BINARY_OP 0 (+) 66 CALL 1 74 LOAD_CONST 1: None 76 IS_OP 1 (is not) 78 YIELD_VALUE 0 80 RESUME 5 82 POP_TOP 84 JUMP_BACKWARD 38 (to 10) 88 END_FOR 90 POP_TOP 92 RETURN_CONST 1: None 94 CALL_INTRINSIC_1 3 (INTRINSIC_STOPITERATION_ERROR) 96 RERAISE 1 ``` The check looks like: ```python if any((reg.get('r' + str(i)) is not None for i in range(8))): print() print('You return to the same room.') ... ``` So all it's doing is checking if any of the registry values `r0` to `r7` exist! I can just delete the `HKEY_CURRENT_USER\Software\OneShot` registry key to bypass this check. Let's give this challenge another shot: ``` C:\Users\fsharp\Desktop\qnqsec\OneShotRevenge>Revengeee.exe Niko is trapped in a puzzle. Scratches on the walls read: THIS IS YOUR ONLY CHANCE. ONE SHOT, OR STUCK HERE TRYING, FOREVER. "Please... you have to help me. I don't understand this." Niko looks at you with desperate hope. "Do you know the way out?" > QnQSec{1_4m_ju57_4_7w3lf7h_6r4d3r_h16h_5ch00l_57ud3n7_4nd_1_4m_45k3d_70_m4k3_7h353_r3v3r51n6_ch4ll3n635,_50b_50b,_pl3453_d0_n07_m1nd,_!h4v3_fun_w17h_7h3_r357!_<3} Niko's eyes light up. The puzzle dissolves. "We did it! We actually made it!" The way forward opens. Niko is free. ``` It's over! Since I've already solved the challenge, if I run the program again without deleting the registry key, I'll get: ``` C:\Users\fsharp\Desktop\qnqsec\OneShotRevenge>Revengeee.exe You've done it, my man. The sun now glows a little brighter. "Thank you for playing." ``` ## Bonus: A few CTF post-mortem thoughts Only when creating this writeup did I notice the Pyarmor deobfuscation tool I used has '1Shot' in its name. '1Shot'... 'OneShot'... is this merely a coincidence? Is this tool what the challenge author would use to deobfuscate the flag checker? I also find it weird how the program didn't delete itself when `destruction` is called. Maybe it's got something to do with file permissions?