🏰 EldoriaNet – XOR Magic and Broken Encryption

An image to describe post

Long ago, a sacred message was sealed away, its meaning obscured by the overlapping echoes of its own magic.
The careless work of an enchanter has left behind a flaw—a weakness hidden within repetition.
With keen eyes and sharper wits, can you untangle the whispers of the past and restore the lost words?

server.py

from db import *
from Crypto.Util import Counter
from Crypto.Cipher import AES
import os
from time import sleep
from datetime import datetime

def err(msg):
    print('\033[91m'+msg+'\033[0m')

def bold(msg):
    print('\033[1m'+msg+'\033[0m')

def ok(msg):
    print('\033[94m'+msg+'\033[0m')

def warn(msg):
    print('\033[93m'+msg+'\033[0m')

def menu():
    print()
    bold('*'*99)
    bold(f"*                                🏰 Welcome to EldoriaNet v0.1! 🏰                                *")
    bold(f"*            A mystical gateway built upon the foundations of the original IRC protocol 📜        *")
    bold(f"*          Every message is sealed with arcane wards and protected by powerful encryption 🔐      *")
    bold('*'*99)
    print()

class MiniIRCServer:
    def __init__(self, host, port):
        self.host = host
        self.port = port
        self.key = os.urandom(32)

    def display_help(self):
        print()
        print('AVAILABLE COMMANDS:\n')
        bold('- HELP')
        print('\tDisplay this help menu.')
        bold('- JOIN #<channel> <key>')
        print('\tConnect to channel #<channel> with the optional key <key>.')
        bold('- LIST')
        print('\tDisplay a list of all the channels in this server.')
        bold('- NAMES #<channel>')
        print('\tDisplay a list of all the members of the channel #<channel>.')
        bold('- QUIT')
        print('\tDisconnect from the current server.')

    def output_message(self, msg):
        enc_body = self.encrypt(msg.encode()).hex()
        print(enc_body, flush=True)
        sleep(0.001)

    def encrypt(self, msg):
        encrypted_message = AES.new(self.key, AES.MODE_CTR, counter=Counter.new(128)).encrypt(msg)
        return encrypted_message
    
    def decrypt(self, ct):
        return self.encrypt(ct)
    
    def list_channels(self):
        bold(f'\n{"*"*10} LIST OF AVAILABLE CHANNELS {"*"*10}\n')
        for i, channel in enumerate(CHANNELS.keys()):
            ok(f'{i+1}. #{channel}')
        bold('\n'+'*'*48)

    def list_channel_members(self, args):
        channel = args[1] if len(args) == 2 else None

        if channel not in CHANNEL_NAMES:
            err(f':{self.host} 403 guest {channel} :No such channel')
            return
        
        is_private = CHANNELS[channel[1:]]['requires_key']
        if is_private:
            err(f':{self.host} 401 guest {channel} :Unauthorized! This is a private channel.')
            return

        bold(f'\n{"*"*10} LIST OF MEMBERS IN {channel} {"*"*10}\n')
        members = CHANNEL_NAMES[channel]
        for i, nickname in enumerate(members):
            print(f'{i+1}. {nickname}')
        bold('\n'+'*'*48)

    def join_channel(self, args):
        channel = args[1] if len(args) > 1 else None
        
        if channel not in CHANNEL_NAMES:
            err(f':{self.host} 403 guest {channel} :No such channel')
            return

        key = args[2] if len(args) > 2 else None

        channel = channel[1:]
        requires_key = CHANNELS[channel]['requires_key']
        channel_key = CHANNELS[channel]['key']

        if (not key and requires_key) or (channel_key and key != channel_key):
            err(f':{self.host} 475 guest {channel} :Cannot join channel (+k) - bad key')
            return
        
        for message in MESSAGES[channel]:
            timestamp = message['timestamp']
            sender = message['sender']
            print(f'{timestamp} <{sender}> : ', end='')
            self.output_message(message['body'])
        
        while True:
            warn('You must set your channel nickname in your first message at any channel. Format: "!nick <nickname>"')
            inp = input('guest > ').split()
            if inp[0] == '!nick' and inp[1]:
                break

        channel_nickname = inp[1]
        while True:
            timestamp = datetime.now().strftime('%H:%M')
            msg = input(f'{timestamp} <{channel_nickname}> : ')
            if msg == '!leave':
                break

    def process_input(self, inp):
        args = inp.split()
        cmd = args[0].upper() if args else None

        if cmd == 'JOIN':
            self.join_channel(args)
        elif cmd == 'LIST':
            self.list_channels()
        elif cmd == 'NAMES':
            self.list_channel_members(args)
        elif cmd == 'HELP':
            self.display_help()
        elif cmd == 'QUIT':
            ok('[!] Thanks for using MiniIRC.')
            return True
        else:
            err('[-] Unknown command.')


server = MiniIRCServer('irc.hackthebox.eu', 31337)

exit_ = False
while not exit_:
    menu()
    inp = input('> ')
    exit_ = server.process_input(inp)
    if exit_:
        break

📡 Recon

The challenge presented a Python-based IRC-like service, accessible via Netcat:

1337sheets@Zenbook:~$ nc 94.237.63.165 51908

Upon connection, I was greeted by the EldoriaNet v0.1 banner — a fantasy-themed IRC world built on "arcane encryption."

An image to describe post

Typing help revealed the available commands. A list command showed two channels: #general and #secret.

> list

********** LIST OF AVAILABLE CHANNELS **********
1. #general
2. #secret
************************************************

> join #secret
:irc.hackthebox.eu 475 guest secret :Cannot join channel (+k) - bad key

Access to #secret was locked. I needed the key.


🔍 Source Code Analysis

After reviewing the provided Python server source code, I discovered two crucial things:

  1. Encryption uses AES-CTR with the same key for every message.

    def encrypt(self, msg):
        return AES.new(self.key, AES.MODE_CTR, counter=Counter.new(128)).encrypt(msg)
    

    This meant the server was vulnerable to nonce reuse attacks, where XORing ciphertexts could reveal plaintext.

  2. To leave a channel, you just type !leave.

    if msg == '!leave':
        break
    

An image to describe post


🧠 The Flaw – Reused Nonce in AES-CTR

AES-CTR mode is essentially a stream cipher: plaintext is XORed with a keystream. If the same key and nonce are used across multiple messages, any ciphertexts can be XORed together to cancel the keystream — leaving you with plaintext XOR plaintext, which is extremely breakable if you guess part of a message.


🛠️ Exploit Script: Decrypting #general and #secret

I wrote this script to automate decryption using known plaintext recovery:

import re
from pwn import *
from binascii import unhexlify

def xor_bytes(a, b):
    return bytes(x ^ y for x, y in zip(a, b))

host = "94.237.63.165"
port = 51908

# Recovered a small ciphertext known to correspond to "!leave"
partial_key = xor_bytes(unhexlify('db1653c1c20a'), b'!leave')
print("Partial key:", partial_key)

r = remote(host, port)

# Join #general
r.sendline(b"JOIN #general")
r.recvuntil(b"> ")
data = r.recvuntil(b"guest >").decode()

# Extract all ciphertexts from messages
ciphertexts = re.findall(r':\s([0-9a-fA-F]+)', data)

# Known plaintext assumption for one message
known_plain = b"Not yet, but I'm checking some unusual signals. If they sense us, we might have to channnel."
extended_key = xor_bytes(unhexlify(ciphertexts[5]), known_plain)

print("\n[+] Recovered key from #general:\n", extended_key, "\n")

# Decrypt all general messages
for crypt in ciphertexts:
    decrypted = xor_bytes(unhexlify(crypt), extended_key)
    print(decrypted.decode())

# Set nick and leave
r.sendline(b"!nick guest")
r.recvuntil(b"> ")
r.sendline(b"!leave")
r.recvuntil(b"> ")

# Join #secret using discovered key
r.sendline(b"JOIN #secret %mi2gvHHCV5f_kcb=Z4vULqoYJ&oR")
r.recvuntil(b"> ")
secret_data = r.recvuntil(b"guest >").decode()
secret_ciphertexts = re.findall(r':\s([0-9a-fA-F]+)', secret_data)

# Another known-plaintext attack on a message from #secret
known_secret = b"I've been studying the traces left behind by our previous incantations, and something feels wrong. Our network of spells has sent out signals to an unknown beacon-one that none of us."
secret_key = xor_bytes(unhexlify(secret_ciphertexts[5]), known_secret)

print("\n[+] Recovered key for #secret:\n", secret_key, "\n")

# Decrypt #secret messages
for crypt in secret_ciphertexts:
    decrypted = xor_bytes(unhexlify(crypt), secret_key)
    print(decrypted.decode('latin-1'))

r.close()

🔓 Output: Decryption Complete

✅ Decrypting #general:

An image to describe post

You can see readable messages once the keystream is recovered.


✅ Joining #secret and Revealing the Hidden Wisdom:

An image to describe post

Thanks to the repeated keystream flaw, I was able to fully decrypt #secret and recover the hidden flag/message.


🎯 Takeaway

This challenge is a textbook case of why you should never reuse a nonce or keystream in stream ciphers like AES-CTR. One known plaintext is all it takes to unravel the entire communication.

🔐 Crypto mistake: AES-CTR with reused nonce
🧠 Attack vector: Known plaintext → recover keystream → decrypt everything
🏆 Reward: Full access to a secret IRC channel


Let the enchanter's mistake serve as a reminder: even arcane encryption can crumble in the hands of a careless mage.

🔓✨