CSIT January Mini Challenge

The Nian Awakens 🛠️

Decrypt to Defend!

Legends speak of enchanted artifacts scattered across the region when Nian was first captured. These artifacts contain vital information of Nian's weaknesses. It's up to you to decrypt their secrets and uncover information to bring the beast down.

Nian is currently resting at 34.123.42.200:80. When you're ready, face the beast and save Chinese New Year 2025.

The following three files were provided:

  • client - Python Executable

  • dev_server - Python Executable

  • 蛇年吉祥.pcapng - Encrypted Network Traffic

DetectItEasy

Using DetectItEasy, we identify that both executables are Python programs packed with PyInstaller. After extracting the Python scripts using PyInstxtractor and decompiling with PyLingual, we get:

  • client.py: Client for connecting to WebSocket server

  • dev_server.py: Development server with encrypted test flag

  • common.py: Shared functions

  • constants.py: Logging configuration and hints

I began by running both executables. The programs downloaded the necessary TLS certificates for mutual TLS (mTLS) connection, but the client is not working.

└─$ ./client
2025-02-09 20:20:06,723 - INFO - DEV_client.crt not found. Downloading...
2025-02-09 20:20:07,180 - INFO - Downloaded file from http://34.57.139.144:80/REVW/DEV_client.crt to DEV_client.crt
2025-02-09 20:20:07,181 - INFO - DEV_client.key not found. Downloading...
2025-02-09 20:20:07,645 - INFO - Downloaded file from http://34.57.139.144:80/REVW/DEV_client.key to DEV_client.key
2025-02-09 20:20:07,645 - INFO - DEV_ca.crt not found. Downloading...
2025-02-09 20:20:08,094 - INFO - Downloaded file from http://34.57.139.144:80/REVW/DEV_ca.crt to DEV_ca.crt

└─$ ./dev_server
2025-02-09 20:20:16,217 - INFO - DEV_server.crt not found. Downloading...
2025-02-09 20:20:16,667 - INFO - Downloaded file from http://34.57.139.144:80/REVW/DEV_server.crt to DEV_server.crt
2025-02-09 20:20:16,667 - INFO - DEV_server.key not found. Downloading...
2025-02-09 20:20:17,282 - INFO - Downloaded file from http://34.57.139.144:80/REVW/DEV_server.key to DEV_server.key
2025-02-09 20:20:17,282 - INFO - DEV_ca.crt found.
2025-02-09 20:20:17,302 - INFO - server listening on 0.0.0.0:33073
2025-02-09 20:20:17,303 - INFO - server listening on [::]:34313
2025-02-09 20:20:17,303 - INFO - Server started...

After reviewing the source code, it became apparent that the program relies on certain environmental variables. I configured these variables as follows:

export LOGGING_VERBOSITY="REG0D" 
export LOGGING_MORE_VERBOSITY="y0uReOnToSomEThinG"
export WS_IP=0.0.0.0
export WS_PORT=12345
# The WebSocket port is depends on the port the server running

The constants.py file provided several hints that trigger under specific conditions:

  • HINT_1: Failure to download TLS certificates from SERVER_IP:SERVER_PORT

  • HINT_2: Invalid SSL certificate detection

  • HINT_3: Failed connection attempt to WS_IP:WS_PORT

HINT_1 = '[HINT] Have you tried REading the binaries? Where are you downloading them from?'
HINT_2 = '[HINT] Have you tried REading the binaries? Is your SSL Context malformed or what?'
HINT_3 = "[HINT] Have you tried REading the binaries? Bruh we can't make a connection..."

Now it works on the local setup.

└─$ ./client
2025-02-08 09:03:54,497 - INFO - DEV_client.crt found.
2025-02-08 09:03:54,497 - INFO - DEV_client.key found.
2025-02-08 09:03:54,497 - INFO - DEV_ca.crt found.
>> Welcome to the chat!

Before proceeding to find flag, I decided to first connect to the remote server. The development certificates do not work with the remote server. From certificate download URL, the "REVW" in http://34.57.139.144:80/REVW/DEV_server.key represented "DEV" in Base64. Following this pattern, we determined that production certificates should be located under "PROD" in Base64 (UFJPRA==).

We retrieved the CA certificates:

wget http://34.57.139.144:80/UFJPRA==/PROD_ca.crt
wget http://34.57.139.144:80/UFJPRA==/PROD_ca.key

Subsequently, we generated client certificates for mTLS:

openssl genrsa -out DEV_client.key 2048
openssl req -new -key DEV_client.key -out DEV_client.csr -subj "/CN=client"
openssl x509 -req -in DEV_client.csr -CA PROD_ca.crt -CAkey PROD_ca.key -CAcreateserial -out DEV_client.crt -days 365 -sha256

After copying PROD_ca.crt to DEV_ca.crt, we can now connect to the remote server.

└─$ export WS_IP=34.123.42.200
└─$ export WS_PORT=80
└─$ ./client
2025-02-09 20:40:02,416 - INFO - DEV_client.crt found.
2025-02-09 20:40:02,416 - INFO - DEV_client.key found.
2025-02-09 20:40:02,416 - INFO - DEV_ca.crt found.
>> Welcome to the chat!

Now, lets go back to find the flag. The flag is found at the dev_server.py by sending a message in the format 5n@k3#88#ENCRYPTION_KEY where the ENCRYPTION_KEY is used to decrypt the encrypted flag using 3DES.

FLAG = '7eb66acfb3652e80ef006143b4e5b6565b84b51355b26e39d2979a3bc873ba394ecae0061bd9522a9639ac4488733ad97d5e5acfb1e3e6f7'
if '5n@k3' not in message:
    # ...
else:
    parts = message.split('#')
    if len(parts) > 2:
        command = parts[1]
        args1 = parts[2]
        if command == '88':
            try:
                decrypted_message = decrypt_3des(bytes.fromhex(FLAG), bytes.fromhex(args1))
                if client == senderId:
                    await clients[client]['listener'].send(f's()p3rR00+: {decrypted_message}0')
            except Exception as E:
                logging.debug(E)
                if client == senderId:
                    await clients[client]['listener'].send('s()p3rR00+: flag{this_is_real_definitely_not_fake}')

Brute-forcing doesn't sounds practical here, so the key is likely provided somewhere.

Oh! I completely forgot about the encrypted 蛇年吉祥.pcapng. Now that we have the private key, we can decrypt the traffic directly in Wireshark.

Here are the decrypted messages:

r00t: Hi there, welcome!
r00t: So, you’re here because Nian is coming to kill us all?
r00t: I see… Well, I can help you with that. No worries. :)
r00t: I hope you’ve read enough into the ancient runes—those are your keys to success.
r00t: As for the key itself, it is the weakness of Nian: 红 火 热闹
r00t: take the 汉语拼音 of those 4 characters, then separate them with a "_" and pad them with 4 pluses
r00t: Oh? Missing some bytes?
r00t: Well… oh darn, Nian has come!
r00t: It has bitten off both my legs… I’m dying…
r00t: I don’t have time to tell you the last part of the key, but maybe if you look into the SSL certificates…
r00t: Encoding? I can’t remember… It sounds like the acid found in mandarin oranges and other citrus fruits…
r00t: Urgh… I’m sorry… I’m losing too much blood…
r00t: We are counting on you…

With this, we can construct the key as hong_huo_re_nao++++, but it is only 19 characters long, meaning we're still missing 5 bytes for the full 3DES key.

The messages also mentioned the SSL certificate, so let's revisit the DEV certificates we downloaded.

Interestingly, the extensions in DEV_server.crt seem unusual. Based on the earlier hint, decoding it using Citrix aligns with the given clue.

└─$ openssl x509 -in DEV_server.crt -text -noout
...
        X509v3 extensions:
            X509v3 Basic Constraints:
                CA:FALSE
            X509v3 Key Usage:
                Digital Signature, Non Repudiation, Key Encipherment
            X509v3 Subject Alternative Name:
                IP Address:0.0.0.0
            X509v3 Extended Key Usage:
                TLS Web Server Authentication
            1.2.12.5:
                ...PBFEJJDMPMFJNMHJJADFPBFEICCHPGFDNGHDJADFPJFMIPCKOKEPMKGPIICNPBFEIFCAOAEFJDDGLDBGPCFHIACFOFEAMFGAJCDHOAEFIPCKOBEEIGCDKGADPCFHJKDPPP
            1.2.12.6:
                ...FKNPHKJMDJPDFGIBCEPDFGJGDDPFFAIBCEKBAEODEGJKDPOOELILCOPIFNNIHNJLDOPEFBJJDMPLFOJCDHPMFJJJDMPNFINNHIIJCMOGEDIBCEOEEBJADFPIFNJNDIOPEK
            1.2.12.7:
                ...MPGKIGCDPFFANFHAJEDBPHFCIDCGPGFDJHDCPLFOJHDCOOELMOGLJKDPPCFHJHDCLHBCPCFHICCHONEIIOCLOGEDMGGDJDDGPNFIJEDBOMEJMMGJJIDNPBFEJMDJPJFMIK
            1.2.12.8:
                ...CPPOFLJPDKPCFHICCHKCAHOLEOIFCAKFAAPGFDJDDGPAFFJPDKPBFEJFDAOGEDMGGDIACFOPEKJNDILNBIPOFLJGDDPPFKJBDEPEFBIHCCOCEHMCGHIMCJOJEMJODLLOBL
            1.2.12.9:
                .$OHECICCHODEGJBDELBBEIDCGLDBGIBCELEBB
...

And using Cyberchef, we obtained the following:

The Last Five Bytes Are Wrong 
The Correct Bytes Combined Together Is Actually 
The Epoch Unix Timestamp In Seconds For Chinese New Year 2025

I was stuck at this step for a long time, as none of the combinations I tried seemed to work. Here were some of the ideas I attempted:

  • Remove the last 5 bytes and replace them with the timestamp.

  • Try different pinyin variations, including hong, hong2, hóng, with different capitalizations.

  • Test various timestamps, including both GMT and UTC+8 Chinese New Year timestamps across all 15 days.

  • Split the key into three parts, since 3DES consists of three keys:

    • k1 = hong, k2 = huo, k3 = renao, then distribute the timestamp among them.

After extensive trial and error, the correct key turned out to be simply appending the timestamp to the hex representation of hong_huo_re_nao++++

from Crypto.Cipher import DES3
from Crypto.Util.Padding import pad, unpad

def decrypt_3des(ciphertext: bytes, key: bytes):
    iv = ciphertext[:DES3.block_size]
    cipher = DES3.new(key, DES3.MODE_CBC, iv=iv)
    decrypted_data = cipher.decrypt(ciphertext[DES3.block_size:])
    return decrypted_data.decode("utf-8","replace")

def main():
    ciphertext = bytes.fromhex('7eb66acfb3652e80ef006143b4e5b6565b84b51355b26e39d2979a3bc873ba394ecae0061bd9522a9639ac4488733ad97d5e5acfb1e3e6f7')
    key = b"hong_huo_re_nao++++".hex()
    key = key + "1738080000"
    result = decrypt_3des(ciphertext,  bytes.fromhex(key))
    print(f"Message: 5n@k3#88#{key}")
    print(f"Decrypted: {result}")

if __name__ == "__main__":
    main()
└─$ python decrypt.py 
Message: 5n@k3#88#686f6e675f68756f5f72655f6e616f2b2b2b2b1738080000
Decrypted: flag{now_you_are_ready_for_the_real_thing}

Now, sending the same message to the remote server, we finally got the flag! 🎉

└─$ s()p3rR00+: CSIT{f1R3_rEd_C0L0UR_L0UD_N01535_SCARE5_n1@n} 
Cool Badge

It was an incredibly fun challenge! Reversing the Python executable and navigating through the various steps was exciting, but figuring out the key was a pain for me. 😩

There was one hint given by CSIT that I never encountered—turns out, it was related to an issue with pycdc.

Is # WARNING: Decompyle incomplete the bane of your existence? 
A Corgi or Issue #234 can help you with that!

Source Code

# client.py
from dotenv import load_dotenv
import asyncio
import websockets
import logging
import threading
import ssl
import os
import urllib.request
import sys
import socket
from common import validate, generate_client_context, generate_random_string
from constants import LOGGING_VERBOSITY, HINT_3

load_dotenv()
logging.basicConfig(level=LOGGING_VERBOSITY, format='%(asctime)s - %(levelname)s - %(message)s')
SERVER_IP = os.getenv('SERVER_IP', '127.127.127.127')
SERVER_PORT = os.getenv('SERVER_PORT', '9999')
WS_IP = os.getenv('WS_IP', '0.0.0.0')
WS_PORT = os.getenv('WS_PORT', '8000')
TIMEOUT = 5
CA_CERT = 'DEV_ca.crt'
CLIENT_CERT = 'DEV_client.crt'
CLIENT_KEY = 'DEV_client.key'
CLIENT_SERVER = f'http://{SERVER_IP}:{SERVER_PORT}/REVW/'
CLIENT_PATH = ''
CERT_URL = f'{CLIENT_SERVER}{CLIENT_CERT}'
CERT_PATH = f'{CLIENT_PATH}{CLIENT_CERT}'
KEY_URL = f'{CLIENT_SERVER}{CLIENT_KEY}'
KEY_PATH = f'{CLIENT_PATH}{CLIENT_KEY}'
CA_URL = f'{CLIENT_SERVER}{CA_CERT}'
CA_PATH = f'{CLIENT_PATH}{CA_CERT}'
validate(CERT_URL, CERT_PATH, logging)
validate(KEY_URL, KEY_PATH, logging)
validate(CA_URL, CA_PATH, logging)
context = generate_client_context(CERT_PATH, KEY_PATH, CA_PATH, logging)

async def listen_messages(uri, headers, log):
    try:
        async with websockets.connect(uri, additional_headers=headers, ssl=context) as websocket:
            while True:
                message = await websocket.recv()
                print(f'>> {message}0')
    except asyncio.TimeoutError:
        return None
    except Exception as E:
        log.debug(f'{HINT_3}')

async def send_commands(uri, headers, log):
    try:
        async with websockets.connect(uri, additional_headers=headers, ssl=context) as websocket:
            while True:
                message = input('')
                if message.strip():
                    await websocket.send(message)
    except asyncio.TimeoutError:
        return None
    except Exception as E:
        log.debug(f'{HINT_3}')

def start_client():
    _string = generate_random_string()
    headersl = {'X-ARBOC': f'{_string}0-listener'}
    headerss = {'X-ARBOC': f'{_string}0-sender'}
    uri = f'wss://{WS_IP}:{WS_PORT}/'
    listener_thread = threading.Thread(target=lambda: asyncio.run(listen_messages(uri, headersl, logging)))
    listener_thread.daemon = True
    listener_thread.start()
    asyncio.run(send_commands(uri, headerss, logging))
if __name__ == '__main__':
    start_client()
# dev_server.py
from dotenv import load_dotenv
import asyncio
import websockets
import logging
import os
from common import validate, generate_server_context, decrypt_3des
from constants import LOGGING_VERBOSITY
load_dotenv()
logging.basicConfig(level=LOGGING_VERBOSITY, format='%(asctime)s - %(levelname)s - %(message)s')
SERVER_IP = os.getenv('SERVER_IP', '127.127.127.127')
SERVER_PORT = os.getenv('SERVER_PORT', '9999')
WS_IP = os.getenv('WS_IP', '0.0.0.0')
WS_PORT = os.getenv('WS_PORT', '8000')
CA_CERT = 'DEV_ca.crt'
SERVER_CERT = 'DEV_server.crt'
SERVER_KEY = 'DEV_server.key'
SERVER_SERVER = f'http://{SERVER_IP}:{SERVER_PORT}/REVW/'
SERVER_PATH = ''
CERT_URL = f'{SERVER_SERVER}{SERVER_CERT}'
CERT_PATH = f'{SERVER_PATH}{SERVER_CERT}'
KEY_URL = f'{SERVER_SERVER}{SERVER_KEY}'
KEY_PATH = f'{SERVER_PATH}{SERVER_KEY}'
CA_URL = f'{SERVER_SERVER}{CA_CERT}'
CA_PATH = f'{SERVER_PATH}{CA_CERT}'
validate(CERT_URL, CERT_PATH, logging)
validate(KEY_URL, KEY_PATH, logging)
validate(CA_URL, CA_PATH, logging)
context = generate_server_context(CERT_PATH, KEY_PATH, CA_PATH, logging)
clients = {}
FLAG = '7eb66acfb3652e80ef006143b4e5b6565b84b51355b26e39d2979a3bc873ba394ecae0061bd9522a9639ac4488733ad97d5e5acfb1e3e6f7'

def header_extraction(websocket):
    headers = websocket.request.headers['X-ARBOC']
    parts = headers.split('-', 1)
    clientId = parts[0]
    clientType = parts[1]
    return [clientId, clientType]

async def handler(websocket):
    parts = header_extraction(websocket)
    clientId = parts[0]
    clientType = parts[1]
    if clientId not in clients:
        clients[clientId] = {clientType: websocket}
    else:
        clients[clientId][clientType] = websocket
    try:
        await websocket.send('Welcome to the chat!')
        async for message in websocket:
            logging.debug(f'Received message: {message}0')
            await broadcast(message, websocket)
    except websockets.exceptions.ConnectionClosed as e:
        logging.error(f'Connection closed: {e}0')

async def broadcast(message, websocket):
    to_remove = []
    senderId = header_extraction(websocket)[0]
    for client in clients:
        try:
            if '5n@k3' not in message:
                if client == senderId:
                    await clients[client]['listener'].send(f'r00t: {message}0')
                else:
                    await clients[client]['listener'].send(f'{senderId}: {message}0')
            elif len(message) <= 5:
                if client == senderId:
                    await clients[client]['listener'].send('s()p3rR00+: run --help')
            else:
                parts = message.split('#')
                if len(parts) > 2:
                    command = parts[1]
                    args1 = parts[2]
                    if command == '90' and len(args1) > 5:
                        os.system(f'ls {args1[:5]}0')
                    if command == '01':
                        os.system('ip addr')
                    if command == '33':
                        os.system('hostname')
                    if command == '27':
                        os.system('cat /proc/cpuinfo')
                    if command == '18':
                        os.system('whoami ')
                    if command == '88':
                        try:
                            decrypted_message = decrypt_3des(bytes.fromhex(FLAG), bytes.fromhex(args1))
                            if client == senderId:
                                await clients[client]['listener'].send(f's()p3rR00+: {decrypted_message}0')
                        except Exception as E:
                            logging.debug(E)
                            if client == senderId:
                                await clients[client]['listener'].send('s()p3rR00+: flag{this_is_real_definitely_not_fake}')
                    if command == '79':
                        os.system('dmesg | tail -n 10')
                    if command == '61':
                        os.system('ps aux')
                    if command == '49':
                        os.system('uptime -p')
                    if command == '55':
                        os.system('lscpu')
                    if command == '72':
                        os.system(f'{args1[:2]}')
        except websockets.exceptions.ConnectionClosed:
            to_remove.append(client)
    for client in to_remove:
        del clients[client]

async def main():
    if context != None:
        server = await websockets.serve(handler, WS_IP, WS_PORT, ssl=context, ping_interval=3600, ping_timeout=3600, close_timeout=600)
        logging.info('Server started...')
        await server.wait_closed()
if __name__ == '__main__':
    asyncio.run(main())
# common.py
import os
import urllib.request
import ssl
import random
import string
from Crypto.Cipher import DES3
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad, unpad
from constants import HINT_1, HINT_2

def generate_random_string(length=8):
    return ''.join(random.choices(string.ascii_letters + string.digits, k=length))

def download_file(url, dest_path, log):
    try:
        with urllib.request.urlopen(url) as response:
            with open(dest_path, 'wb') as f:
                f.write(response.read())
        log.info(f'Downloaded file from {url}0 to {dest_path}0')
        return True
    except Exception as e:
        return False

def validate(URL, FILE, log):
    if not os.path.exists(FILE):
        log.info(f'{FILE} not found. Downloading...')
        if not download_file(URL, FILE, log):
            log.debug(f'{HINT_1}')
    else:
        log.info(f'{FILE} found.')

def decrypt_3des(ciphertext: bytes, key: bytes):
    iv = ciphertext[:DES3.block_size]
    cipher = DES3.new(key, DES3.MODE_CBC, iv=iv)
    decrypted_data = unpad(cipher.decrypt(ciphertext[DES3.block_size:]), DES3.block_size)
    return decrypted_data.decode()

def generate_client_context(CERT_PATH, KEY_PATH, CA_PATH, log):
    try:
        context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=CA_PATH)
        context.options |= ssl.OP_NO_SSLv2
        context.options |= ssl.OP_NO_SSLv3
        context.load_cert_chain(certfile=CERT_PATH, keyfile=KEY_PATH)
        context.options |= ssl.OP_NO_TLSv1_3
        context.set_ciphers('RSA')
        return context
    except Exception as e:
        print(e)
        log.debug(f'{HINT_2}')

def generate_server_context(CERT_PATH, KEY_PATH, CA_PATH, log):
    try:
        context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
        context.load_cert_chain(certfile=CERT_PATH, keyfile=KEY_PATH)
        context.load_verify_locations(cafile=CA_PATH)
        context.verify_mode = ssl.CERT_REQUIRED
        context.options |= ssl.OP_NO_TLSv1_3
        context.set_ciphers('RSA')
        return context
    except Exception as e:
        log.debug(f'{HINT_2}')

def init():
    if not os.path.exists('.env'):
        env_content = '\nLOGGING_VERBOSITY=REG0D\n\nSERVER_IP=34.57.139.144\nSERVER_PORT=80\n\nWS_IP=\nWS_PORT=\n'
        with open('.env', 'w') as env_file:
            env_file.write(env_content.strip())
init()
# constants.py
import logging
import os
from dotenv import load_dotenv
load_dotenv()
LOGGING_VERBOSITY = os.getenv('LOGGING_VERBOSITY', 'NONE')
LOGGING_MORE_VERBOSITY = os.getenv('LOGGING_MORE_VERBOSITY', 'NONE')
if LOGGING_VERBOSITY == 'REG0D' and LOGGING_MORE_VERBOSITY == 'y0uReOnToSomEThinG':
    LOGGING_VERBOSITY = logging.DEBUG
elif LOGGING_VERBOSITY == 'REG0D':
    LOGGING_VERBOSITY = logging.INFO
else:
    LOGGING_VERBOSITY = logging.ERROR
HINT_1 = '[HINT] Have you tried REading the binaries? Where are you downloading them from?'
HINT_2 = '[HINT] Have you tried REading the binaries? Is your SSL Context malformed or what?'
HINT_3 = "[HINT] Have you tried REading the binaries? Bruh we can't make a connection..."

Last updated