weirdSnake picoCTF 2024 Solution

Published: April 3, 2024

Description

I have a friend that enjoys coding and he hasn't stopped talking about a snake recently He left this file on my computer and dares me to uncover a secret phrase from it. Can you assist?

Bytecode reversing

Download the bytecode file snake and examine it with tools like uncompyle6 or Python's dis module.

bash
wget https://artifacts.picoctf.net/c_titan/31/snake && \
python3 your_decrypt.py

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Recover the key
    Observation
    I noticed the downloaded file had no .py or .pyc extension and the challenge name referenced a snake (Python), which suggested it was compiled Python bytecode and that disassembling it with the dis module would expose the LOAD_CONST sequences used to build the key character by character.
    Disassemble the bytecode and scan for a sequence of LOAD_CONST ops that push single characters onto the stack, ending in BUILD_STRING. That sequence reconstructs key_str (in this challenge it builds out to t_Jo3). Convert it to a list of integers for the XOR step:
    python
    python3 -m dis snake | less
    bash
    key_list = [ord(c) for c in 't_Jo3']
    What didn't work first

    Tried: Run uncompyle6 directly on the snake file expecting clean source output with the key visible as a string literal.

    uncompyle6 often fails or produces mangled output on bytecode from Python versions it does not fully support, printing 'Internal error' or a partial decompilation that omits exactly the constant-building section. The dis module is more reliable here because it operates directly on the raw opcodes without trying to infer high-level constructs, so the LOAD_CONST sequence that builds the key character-by-character is always visible regardless of Python version.

    Tried: Use the strings utility on the snake file to look for the key printed as a plain ASCII sequence.

    The key 't_Jo3' is built one character at a time by separate LOAD_CONST opcodes, so it never appears as a contiguous ASCII string in the binary data. The strings tool only finds runs of printable characters above a minimum length, so individual single-character constants scattered through the bytecode are invisible to it. Only disassembling with dis reveals the instruction sequence that assembles those characters into the key.

    Learn more

    Python source files (.py) are compiled to bytecode (.pyc) by the CPython interpreter before execution. Bytecode is a lower-level, platform-independent instruction set for the Python Virtual Machine: not machine code, but not source code either. It sits in the middle: harder to read than Python, but much easier to recover than compiled C or C++.

    Tools like uncompyle6, decompile3, or pycdc can reconstruct Python source from bytecode with high fidelity. Python's built-in dis module disassembles bytecode into human-readable opcode mnemonics; useful when decompilers fail on unusual or obfuscated bytecode.

    • python3 -m dis snake disassembles the bytecode file.
    • uncompyle6 snake attempts full source reconstruction (install with pip install uncompyle6).
    • Strings built character-by-character show up as repeated LOAD_CONST 't', LOAD_CONST '_', ... followed by a BUILD_STRING N op that joins them.
  2. Step 2
    Recreate the input list
    Observation
    I noticed the dis output contained a BUILD_LIST opcode preceded by a long sequence of LOAD_CONST instructions pushing integers, which indicated those integers were the ciphertext list (input_list) that the program XORs against the key at runtime.
    The bytecode stores the ciphertext integers in a list (input_list). Copy those numbers into your script.
    Learn more

    In Python bytecode, list literals are stored as a series of LOAD_CONST instructions (one per element) followed by a BUILD_LIST opcode. The disassembly makes these values directly visible - no decryption needed to extract the ciphertext list. This is a fundamental asymmetry in obfuscation via compilation: the data the program operates on must be present at runtime, so it is always recoverable from the bytecode.

    ord(c) converts a character to its ASCII integer value (e.g., ord('A') == 65). The key is stored as a string but used as a list of integers during XOR operations, so converting it with [ord(c) for c in key_str] produces the correct numeric representation.

    Recognizing that a program stores integers in a list and XORs them against a key is a pattern found constantly in CTF reversing challenges and in real malware - it is one of the simplest ways to obfuscate strings like API keys, C2 addresses, or flags without using a proper cipher.

  3. Step 3
    XOR decrypt
    Observation
    I noticed the bytecode contained a BINARY_XOR opcode operating on elements from input_list and key_list, confirming the cipher was repeating-key XOR and that applying the same XOR with itertools.cycle would recover the plaintext flag.
    XOR each ciphertext byte with the cycling key. Watch out: zip(a, b) stops at the shorter sequence, so if you pass the raw key_list and it is shorter than input_list you silently lose the tail of the message. Use itertools.cycle to repeat the key. The Python for CTF guide covers more idioms like this.
    python
    from itertools import cycle
    result = ''.join(chr(a ^ b) for a, b in zip(input_list, cycle(key_list)))
    print(result)

    Expected output

    picoCTF{N0t_sO_coNfus1ng_sn@ke_30a...}
    What didn't work first

    Tried: Use zip(input_list, key_list) without itertools.cycle, then wonder why the decoded output is truncated and missing the end of the flag.

    zip stops at the shorter of the two sequences, which is the key (length 5) not the ciphertext. Only the first 5 bytes get XOR'd and the rest of the flag is silently dropped. itertools.cycle wraps the key into an infinite repeating stream so that zip runs for the full length of input_list and every ciphertext byte is decrypted.

    Tried: XOR the ciphertext integers directly against the key string characters without calling ord(), getting a TypeError and assuming the key extraction step was done incorrectly.

    Python's XOR operator (^) works on integers, not characters. The key string must first be converted to a list of ASCII code points with [ord(c) for c in key_str]. The TypeError 'unsupported operand type(s) for ^: int and str' means the conversion was skipped, not that the wrong key was found.

    Learn more

    XOR (exclusive OR) is the simplest symmetric cipher. XOR has a beautiful property: applying the same key twice returns the original value (m XOR k XOR k = m). This makes XOR decryption identical to encryption: just XOR the ciphertext with the same key and you recover the plaintext.

    When the key is shorter than the message, it is typically repeated cyclically; this is called a repeating-key XOR or Vigenere-like XOR. zip in Python pairs elements from two sequences and stops at the shorter one, which is the gotcha called out above. itertools.cycle(key_list) creates an infinitely repeating keystream so the zip stops at the ciphertext length instead.

    Repeating-key XOR was used in real cryptography before modern ciphers (the Vigenere cipher is a letter-based variant). It is broken by known-plaintext attacks (if you know any bytes of the plaintext, you immediately recover the corresponding key bytes) and by frequency analysis when the key is short relative to the message. AES in CTR mode is essentially XOR with a secure, non-repeating keystream; the security comes entirely from the quality of the keystream, not the XOR operation itself.

Interactive tools
  • Strings ExtractorPull printable text from any binary, library, or image. ASCII and UTF-16 detection, configurable minimum length, flag-like highlight, no command line needed.

Flag

Reveal flag

picoCTF{N0t_sO_coNfus1ng_sn@ke_30a...}

The decrypted string from the XOR routine is the final flag.

Key takeaway

Python bytecode is not source code, but it is far from opaque: the dis module and tools like uncompyle6 recover constants, keys, and control flow with minimal effort. Any secret embedded in a compiled program as a constant must be present at runtime, making it directly readable from the bytecode regardless of what language compiled it. Repeating-key XOR is trivially reversed once the key is known, and the key is always recoverable from the bytecode because the program must use it to encrypt or decrypt at runtime.

Related reading

Want more picoCTF 2024 writeups?

Tools used in this challenge

What to try next