CJ2024: Armored Snake

Posted on 2025-02-08 23:00


Armored Snake is a challenge in Cyber Jawara 2024 Finals (public category). We are given a zip file of a Python project, where it has been generated by PyArmor. PyArmor itself is a obfuscator for Python projects, so that your code can't be easily reversed by others. It applies various mechanics, notably encrypting the code itself, and decrypting it during runtime. Not only that, the project is also tied to the library that it generates, and it cannot be tampered with. Any altercation to the file will result to the file not being able to be executed, throwing an exception right away.

Challenge Files

Of course, just like any other reverse challenges, we try to run the file! After running, we get a nice snake game that keep track of our high score, and persisting through every run.

The Game

From the game itself, it only shows a brief Can you win the game? message, so it should be noted that there is a win criteria in this game.

Reversing... not?

As I have mentioned before, PyArmor provides an anti-tamper, so if you edit the script itself, it will error out if you try to run it.

We need to find another way to access it. First, we need to know how Python runs a file.

The Interpreter

Python is an interpreted language, means it runs the script as it is from top to down. Of course, it's not exactly this way, but to be simple this is still true. Python also supports modules, and Python does this quite uniquely (yet, strangely, also predictable?)

All Files Is Just A Module

That's right, even if it looks like a script file that's not meant to be a module, it is technically a module! Let's say you have the following file called program.py:

def greet(name: str):
    print(f"Hello, {name}!")

greet("Ren")

and then you have another file called main.py that only imports that file: import greet. When you try to run it, it'll print out Hello, Ren!. Not only that, if we try to import that in a REPL, we can call greet(name) as well! This concept is extremely important to get an entry point later on.

Let's try to run the program in REPL by just importing it! The game will run, and we'll get thrown back to the interpreter! We can even see all the functions defined in the "module", despite it being completely obfuscated, and of course, run them!

Reversing... still no?

Python has a builtin disassembler in the dis module. So of course, that's the first stop to disassemble the program. There is a curiously named get_flag variable there, and a quick look is that it is indeed a function. Let's try to disassemble it!

Shucks, no luck. We somehow got an error during disassembling the function. Let's try another function to sanity check, maybe bytes_to_long, usually it's imported from PyCryptodome, so it shouldn't be obfuscated.

It seems to work just fine, so it's definitely PyArmor doing its thing. Curiously, the first part of the function seems to be disassembled just fine, and it calls to... some whatever function that it does. Let's see where it came from. We know it is a constant, so we can check it using its code object's co_consts.

I see, it's from the PyArmor runtime module! This might be a clue to the "decrypting" routine.

Now, we do know that Python still needs to somehow run the bytecode, and if it is in a format that Python doesn't know (as indicated by the disassemble output), it'll just crash right away. However, we also know that Python is an interpreted language, so let's try doing what Python does: execute it ourselves!

We'll execute the C_ENTER_CO_OBJECT_INDEX function with the argument specified there.

Nothing happens. But, let's try disassembling the function again.

Suddenly, we got the whole bytecode of that function! It seems like whatever that function does, it basically "decrypts" the whole function, making it available and understandable by Python!

Reversing... yes!

Let's recap what we have known:

  • We can access all the functions and variables in the chall project by importing it in REPL
  • We can disassemble the obfuscated function by calling the C_ENTER_CO_OBJECT_INDEX as well as the argument that match it

The only thing left to do is to get all the function's bytecode and reverse them! I made the following function to help me get all the bytecode:

import dis

def disarmor(f, filename: str):
    consts = f.__code__.co_consts
    # C_ENTER_CO_OBJECT_INDEX(bytes)
    # its always 3 and 4
    consts[3](consts[4])
    with open(filename, 'w') as fw:
        dis.dis(f, file=fw)

I won't bore you with all the outputs of the disassemble. You can easily do that by doing disarmor(chall.snake_game, "thegame").

Getting Flag

The first stop that I did was to go to get_flag just like above. However, upon inspecting, it seems like it takes a key then and decrypt the given encrypted flag. So we'll have to find the key.

Upon the start of the game, it says if I can win the game, I decided to disassemble the game function itself, chall.snake_game.

We can clearly see that there is a call to get_flag if we won, and upon scrolling up, we can see the condition of winning (getting the score of 3133713377331) and some precalculation for the get_flag argument.

The functions that is calling can be seen defined as locals way at the top.

_var_var_10:

_var_var_11:

_var_var_9:

_var_var_12:

Now, we're still missing the encrypt_crypto_aes, so let's do just that:

Last but not least, the set_random_seed func:

Okay, now here's all the code, in plain Python!

import hashlib
import random
import sys
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad


def _var_var_9(a, b, c):
    return ((a << (b % c)) & ((2**c) - 1)) | ((a & ((2**c) - 1)) >> (c - (b % c)))


def _var_var_12(x):
    return x ^ _var_var_9(x, 7, 32) ^ _var_var_9(x, 15, 32) ^ _var_var_9(x, 21, 32) ^ _var_var_9(x, 3, 32)


def _var_var_10(score):
    return hashlib.md5(
        bytes(
            [
                (random.randint(0, 255) ^ score) & 255,
                (random.randint(0, 255) + score) & 255,
                (random.randint(0, 255) - score) & 255,
                (random.randint(0, 255) * score) & 255,
            ]
        )
    ).digest()


def _var_var_11(score):
    return hashlib.md5(
        bytes(
            [
                (random.randint(0, 127) - score) & 255,
                (random.randint(0, 127) * score) & 255,
                (random.randint(0, 255) ^ score) & 255,
                (random.randint(0, 255) + score) & 255,
            ]
        )
    ).digest()


def encrypt_crypto_aes(plaintext):
    key1 = b"0HjtygmYRTDu6cs1VFSP30O4HPdmr4Qv"
    nonce = b"95AtEIhGaPKX2R10fhEcqCDghhBZQiCN"
    cipher = AES.new(key1, AES.MODE_GCM, nonce=nonce)
    ciphertext = cipher.encrypt(pad(plaintext, 16))

    key2 = b"1MfGhHjSMebYoMOM85uyT8SHQzqSRxWp"
    iv = b"V05jbV9cnBzDUmhE"

    for _ in range(16):
        cipher = AES.new(key2, AES.MODE_CBC, iv)
        ciphertext = cipher.encrypt(ciphertext)

    return ciphertext


def main():
    score = 3133713377331  # The winning score

    # set_random_seed(score)
    random.seed(3034264383 + score)

    var_var_25 = encrypt_crypto_aes(str(score).encode()).hex()
    var_var_15 = _var_var_10(score).hex()
    var_var_16 = _var_var_11(_var_var_12(score)).hex()

    var_var_26 = "_".join([var_var_25, var_var_15, var_var_16])

    print(var_var_26)


if __name__ == "__main__":
    main()

We just need to run the file, then set it as the argument for chall.get_flag (we can do it in the same REPL)

...and we get the flag!

An absolutely insane chall, with a twist that I was not expecting. Solved it 2 mins before the competition ends. Really, really good challenge.