Holmes CTF 2026 — S03 Whisper Chain

// CLASSIFIED OPERATION REPORT //

Holmes CTF 2026 — S03 Whisper Chain

Forense de un servidor XMPP/Prosody vivo (MurkNet): del certificado TLS al descifrado de las órdenes, encadenando registro in-band, metadatos PDF, pubsub AES y un pivote OSINT a la Wayback Machine.

HTB MEDIUM 17 de septiembre de 2026
#DFIR#XMPP#OSINT#Crypto#Forensics#Holmes CTF 2026
Realizado en entornos autorizados. Solo con fines educativos.

Reto: Holmes CTF 2026 — Sherlock 03 “Whisper Chain” · Forense de XMPP/Prosody + OSINT. Parte del arco The Reichenbach Directive.

Dificultad: Medium · Flags: 8/8 · Disciplinas: DFIR de servicio vivo · XMPP/Prosody · metadatos · criptografía simétrica · OSINT de archivo web.

Navegación: 🇪🇸 Español · 🇬🇧 English

🇪🇸 Español

Escenario

Watson sobrevive. En Silvertown queda un portátil con un intento apresurado de borrar la actividad reciente; solo persiste un cliente de comunicaciones cuyo servidor, cuenta y propósito son desconocidos. El operador hablaba a través de una red privada (MurkNet) bajo un alias.

El reto solo entrega un PDF con la historia — toda la evidencia está en el objetivo vivo: un servidor XMPP (Prosody) sobre 10.129.x.x. No hay disco, ni memoria, ni pcap: hay que recuperar lo que sobrevivió al wipe hablando con el servicio, entender qué voces pertenecían juntas y adónde viajaban las órdenes cuando la conversación pública se detenía. Dificultad: medium.

La metáfora del título es literal y es la clave del reto. Cada eslabón de la investigación entrega la clave del siguiente: el certificado nombra los subdominios, los subdominios abren los canales, los canales filtran un email, el email pide una password que está pegada por error en otro canal, la cuenta abre el pubsub cifrado, el pubsub exige una clave que solo vive en un blog borrado, y el blog resucita en la Wayback Machine con el descifrador que finalmente revela la wallet y el objetivo final. Un susurro tras otro.

Encuadre. Todo el trabajo se hace contra una infraestructura de laboratorio de HTB, autorizada y efímera. El objetivo es defensivo/didáctico: reconstruir la operación de un APT ficticio (Napoleon/MurkNet) para entender su cadena de mando y sus fallos de OPSEC, no atacar sistemas de terceros.

Artefacto y herramientas

  • Objetivo vivo: servidor XMPP/Prosody (murknet.htb) detrás de nginx, con tres componentes: groups (MUC), command (pubsub) y upload (HTTP File Upload).
  • Recon: nmap, openssl s_client (certificado TLS y SANs vía STARTTLS y vía 443).
  • Acceso: slixmpp con registro in-band (XEP-0077) y verificación TLS desactivada (cert autofirmado).
  • Enumeración/lectura: disco#items (XEP-0030), MAM (XEP-0313), MUC (XEP-0045), pubsub (XEP-0060), HTTP File Upload (XEP-0363).
  • Metadatos: exiftool sobre los PDFs subidos a upload.murknet.htb.
  • Cripto: openssl enc -d -aes-256-cbc -pbkdf2 -iter 120000 -md sha256 sobre payloads base64 con cabecera Salted__.
  • OSINT: Wayback Machine / API CDX (web.archive.org) para recuperar un blog de threat-intel borrado y su decrypt_command.sh.

Preparativos. Antes de tocar nada, fijamos los nombres en /etc/hosts para que STARTTLS y la subida HTTP resuelvan bien el SNI/Host y el certificado valide contra el hostname esperado:

# 10.129.x.x es la IP del target del lab
echo "10.129.x.x murknet.htb groups.murknet.htb command.murknet.htb upload.murknet.htb submit-creds.murknet.htb" | sudo tee -a /etc/hosts

Metodología (paso a paso)

1. Recon — un Prosody detrás de nginx

Por qué. No sabemos ni el dominio XMPP ni los componentes. Un servidor Prosody típico expone un cuarteto de puertos muy reconocible; el certificado TLS, aunque sea autofirmado, está obligado a declarar en sus Subject Alternative Names todos los hostnames virtuales que atiende — es decir, nos regala el mapa del despliegue y la primera flag.

# barrido completo + versión sobre puertos abiertos
nmap -Pn -p- --min-rate 2000 10.129.x.x -oA whisper_allports
nmap -Pn -p22,443,5222,5269,5281 -sV -sC 10.129.x.x -oA whisper_svc
22/tcp   open  ssh       OpenSSH 9.6p1
443/tcp  open  ssl/http  nginx 1.24.0
5222/tcp open  xmpp-client   # cliente (STARTTLS)
5269/tcp open  xmpp-server   # s2s (server-to-server)
5281/tcp open  ssl/http      # Prosody mod_http (BOSH/WebSocket/Upload)

La firma es inequívoca: 5222 (c2s con STARTTLS), 5269 (federación s2s) y 5281 (el HTTP interno de Prosody). El 443/nginx es el reverse proxy que publica los vhosts HTTP (entre ellos upload). El certificado autofirmado, presentado en 443 y en 5222, revela la identidad del despliegue y da directamente Q1.

El truco del SANs. Se puede leer el certificado de dos maneras — desde el 443 con SNI, o directamente del puerto XMPP negociando STARTTLS; ambas devuelven el mismo cert:

# vía 443 con SNI
echo | openssl s_client -connect 10.129.x.x:443 -servername murknet.htb 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName

# vía XMPP c2s negociando STARTTLS (útil si el 443 está filtrado)
echo | openssl s_client -connect 10.129.x.x:5222 -starttls xmpp -xmpphost murknet.htb 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name:
    DNS:murknet.htb, DNS:groups.murknet.htb, DNS:command.murknet.htb, DNS:upload.murknet.htb

Subdominios clave: groups (MUC/salas de chat multiusuario), command (el dispatcher pubsub por donde viajan las órdenes) y upload (el file share XEP-0363 donde se cuelgan los PDFs). El orden pedido por la pregunta es hostname principal primero, resto alfabético.

Q1 — SANs del certificado TLS: murknet.htb,command.murknet.htb,groups.murknet.htb,upload.murknet.htb

2. Acceso — registro in-band y enumeración de canales

Por qué. No tenemos credenciales, pero el servidor anuncia <register/> en sus stream features: registro in-band habilitado (XEP-0077, IBR). Podemos crearnos una cuenta desechable y, ya autenticados, preguntarle al servidor por sus componentes (disco#items) y por los canales del MUC. El certificado es autofirmado, así que hay que desactivar la verificación TLS en el cliente.

Nota crítica de OPSEC del lab: las cuentas nuevas (burner) se purgan casi al instante — hay un job que las limpia. Por eso hay que registrar y trabajar en la misma sesión: en cuanto tienes stream, enumeras y lees; no cierres y vuelvas.

Confirmamos primero que el IBR está anunciado:

# las stream features anuncian <register/> si el IBR está activo
echo "<stream:stream to='murknet.htb' xmlns='jabber:client' \
xmlns:stream='http://etherx.jabber.org/streams' version='1.0'>" \
  | openssl s_client -connect 10.129.x.x:5222 -starttls xmpp -xmpphost murknet.htb -quiet 2>/dev/null \
  | grep -o 'register'

Y lo automatizamos con slixmpp, que trae plugins para todos los XEP que necesitamos:

#!/usr/bin/env python3
import asyncio, ssl, slixmpp

class Burner(slixmpp.ClientXMPP):
    def __init__(self, jid, pwd):
        super().__init__(jid, pwd)
        for x in ('xep_0030','xep_0045','xep_0060','xep_0077','xep_0313','xep_0363'):
            self.register_plugin(x)          # disco, MUC, pubsub, IBR, MAM, HTTP Upload
        self['xep_0077'].force_registration = True
        self.add_event_handler('register', self._register)
        self.add_event_handler('session_start', self._start)

    async def _register(self, iq):
        resp = self.Iq(); resp['type'] = 'set'
        resp['register']['username'] = self.boundjid.user
        resp['register']['password'] = self.password
        await resp.send()                     # crea la cuenta burner

    async def _start(self, _):
        self.send_presence(); await self.get_roster()
        # disco#items sobre el componente de salas
        items = await self['xep_0030'].get_items(jid='groups.murknet.htb')
        for it in items['disco_items']['items']:
            print(it[0], '|', it[2])          # JID | nombre del canal
        self.disconnect()

x = Burner('burner01@murknet.htb', 'Burner!2026')
x.ssl_context.check_hostname = False          # cert autofirmado
x.ssl_context.verify_mode   = ssl.CERT_NONE
x.connect(('10.129.x.x', 5222))
x.process(forever=False)

El disco#items sobre groups.murknet.htb — equivalente al stanza crudo <iq type='get' to='groups.murknet.htb'><query xmlns='http://jabber.org/protocol/disco#items'/></iq> — lista los canales públicos:

resources@groups.murknet.htb  | Resources
rules@groups.murknet.htb      | Rules
random@groups.murknet.htb     | Random
infra@groups.murknet.htb      | Infrastructure

La pregunta pide los nombres en orden alfabético.

Q2 — Canales públicos del XMPP (alfabético): Infrastructure,Random,Resources,Rules

3. El primer susurro — email en metadatos, password en #infra

Por qué. Los canales públicos son historia viva: Prosody guarda MAM (Message Archive Management, XEP-0313), así que podemos rebobinar todo lo dicho. Entre los mensajes hay enlaces a PDFs colgados en upload.murknet.htb (XEP-0363). Los documentos ofimáticos casi siempre filtran quién los creó en sus metadatos: es OSINT gratis que nos da el email de un miembro real. Con el email, solo falta la password — y alguien la pegó por error en un canal.

Consultamos el archivo de una sala con MAM. En slixmpp:

# dentro de _start(): recupera el historial completo de #infra
async for msg in self['xep_0313'].iterate(jid='infra@groups.murknet.htb',
                                           amount=200, rsm={'max': 200}):
    body = msg['mam_result']['forwarded']['stanza']['body']
    if body: print(body)

…que por debajo emite el stanza estándar de MAM:

<iq type='set' to='infra@groups.murknet.htb' id='mam1'>
  <query xmlns='urn:xmpp:mam:2'>
    <set xmlns='http://jabber.org/protocol/rsm'><max>200</max></set>
  </query>
</iq>

En el historial aparecen las URLs de los PDFs. Los descargamos del file share y les pasamos exiftool:

# el enlace HTTP Upload lleva un UUID de un solo uso
curl -sk "https://upload.murknet.htb/<uuid>/Operational_Onboarding_Guide_v3.2.pdf" -o guide.pdf
exiftool -Author -Creator -Producer -CreateDate guide.pdf
Author  : zytglogge88@murknet.htb
Creator : swissclock          # Zytglogge = torre del reloj de Berna

El Operational Onboarding Guide filtra la identidad de un miembro: el email zytglogge88@murknet.htb y el alias swissclock. Zytglogge es la torre del reloj medieval de Berna — un guiño temático (reloj) que enseguida encaja con la password.

La contraseña estaba a la vista: en el canal #infra, rattlesnake pega por error las “contraseñas temporales” antiguas de un lote de altas. En vez de forzar login por login, dejamos que la temática desempate — swissclock ↔ reloj ↔ TickTock:

# passwords filtradas en #infra (lote de altas antiguo)
KillBill2025!   K4w4Bong424!   Northwind225!   TickTock24!   <-- válida

Autenticando zytglogge88@murknet.htb:TickTock24! entramos como swissclock, un miembro real de la célula — no un burner —, lo que desbloquea los chats privados 1-a-1 y las salas de operación que a un desconocido le están vedadas.

Q3 — Credenciales de un miembro del APT: zytglogge88@murknet.htb:TickTock24!

4. Dentro de la célula — tres operaciones y un dispatcher cifrado

Por qué. Como swissclock ya no somos observadores: tenemos MAM personal, DMs y acceso a las salas privadas donde se coordina de verdad. Aquí aparece el corazón del reto: el componente command.murknet.htb es un servicio pubsub (XEP-0060) que funciona como dead drop — cada operación tiene su nodo con las órdenes publicadas cifradas (OpenSSL Salted__ / AES-256-CBC, payload en base64). Enumerar los nodos y sus items nos da el mapa de operaciones.

# nodos del dispatcher pubsub
nodes = await self['xep_0060'].get_nodes('command.murknet.htb')
for n in nodes['disco_items']['items']:
    print('NODE', n[1])
    items = await self['xep_0060'].get_items('command.murknet.htb', n[1])
    for it in items['pubsub']['items']:
        print('  ', it['id'], it['payload'].text[:60], '...')   # base64 cifrado

Aparecen tres operaciones:

  • op_sparkling → robo de criptoactivos (la 1ª operación)
  • op_snatch → secuestro de Watson
  • op_dominance → “next operation”

La trampa central. En un DM, rattlesnake entrega a swissclock la clave rotada: The new key is SHALLOWBLUE. Es tentador usarla para todo — pero SHALLOWBLUE no descifra los comandos ya publicados de op_sparkling/op_snatch, porque esos items son anteriores a la rotación. La clave vieja con la que se cifraron no está en el XMPP: hay que recuperarla del descifrador filtrado (el pivote OSINT del paso 5). Regla mental: si la clave nueva da basura, el mensaje es pre-rotación.

Mientras tanto, las salas de operación reparten roles y objetivos. En op_snatch, el colonel asigna la logística del secuestro:

# sala op_snatch — reparto de roles (colonel)
"Dynamite handles transport. Spur handles custody after delivery"
dynamite: "I'm starting the operation now"

Quien ejecuta el traslado/agarre de Watson es dynamite (Spur solo se encarga de la custodia después de la entrega).

Q6 — Apodo del afiliado que secuestró a Watson: dynamite

Y en la sala privada op_dominance, el briefing de doctor nombra la operación final y su objetivo:

# sala privada op_dominance — briefing de doctor
"The next operation is unlike anything we've handled before…
 Our objective has a name: DIOGENES"

Q8 — Última operación planeada y su objetivo: Dominance,DIOGENES

5. El pivote OSINT — “nothing is ever truly deleted once enough eyes have seen it”

Por qué. Los comandos publicados están cifrados con una clave que no vive en el chat. La sala op_sparkling guarda la reacción de la célula a un informe de threat-intel externo que les atribuyó el malware BalanceRAT. rattlesnake (alias Porlock) presume de haber borrado el blog — “I personally nuked it. It’s gone. try opening it now” — y doctor le corrige con la frase que es la pista literal del reto: “nothing is ever truly deleted once enough eyes have seen it”. timothy pega la URL, con el host caído a propósito:

http://security.billblog.co.uk/threat/BalanceRAT-analysis-and-attribution

El host real está muerto, pero la copia vive en la Wayback Machine. Consultamos la API CDX para listar snapshots y descubrir recursos asociados (una imagen delatora y el script):

# lista de capturas y recursos bajo ese host
curl -s "http://web.archive.org/cdx/search/cdx?url=security.billblog.co.uk*&output=text&collapse=urlkey"
20260805 .../threat/BalanceRAT-analysis-and-attribution/                200
20260805 .../threat/BalanceRAT-analysis-and-attribution/porlock.png     200
20260805 .../threat/BalanceRAT-analysis-and-attribution/decrypt_command.sh  200

Recuperamos el artículo y sus recursos por el proxy de la Wayback. El sufijo id_ sirve el objeto crudo, sin la barra de navegación de archive.org:

BASE="https://web.archive.org/web/20260805000000id_/http://security.billblog.co.uk/threat/BalanceRAT-analysis-and-attribution"
curl -s "$BASE/"                 -o article.html
curl -s "$BASE/porlock.png"      -o porlock.png
curl -s "$BASE/decrypt_command.sh" -o decrypt_command.sh

El artículo (“Ghost Balance”) mapea las identidades online del alias Porlock (= rattlesnake), incluida su cuenta de foros, y adjunta el descifrador completo. La cuenta de RRSS del miembro es la respuesta a Q4.

Q4 — URL de la cuenta de RRSS de un miembro del APT: https://stonedforums.htb/@porlock

El decrypt_command.sh recuperado del blog archivado revela la clave vieja y, sobre todo, los parámetros exactos de OpenSSL:

#!/usr/bin/env bash
KEY='BLACKFENLOTTE'                # clave ANTIGUA (pre-rotación)
printf '%s' "$1" | base64 -d |
  openssl enc -d -aes-256-cbc -pbkdf2 -iter 120000 -md sha256 -pass "pass:${KEY}"

Cada operación usa su propia clave con el mismo método: op_sparkling → BLACKFENLOTTE, op_snatch → SHALLOWBLUE (la rotada). El detalle que rompía el descifrado a mano antes de tener el script: faltaban -pbkdf2 -iter 120000 -md sha256. Sin esa derivación de clave idéntica, OpenSSL produce basura aunque la contraseña sea correcta.

6. Descifrado — las órdenes en claro

Por qué. Con el método exacto y las dos claves, ya podemos convertir cada payload base64 del pubsub en texto plano. Conviene verificar primero la cabecera Salted__ (así confirmamos que es un blob de openssl enc con sal, no otra cosa):

# 1) inspección: los primeros 8 bytes deben ser "Salted__"
echo -n "$PAYLOAD_B64" | base64 -d | head -c 16 | xxd
# 00000000: 5361 6c74 6564 5f5f ....  Salted__....

# 2) descifrado de op_sparkling con la clave VIEJA
echo -n "$PAYLOAD_B64" | base64 -d \
  | openssl enc -d -aes-256-cbc -pbkdf2 -iter 120000 -md sha256 -pass pass:BLACKFENLOTTE

Con BLACKFENLOTTE se descifra op_sparkling (robo de criptoactivos):

cmd-02 Evilginx server: 185.203.11.109
cmd-03 BTC: 1BfQc4twbZrUSrtKdwW8TtSN6o1SUezWkX · 1BcanTJp… · 1BHoNZGG…
cmd-04 XMR Address: 429x3WVq1ucARGXx6NEwL4Sg4iowfW5ZWMAqEDErLxrWdg4ffkonB5tNxg85BKGjDqDQRfBERANhgf6DnGjjFyDR5L7uwye
cmd-05 Bank creds -> https://submit-creds.murknet.htb:9099
cmd-06 New C2 (BalanceRAT): 34.54.165.186

La wallet Monero de cmd-04 es la de la primera operación.

Q5 — Wallet XMR usada en la primera operación: 429x3WVq1ucARGXx6NEwL4Sg4iowfW5ZWMAqEDErLxrWdg4ffkonB5tNxg85BKGjDqDQRfBERANhgf6DnGjjFyDR5L7uwye

Con SHALLOWBLUE (misma línea, cambiando solo -pass pass:SHALLOWBLUE) se descifra op_snatch (secuestro de Watson):

cmd-02 Watson… red Ford Fiesta, matrícula WT609DXT
cmd-03 He just stopped at Pembridge Square
cmd-04 …turned onto Bayswater Road. Stay right on his heels!
cmd-05 He's about to enter Victoria Station. He must not reach the tracks. Act NOW
cmd-06 Heading toward Silvertown — 51°30'17.6"N, 0°02'05.1"E
cmd-08 Sector B. Row 6. Container 75JM77. Watchword: Chaos Is Order
cmd-09 Take the car to the junkyard in Walton-on-Thames. Ask for Richard.
cmd-10 Start cleanup process IMMEDIATELY

La secuencia narra el operativo minuto a minuto: siguen a Watson desde Pembridge Square por Bayswater Road; el agarre se ejecuta en Victoria Station (cmd-05, “He must not reach the tracks. Act NOW”); después lo trasladan a la guarida de Silvertown (cmd-06/cmd-08, contenedor 75JM77), que es exactamente donde el PDF de la historia sitúa los objetos recuperados. La pregunta pide dónde lo secuestran, no dónde lo retienen.

Q7 — Dónde ha sido secuestrado Watson: Victoria Station (captura ≠ cautiverio)

Cronología

FaseHito de la investigaciónArtefacto / técnica
T0Portátil incautado en Silvertown; solo un cliente XMPP supervivientePDF de historia
T1nmap → stack Prosody (5222/5269/5281 + nginx 443)recon de puertos
T2SANs del cert autofirmado → 4 hostnames (Q1)openssl s_client / x509
T3IBR activo → cuenta burner con slixmpp; disco#items → 4 canales (Q2)XEP-0077 / XEP-0030
T4MAM de las salas → PDFs en upload; exiftool filtra email+aliasXEP-0313 / XEP-0363 / metadatos
T5Password temática pegada en #infra → login como swissclock (Q3)reutilización/fuga de credenciales
T6Pubsub command.* → 3 operaciones cifradas; DM entrega SHALLOWBLUEXEP-0060
T7Roles en op_snatch → dynamite (Q6); briefing op_dominance → DIOGENES (Q8)lectura de MUC
T8Pista “nothing is ever truly deleted” → CDX/Wayback recupera el blogOSINT de archivo
T9Blog archivado → cuenta @porlock (Q4) + decrypt_command.sh (BLACKFENLOTTE, -iter 120000)Wayback + análisis del script
T10Descifrado: op_sparkling → wallet XMR (Q5); op_snatch → Victoria Station (Q7)openssl enc -d

IOCs

TipoIndicadorContexto
Dominiomurknet.htb, groups.murknet.htb, command.murknet.htb, upload.murknet.htbInfra XMPP/Prosody de la célula (SANs del cert)
Dominio/puertosubmit-creds.murknet.htb:9099Colector de credenciales bancarias robadas (op_sparkling cmd-05)
Dominiosecurity.billblog.co.ukBlog de threat-intel borrado; recuperable en Wayback
Dominiostonedforums.htb/@porlockCuenta de foros del alias Porlock (= rattlesnake)
IPv4185.203.11.109Servidor Evilginx (phishing AiTM) — op_sparkling cmd-02
IPv434.54.165.186Nuevo C2 de BalanceRAT — op_sparkling cmd-06
Wallet BTC1BfQc4twbZrUSrtKdwW8TtSN6o1SUezWkX, 1BcanTJp…, 1BHoNZGG…Destino del robo cripto
Wallet XMR429x3WVq1ucARGXx6NEwL4Sg4iowfW5ZWMAqEDErLxrWdg4ffkonB5tNxg85BKGjDqDQRfBERANhgf6DnGjjFyDR5L7uwyeWallet Monero de la 1ª operación
MalwareBalanceRATRAT atribuido a la célula en el informe “Ghost Balance”
Clave criptoBLACKFENLOTTE (op_sparkling), SHALLOWBLUE (op_snatch, rotada)AES-256-CBC / PBKDF2 iter 120000
Credencialzytglogge88@murknet.htb:TickTock24! (alias swissclock)Cuenta comprometida de la célula
Aliasswissclock, rattlesnake/Porlock, doctor, colonel, dynamite, spur, timothyMiembros de la célula
VehículoFord Fiesta rojo, matrícula WT609DXTCoche de Watson (op_snatch)
Geo51°30'17.6"N, 0°02'05.1"E — Silvertown; Contenedor 75JM77, watchword Chaos Is OrderGuarida/cautiverio

Mapeo MITRE ATT&CK

TTPs del adversario observadas al reconstruir la operación:

TácticaTécnicaEvidencia en el reto
Resource DevelopmentT1583.001 Acquire Infrastructure: Domainsmurknet.htb + subdominios propios
Resource DevelopmentT1587.001 Develop Capabilities: MalwareBalanceRAT
Resource DevelopmentT1585.001 Establish Accounts: Social Media@porlock en stonedforums.htb
Command and ControlT1071.001 Application Layer Protocol: Web/MessagingXMPP (Prosody) como canal de mando
Command and ControlT1102 Web Service (dead drop)pubsub XEP-0060 como buzón de órdenes
Command and ControlT1573.001 Encrypted Channel: Symmetric Cryptographyórdenes en AES-256-CBC / PBKDF2
Command and ControlT1573.002 + rotación de clavesrotación BLACKFENLOTTE → SHALLOWBLUE
Credential AccessT1557 Adversary-in-the-Middleservidor Evilginx (185.203.11.109)
Collection / Initial AccessT1566 Phishing / T1598 Phishing for Informationrobo de credenciales bancarias → submit-creds:9099
ImpactT1657 Financial Theftrobo de criptoactivos (op_sparkling), wallets BTC/XMR
Defense EvasionT1070 Indicator Removal”I personally nuked it” — borrado del blog de atribución
— (mundo físico)Secuestro de Watson (op_snatch)fuera del alcance ATT&CK; incluido por contexto narrativo

Detección y remediación

Para el operador del servidor XMPP (lecciones de OPSEC del APT, aplicables a un defensor):

  • Desactivar el registro in-band en Prosody (allow_registration = false) o exigir aprobación de admin (mod_register_web). Un IBR abierto permitió el burner y toda la enumeración.
  • Certificados propios de una CA, no autofirmados: el autofirmado no aporta seguridad y sus SANs filtraron el mapa completo del despliegue. Al menos, no listar todos los vhosts sensibles en un único cert público.
  • Retención de MAM mínima y por defecto desactivada en salas sensibles: el archivo completo dejó reconstruir meses de coordinación. Purgar y limitar max_archive_query_results.
  • Higiene de credenciales: nunca pegar lotes de “contraseñas temporales” en un canal (#infra), no usar passwords temáticas/adivinables (TickTock24! ↔ swissclock), y rotar de verdad revocando las cuentas afectadas.
  • Scrubbing de metadatos antes de subir documentos: exiftool -all= guide.pdf habría evitado filtrar Author/Creator. Un pipeline de subida debería normalizar metadatos.
  • Cifrado en reposo tras rotación: rotar la clave (SHALLOWBLUE) sin re-cifrar los items antiguos dejó los comandos previos descifrables con la clave vieja recuperable por OSINT. Al rotar, re-encriptar o borrar el histórico.
  • El borrado no es borrado: un host caído sigue vivo en la Wayback Machine / cachés. Para infra propia filtrada, solicitar la exclusión del archivo y asumir que la copia ya circula.

Para el equipo de detección (blue team) que vigila una red parecida:

  • Alertar sobre XMPP c2s/s2s (5222/5269/5281) hacia destinos no corporativos, y sobre s2s federado con dominios desconocidos.
  • Inspeccionar payloads de pubsub/MUC en busca de blobs base64 con cabecera Salted__ (U2FsdGVk en base64): indicador fuerte de comandos cifrados con openssl enc.
  • Correlacionar los IOCs de red (Evilginx 185.203.11.109, C2 34.54.165.186, submit-creds.murknet.htb:9099) contra logs de proxy/DNS/firewall.
  • Vigilar wallets BTC/XMR conocidas en fuentes de inteligencia y monitorizar la creación de cuentas de foros/RRSS asociadas a alias del actor.

Respuestas / flags

#PreguntaRespuesta
Q1SANs del certificado TLS (hostname principal primero, resto alfabético)murknet.htb,command.murknet.htb,groups.murknet.htb,upload.murknet.htb
Q2Canales públicos del servidor XMPP (orden alfabético)Infrastructure,Random,Resources,Rules
Q3Credenciales de un miembro del APT (email:password)zytglogge88@murknet.htb:TickTock24!
Q4URL de la cuenta de RRSS de un miembro del APThttps://stonedforums.htb/@porlock
Q5Wallet XMR usada en la primera operación429x3WVq1ucARGXx6NEwL4Sg4iowfW5ZWMAqEDErLxrWdg4ffkonB5tNxg85BKGjDqDQRfBERANhgf6DnGjjFyDR5L7uwye
Q6Apodo del afiliado que secuestró a Watsondynamite
Q7Dónde ha sido secuestrado WatsonVictoria Station
Q8Última operación planeada y su objetivoDominance,DIOGENES

Lecciones

  • Cada eslabón entrega la clave del siguiente — de ahí “Whisper Chain”: Cert TLS → SANs; registro XMPP → canales; metadatos PDF → email; canal infra → password; login swissclock → pubsub; DM rattlesnake → SHALLOWBLUE; Wayback → decryptor + BLACKFENLOTTE + Porlock; openssl → wallet + ubicación.
  • La trampa central de cripto: los comandos publicados usan la clave vieja (BLACKFENLOTTE, del blog), no la rotada del DM (SHALLOWBLUE). Si la clave nueva da basura, el mensaje es pre-rotación.
  • Los parámetros de KDF son parte de la clave: sin -pbkdf2 -iter 120000 -md sha256 idénticos, la contraseña correcta produce basura. El script archivado no solo daba la clave: daba el método.
  • Registrar y trabajar en la misma sesión: las cuentas IBR se purgan casi al instante en este Prosody.
  • Metadatos = OSINT gratis: exiftool sobre un PDF subido filtró Author/Creator y desbloqueó una cuenta real.
  • ”Nada se borra del todo”: un host caído a propósito seguía vivo en la Wayback Machine (CDX), que guardaba el decryptor completo y la imagen delatora.
  • Distinguir captura vs cautiverio: a Watson lo capturan en Victoria Station y lo retienen en Silvertown; la pregunta pedía el lugar del secuestro.

Serie · The Reichenbach Directive

SherlockTítuloEnlace
S01Silent Dividend../2026-09-15-holmes2026-s01-silent-dividend/
S02Bottle Out../2026-09-16-holmes2026-s02-bottle-out/
S03Whisper Chain — este writeup../2026-09-17-holmes2026-s03-whisper-chain/
S04Paper Ghost../2026-09-18-holmes2026-s04-paper-ghost/
S05Poisoned Branch../2026-09-19-holmes2026-s05-poisoned-branch/
S06Silent Passenger../2026-09-20-holmes2026-s06-silent-passenger/
S07Iron Feather../2026-09-21-holmes2026-s07-iron-feather/
S08Borrowed Name../2026-09-22-holmes2026-s08-borrowed-name/
S09Last Light../2026-09-23-holmes2026-s09-last-light/

🇬🇧 English

Scenario

Watson survives. In Silvertown a laptop is left behind with a hasty attempt to wipe recent activity; only a comms client persists, its server, account and purpose unknown. The operator spoke through a private network (MurkNet) under an alias.

The challenge ships only a story PDF — all the evidence lives on the live target: an XMPP (Prosody) server on 10.129.x.x. There is no disk image, no memory dump, no pcap: you must recover what survived the wipe by talking to the service itself, work out which voices belonged together, and where the orders travelled once the public conversation stopped. Difficulty: medium.

The title is literal and it is the whole point. Every link of the investigation hands you the key to the next: the certificate names the subdomains, the subdomains open the channels, a channel leaks an email, the email needs a password that someone pasted by mistake into another channel, the account opens the encrypted pubsub, the pubsub demands a key that only lives on a deleted blog, and the blog comes back to life in the Wayback Machine carrying the decryptor that finally reveals the wallet and the final objective. One whisper after another.

Framing. All work is done against authorized, ephemeral HTB lab infrastructure. The goal is defensive/educational: reconstruct a fictional APT’s operation (Napoleon/MurkNet) to understand its chain of command and its OPSEC failures, not to attack third-party systems.

Artifact and tooling

  • Live target: XMPP/Prosody server (murknet.htb) behind nginx, with three components: groups (MUC), command (pubsub) and upload (HTTP File Upload).
  • Recon: nmap, openssl s_client (TLS certificate and SANs via STARTTLS and via 443).
  • Access: slixmpp with in-band registration (XEP-0077) and TLS verification disabled (self-signed cert).
  • Enumeration/reading: disco#items (XEP-0030), MAM (XEP-0313), MUC (XEP-0045), pubsub (XEP-0060), HTTP File Upload (XEP-0363).
  • Metadata: exiftool on the PDFs uploaded to upload.murknet.htb.
  • Crypto: openssl enc -d -aes-256-cbc -pbkdf2 -iter 120000 -md sha256 on base64 payloads with a Salted__ header.
  • OSINT: Wayback Machine / CDX API (web.archive.org) to recover a deleted threat-intel blog and its decrypt_command.sh.

Setup. Before touching anything, pin the names in /etc/hosts so STARTTLS and the HTTP upload resolve SNI/Host correctly and the cert validates against the expected hostname:

# 10.129.x.x is the lab target IP
echo "10.129.x.x murknet.htb groups.murknet.htb command.murknet.htb upload.murknet.htb submit-creds.murknet.htb" | sudo tee -a /etc/hosts

Methodology (step by step)

1. Recon — a Prosody behind nginx

Why. We know neither the XMPP domain nor the components. A typical Prosody server exposes a very recognizable quartet of ports; the TLS certificate, self-signed or not, is obliged to declare in its Subject Alternative Names every virtual hostname it serves — so it hands us the deployment map and the first flag.

# full sweep + version detection on open ports
nmap -Pn -p- --min-rate 2000 10.129.x.x -oA whisper_allports
nmap -Pn -p22,443,5222,5269,5281 -sV -sC 10.129.x.x -oA whisper_svc
22/tcp   open  ssh       OpenSSH 9.6p1
443/tcp  open  ssl/http  nginx 1.24.0
5222/tcp open  xmpp-client   # client (STARTTLS)
5269/tcp open  xmpp-server   # s2s (server-to-server)
5281/tcp open  ssl/http      # Prosody mod_http (BOSH/WebSocket/Upload)

The signature is unmistakable: 5222 (c2s with STARTTLS), 5269 (s2s federation) and 5281 (Prosody’s internal HTTP). 443/nginx is the reverse proxy publishing the HTTP vhosts (upload among them). The self-signed cert, presented on 443 and 5222, reveals the deployment’s identity and gives Q1 directly.

The SANs trick. You can read the cert two ways — from 443 with SNI, or straight off the XMPP port negotiating STARTTLS; both return the same cert:

# via 443 with SNI
echo | openssl s_client -connect 10.129.x.x:443 -servername murknet.htb 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName

# via XMPP c2s negotiating STARTTLS (useful if 443 is filtered)
echo | openssl s_client -connect 10.129.x.x:5222 -starttls xmpp -xmpphost murknet.htb 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name:
    DNS:murknet.htb, DNS:groups.murknet.htb, DNS:command.murknet.htb, DNS:upload.murknet.htb

Key subdomains: groups (MUC/multi-user chat rooms), command (the pubsub dispatcher the orders travel through) and upload (the XEP-0363 file share where the PDFs are dropped). The order the question wants is main hostname first, rest alphabetical.

Q1 — TLS certificate SANs: murknet.htb,command.murknet.htb,groups.murknet.htb,upload.murknet.htb

2. Access — in-band registration and channel enumeration

Why. We have no credentials, but the server advertises <register/> in its stream features: in-band registration enabled (XEP-0077, IBR). We can create a burner account and, once authenticated, ask the server for its components (disco#items) and the MUC channels. The cert is self-signed, so we must disable TLS verification in the client.

Critical lab OPSEC note: new (burner) accounts are purged almost instantly — a job cleans them up. So you must register and work in the same session: as soon as you have a stream, enumerate and read; don’t disconnect and come back.

First confirm IBR is advertised:

# stream features advertise <register/> if IBR is on
echo "<stream:stream to='murknet.htb' xmlns='jabber:client' \
xmlns:stream='http://etherx.jabber.org/streams' version='1.0'>" \
  | openssl s_client -connect 10.129.x.x:5222 -starttls xmpp -xmpphost murknet.htb -quiet 2>/dev/null \
  | grep -o 'register'

And automate with slixmpp, which ships plugins for every XEP we need:

#!/usr/bin/env python3
import asyncio, ssl, slixmpp

class Burner(slixmpp.ClientXMPP):
    def __init__(self, jid, pwd):
        super().__init__(jid, pwd)
        for x in ('xep_0030','xep_0045','xep_0060','xep_0077','xep_0313','xep_0363'):
            self.register_plugin(x)          # disco, MUC, pubsub, IBR, MAM, HTTP Upload
        self['xep_0077'].force_registration = True
        self.add_event_handler('register', self._register)
        self.add_event_handler('session_start', self._start)

    async def _register(self, iq):
        resp = self.Iq(); resp['type'] = 'set'
        resp['register']['username'] = self.boundjid.user
        resp['register']['password'] = self.password
        await resp.send()                     # create the burner account

    async def _start(self, _):
        self.send_presence(); await self.get_roster()
        # disco#items against the rooms component
        items = await self['xep_0030'].get_items(jid='groups.murknet.htb')
        for it in items['disco_items']['items']:
            print(it[0], '|', it[2])          # JID | channel name
        self.disconnect()

x = Burner('burner01@murknet.htb', 'Burner!2026')
x.ssl_context.check_hostname = False          # self-signed cert
x.ssl_context.verify_mode   = ssl.CERT_NONE
x.connect(('10.129.x.x', 5222))
x.process(forever=False)

The disco#items against groups.murknet.htb — equivalent to the raw stanza <iq type='get' to='groups.murknet.htb'><query xmlns='http://jabber.org/protocol/disco#items'/></iq> — lists the public channels:

resources@groups.murknet.htb  | Resources
rules@groups.murknet.htb      | Rules
random@groups.murknet.htb     | Random
infra@groups.murknet.htb      | Infrastructure

The question wants the names in alphabetical order.

Q2 — Public XMPP channels (alphabetical): Infrastructure,Random,Resources,Rules

3. The first whisper — email in metadata, password in #infra

Why. The public channels are living history: Prosody keeps MAM (Message Archive Management, XEP-0313), so we can rewind everything said. Among the messages are links to PDFs dropped on upload.murknet.htb (XEP-0363). Office documents almost always leak their creator in the metadata: free OSINT that hands us a real member’s email. With the email, we only lack the password — and someone pasted it by mistake into a channel.

Query a room’s archive via MAM. In slixmpp:

# inside _start(): pull the full #infra history
async for msg in self['xep_0313'].iterate(jid='infra@groups.murknet.htb',
                                           amount=200, rsm={'max': 200}):
    body = msg['mam_result']['forwarded']['stanza']['body']
    if body: print(body)

…which underneath emits the standard MAM stanza:

<iq type='set' to='infra@groups.murknet.htb' id='mam1'>
  <query xmlns='urn:xmpp:mam:2'>
    <set xmlns='http://jabber.org/protocol/rsm'><max>200</max></set>
  </query>
</iq>

The PDF URLs show up in the history. Download them from the file share and run exiftool:

# the HTTP Upload link carries a one-time UUID
curl -sk "https://upload.murknet.htb/<uuid>/Operational_Onboarding_Guide_v3.2.pdf" -o guide.pdf
exiftool -Author -Creator -Producer -CreateDate guide.pdf
Author  : zytglogge88@murknet.htb
Creator : swissclock          # Zytglogge = clock tower of Bern

The Operational Onboarding Guide leaks a member’s identity: the email zytglogge88@murknet.htb and the alias swissclock. Zytglogge is Bern’s medieval clock tower — a thematic nudge (clock) that immediately fits the password.

The password was in plain sight: in #infra, rattlesnake mistakenly pastes the old “temporary passwords” from an onboarding batch. Instead of brute-forcing login by login, let the theme break the tie — swissclock ↔ clock ↔ TickTock:

# passwords leaked in #infra (old onboarding batch)
KillBill2025!   K4w4Bong424!   Northwind225!   TickTock24!   <-- valid

Authenticating zytglogge88@murknet.htb:TickTock24! gets us in as swissclock, a real cell member — not a burner — which unlocks the private 1-to-1 chats and the operation rooms an outsider can’t see.

Q3 — Credentials of an APT member: zytglogge88@murknet.htb:TickTock24!

4. Inside the cell — three operations and an encrypted dispatcher

Why. As swissclock we are no longer observers: we have a personal MAM archive, DMs and access to the private rooms where the real coordination happens. Here lies the heart of the challenge: the command.murknet.htb component is a pubsub service (XEP-0060) acting as a dead drop — each operation has its node with the orders published encrypted (OpenSSL Salted__ / AES-256-CBC, base64 payload). Enumerating the nodes and their items gives us the operations map.

# pubsub dispatcher nodes
nodes = await self['xep_0060'].get_nodes('command.murknet.htb')
for n in nodes['disco_items']['items']:
    print('NODE', n[1])
    items = await self['xep_0060'].get_items('command.murknet.htb', n[1])
    for it in items['pubsub']['items']:
        print('  ', it['id'], it['payload'].text[:60], '...')   # encrypted base64

Three operations appear:

  • op_sparkling → crypto-asset theft (the 1st operation)
  • op_snatch → Watson’s kidnapping
  • op_dominance → “next operation”

The core trap. In a DM, rattlesnake hands swissclock the rotated key: The new key is SHALLOWBLUE. It’s tempting to use it for everything — but SHALLOWBLUE does not decrypt the already-published commands of op_sparkling/op_snatch, because those items predate the rotation. The old key they were encrypted with is not in the XMPP: it must be recovered from the leaked decryptor (the OSINT pivot in step 5). Mental rule: if the new key yields garbage, the message is pre-rotation.

Meanwhile the operation rooms assign roles and objectives. In op_snatch, the colonel assigns the kidnapping logistics:

# op_snatch room — role assignment (colonel)
"Dynamite handles transport. Spur handles custody after delivery"
dynamite: "I'm starting the operation now"

Whoever executes Watson’s grab/transport is dynamite (Spur only handles custody after delivery).

Q6 — Nickname of the affiliate who kidnapped Watson: dynamite

And in the private op_dominance room, doctor’s briefing names the final operation and its objective:

# private op_dominance room — doctor's briefing
"The next operation is unlike anything we've handled before…
 Our objective has a name: DIOGENES"

Q8 — Last operation planned and its objective: Dominance,DIOGENES

5. The OSINT pivot — “nothing is ever truly deleted once enough eyes have seen it”

Why. The published commands are encrypted with a key that doesn’t live in the chat. The op_sparkling room stores the cell’s reaction to an external threat-intel report attributing the BalanceRAT malware to them. rattlesnake (alias Porlock) brags about deleting the blog — “I personally nuked it. It’s gone. try opening it now” — and doctor corrects him with the line that is the challenge’s literal hint: “nothing is ever truly deleted once enough eyes have seen it”. timothy pastes the URL, host down on purpose:

http://security.billblog.co.uk/threat/BalanceRAT-analysis-and-attribution

The real host is dead, but the copy lives in the Wayback Machine. We query the CDX API to list snapshots and discover associated resources (a giveaway image and the script):

# list of captures and resources under that host
curl -s "http://web.archive.org/cdx/search/cdx?url=security.billblog.co.uk*&output=text&collapse=urlkey"
20260805 .../threat/BalanceRAT-analysis-and-attribution/                200
20260805 .../threat/BalanceRAT-analysis-and-attribution/porlock.png     200
20260805 .../threat/BalanceRAT-analysis-and-attribution/decrypt_command.sh  200

Recover the article and its resources through the Wayback proxy. The id_ suffix serves the raw object, without archive.org’s navigation bar:

BASE="https://web.archive.org/web/20260805000000id_/http://security.billblog.co.uk/threat/BalanceRAT-analysis-and-attribution"
curl -s "$BASE/"                 -o article.html
curl -s "$BASE/porlock.png"      -o porlock.png
curl -s "$BASE/decrypt_command.sh" -o decrypt_command.sh

The article (“Ghost Balance”) maps the online identities of the alias Porlock (= rattlesnake), including his forum account, and attaches the full decryptor. The member’s social account is the answer to Q4.

Q4 — URL of an APT member’s social account: https://stonedforums.htb/@porlock

The decrypt_command.sh recovered from the archived blog reveals the old key and, above all, the exact OpenSSL parameters:

#!/usr/bin/env bash
KEY='BLACKFENLOTTE'                # OLD key (pre-rotation)
printf '%s' "$1" | base64 -d |
  openssl enc -d -aes-256-cbc -pbkdf2 -iter 120000 -md sha256 -pass "pass:${KEY}"

Each operation uses its own key with the same method: op_sparkling → BLACKFENLOTTE, op_snatch → SHALLOWBLUE (the rotated one). The detail that broke manual decryption before we had the script: -pbkdf2 -iter 120000 -md sha256 was missing. Without that identical key derivation, OpenSSL produces garbage even with the correct passphrase.

6. Decryption — the orders in the clear

Why. With the exact method and both keys, we can turn each base64 pubsub payload into plaintext. First verify the Salted__ header (confirming it’s an openssl enc blob with a salt, not something else):

# 1) inspection: the first 8 bytes must be "Salted__"
echo -n "$PAYLOAD_B64" | base64 -d | head -c 16 | xxd
# 00000000: 5361 6c74 6564 5f5f ....  Salted__....

# 2) decrypt op_sparkling with the OLD key
echo -n "$PAYLOAD_B64" | base64 -d \
  | openssl enc -d -aes-256-cbc -pbkdf2 -iter 120000 -md sha256 -pass pass:BLACKFENLOTTE

BLACKFENLOTTE decrypts op_sparkling (crypto-asset theft):

cmd-02 Evilginx server: 185.203.11.109
cmd-03 BTC: 1BfQc4twbZrUSrtKdwW8TtSN6o1SUezWkX · 1BcanTJp… · 1BHoNZGG…
cmd-04 XMR Address: 429x3WVq1ucARGXx6NEwL4Sg4iowfW5ZWMAqEDErLxrWdg4ffkonB5tNxg85BKGjDqDQRfBERANhgf6DnGjjFyDR5L7uwye
cmd-05 Bank creds -> https://submit-creds.murknet.htb:9099
cmd-06 New C2 (BalanceRAT): 34.54.165.186

The Monero wallet in cmd-04 is the one from the first operation.

Q5 — XMR wallet used in the first operation: 429x3WVq1ucARGXx6NEwL4Sg4iowfW5ZWMAqEDErLxrWdg4ffkonB5tNxg85BKGjDqDQRfBERANhgf6DnGjjFyDR5L7uwye

SHALLOWBLUE (same line, only changing -pass pass:SHALLOWBLUE) decrypts op_snatch (Watson’s kidnapping):

cmd-02 Watson… red Ford Fiesta, plate WT609DXT
cmd-03 He just stopped at Pembridge Square
cmd-04 …turned onto Bayswater Road. Stay right on his heels!
cmd-05 He's about to enter Victoria Station. He must not reach the tracks. Act NOW
cmd-06 Heading toward Silvertown — 51°30'17.6"N, 0°02'05.1"E
cmd-08 Sector B. Row 6. Container 75JM77. Watchword: Chaos Is Order
cmd-09 Take the car to the junkyard in Walton-on-Thames. Ask for Richard.
cmd-10 Start cleanup process IMMEDIATELY

The sequence narrates the operation minute by minute: they tail Watson from Pembridge Square along Bayswater Road; the grab happens at Victoria Station (cmd-05, “He must not reach the tracks. Act NOW”); he is then moved to the Silvertown hideout (cmd-06/cmd-08, container 75JM77), which is exactly where the story PDF places the recovered objects. The question asks where he was kidnapped, not where he was held.

Q7 — Where Watson was kidnapped: Victoria Station (capture ≠ captivity)

Timeline

PhaseInvestigation milestoneArtifact / technique
T0Laptop seized in Silvertown; only a surviving XMPP clientstory PDF
T1nmap → Prosody stack (5222/5269/5281 + nginx 443)port recon
T2Self-signed cert SANs → 4 hostnames (Q1)openssl s_client / x509
T3IBR on → burner account via slixmpp; disco#items → 4 channels (Q2)XEP-0077 / XEP-0030
T4Rooms’ MAM → PDFs on upload; exiftool leaks email+aliasXEP-0313 / XEP-0363 / metadata
T5Themed password pasted in #infra → login as swissclock (Q3)credential reuse/leak
T6Pubsub command.* → 3 encrypted operations; DM hands over SHALLOWBLUEXEP-0060
T7Roles in op_snatch → dynamite (Q6); op_dominance briefing → DIOGENES (Q8)MUC reading
T8Hint “nothing is ever truly deleted” → CDX/Wayback recovers the blogarchive OSINT
T9Archived blog → account @porlock (Q4) + decrypt_command.sh (BLACKFENLOTTE, -iter 120000)Wayback + script analysis
T10Decryption: op_sparkling → XMR wallet (Q5); op_snatch → Victoria Station (Q7)openssl enc -d

IOCs

TypeIndicatorContext
Domainmurknet.htb, groups.murknet.htb, command.murknet.htb, upload.murknet.htbCell’s XMPP/Prosody infra (cert SANs)
Domain/portsubmit-creds.murknet.htb:9099Stolen banking credentials collector (op_sparkling cmd-05)
Domainsecurity.billblog.co.ukDeleted threat-intel blog; recoverable in Wayback
Domainstonedforums.htb/@porlockForum account of alias Porlock (= rattlesnake)
IPv4185.203.11.109Evilginx server (AiTM phishing) — op_sparkling cmd-02
IPv434.54.165.186New BalanceRAT C2 — op_sparkling cmd-06
BTC wallet1BfQc4twbZrUSrtKdwW8TtSN6o1SUezWkX, 1BcanTJp…, 1BHoNZGG…Crypto theft destination
XMR wallet429x3WVq1ucARGXx6NEwL4Sg4iowfW5ZWMAqEDErLxrWdg4ffkonB5tNxg85BKGjDqDQRfBERANhgf6DnGjjFyDR5L7uwyeMonero wallet of the 1st operation
MalwareBalanceRATRAT attributed to the cell in the “Ghost Balance” report
Crypto keyBLACKFENLOTTE (op_sparkling), SHALLOWBLUE (op_snatch, rotated)AES-256-CBC / PBKDF2 iter 120000
Credentialzytglogge88@murknet.htb:TickTock24! (alias swissclock)Compromised cell account
Aliasswissclock, rattlesnake/Porlock, doctor, colonel, dynamite, spur, timothyCell members
VehicleRed Ford Fiesta, plate WT609DXTWatson’s car (op_snatch)
Geo51°30'17.6"N, 0°02'05.1"E — Silvertown; Container 75JM77, watchword Chaos Is OrderHideout/captivity

MITRE ATT&CK mapping

Adversary TTPs observed while reconstructing the operation:

TacticTechniqueEvidence in the challenge
Resource DevelopmentT1583.001 Acquire Infrastructure: Domainsmurknet.htb + own subdomains
Resource DevelopmentT1587.001 Develop Capabilities: MalwareBalanceRAT
Resource DevelopmentT1585.001 Establish Accounts: Social Media@porlock on stonedforums.htb
Command and ControlT1071.001 Application Layer Protocol: Web/MessagingXMPP (Prosody) as command channel
Command and ControlT1102 Web Service (dead drop)XEP-0060 pubsub as order mailbox
Command and ControlT1573.001 Encrypted Channel: Symmetric Cryptographyorders in AES-256-CBC / PBKDF2
Command and ControlT1573.002 + key rotationBLACKFENLOTTE → SHALLOWBLUE rotation
Credential AccessT1557 Adversary-in-the-MiddleEvilginx server (185.203.11.109)
Collection / Initial AccessT1566 Phishing / T1598 Phishing for Informationbanking credential theft → submit-creds:9099
ImpactT1657 Financial Theftcrypto-asset theft (op_sparkling), BTC/XMR wallets
Defense EvasionT1070 Indicator Removal”I personally nuked it” — deletion of the attribution blog
— (physical world)Watson’s kidnapping (op_snatch)out of ATT&CK scope; included for narrative context

Detection and remediation

For the XMPP server operator (the APT’s OPSEC lessons, applicable to a defender):

  • Disable in-band registration in Prosody (allow_registration = false) or require admin approval (mod_register_web). Open IBR enabled the burner and all the enumeration.
  • Proper CA certificates, not self-signed: the self-signed cert added no security and its SANs leaked the full deployment map. At minimum, don’t list every sensitive vhost in a single public cert.
  • Minimal MAM retention, off by default in sensitive rooms: the full archive let us reconstruct months of coordination. Purge and cap max_archive_query_results.
  • Credential hygiene: never paste batches of “temporary passwords” into a channel (#infra), don’t use themed/guessable passwords (TickTock24! ↔ swissclock), and rotate for real by revoking affected accounts.
  • Metadata scrubbing before uploading documents: exiftool -all= guide.pdf would have avoided leaking Author/Creator. An upload pipeline should normalize metadata.
  • Encryption at rest after rotation: rotating the key (SHALLOWBLUE) without re-encrypting the old items left the previous commands decryptable with the old, OSINT-recoverable key. On rotation, re-encrypt or delete the history.
  • Deletion isn’t deletion: a downed host stays alive in the Wayback Machine / caches. For your own leaked infra, request exclusion from the archive and assume the copy already circulates.

For the detection team (blue team) watching a similar network:

  • Alert on XMPP c2s/s2s (5222/5269/5281) to non-corporate destinations, and on federated s2s with unknown domains.
  • Inspect pubsub/MUC payloads for base64 blobs with a Salted__ header (U2FsdGVk in base64): a strong indicator of commands encrypted with openssl enc.
  • Correlate the network IOCs (Evilginx 185.203.11.109, C2 34.54.165.186, submit-creds.murknet.htb:9099) against proxy/DNS/firewall logs.
  • Watch known BTC/XMR wallets in intel feeds and monitor for the creation of forum/social accounts tied to the actor’s aliases.

Answers / flags

#QuestionAnswer
Q1TLS certificate SANs (main hostname first, rest alphabetical)murknet.htb,command.murknet.htb,groups.murknet.htb,upload.murknet.htb
Q2Public XMPP channels (alphabetical order)Infrastructure,Random,Resources,Rules
Q3Credentials of an APT member (email:password)zytglogge88@murknet.htb:TickTock24!
Q4URL of an APT member’s social accounthttps://stonedforums.htb/@porlock
Q5XMR wallet used in the first operation429x3WVq1ucARGXx6NEwL4Sg4iowfW5ZWMAqEDErLxrWdg4ffkonB5tNxg85BKGjDqDQRfBERANhgf6DnGjjFyDR5L7uwye
Q6Nickname of the affiliate who kidnapped Watsondynamite
Q7Where Watson was kidnappedVictoria Station
Q8Last operation planned and its objectiveDominance,DIOGENES

Lessons

  • Each link hands you the next key — hence “Whisper Chain”: TLS cert → SANs; XMPP registration → channels; PDF metadata → email; infra channel → password; swissclock login → pubsub; rattlesnake DM → SHALLOWBLUE; Wayback → decryptor + BLACKFENLOTTE + Porlock; openssl → wallet + location.
  • The core crypto trap: the published commands use the old key (BLACKFENLOTTE, from the blog), not the rotated DM key (SHALLOWBLUE). If the new key yields garbage, the message is pre-rotation.
  • KDF parameters are part of the key: without identical -pbkdf2 -iter 120000 -md sha256, the correct passphrase produces garbage. The archived script didn’t just give the key: it gave the method.
  • Register and work in the same session: IBR accounts are purged almost instantly on this Prosody.
  • Metadata = free OSINT: exiftool on an uploaded PDF leaked Author/Creator and unlocked a real account.
  • ”Nothing is ever truly deleted”: a deliberately down host was still alive in the Wayback Machine (CDX), which kept the full decryptor and the giveaway image.
  • Capture vs captivity: Watson is captured at Victoria Station and held in Silvertown; the question asked for the kidnapping location.

Series · The Reichenbach Directive

SherlockTitleLink
S01Silent Dividend../2026-09-15-holmes2026-s01-silent-dividend/
S02Bottle Out../2026-09-16-holmes2026-s02-bottle-out/
S03Whisper Chain — this writeup../2026-09-17-holmes2026-s03-whisper-chain/
S04Paper Ghost../2026-09-18-holmes2026-s04-paper-ghost/
S05Poisoned Branch../2026-09-19-holmes2026-s05-poisoned-branch/
S06Silent Passenger../2026-09-20-holmes2026-s06-silent-passenger/
S07Iron Feather../2026-09-21-holmes2026-s07-iron-feather/
S08Borrowed Name../2026-09-22-holmes2026-s08-borrowed-name/
S09Last Light../2026-09-23-holmes2026-s09-last-light/