
Python PoC for CVE-2026-94504, an unauthenticated stored XSS in Ninja Forms <= 3.15.3. Fingerprints vulnerable versions and plants a payload via nf_ajax_submit.
Ninja Forms unauth stored XSS POC
Ninja Forms ≤ 3.15.3 — unauthenticated stored XSS. A public non-RTE textarea is saved, then html_entity_decode()'d and written into the legacy submission editor with no esc_textarea(). 3.15.4 escapes it. No login is required to plant the payload. It runs when an editor opens that submission.
For authorized testing only. Do not run this against systems you do not own or have permission to test.
Requires: Python 3, standard library only.
python3 cve-2026-94504-ninja-forms-stored-xss.py -u http://TARGET --test
python3 cve-2026-94504-ninja-forms-stored-xss.py -u http://TARGET --plant
python3 cve-2026-94504-ninja-forms-stored-xss.py -u https://TARGET --test --insecure
python3 cve-2026-94504-ninja-forms-stored-xss.py -u TARGET --test
| Flag | What it does |
|---|
-u / --url | WordPress base URL (required). http:// or https://. A hostname with no scheme is tried as HTTPS first, then HTTP. http→https redirects keep POST. Bad TLS certs are retried without verify; --insecure skips verify from the start |
--test | Fingerprint only. Reads readme.txt, finds a public non-RTE textarea. Does not submit |
--plant | Unauthenticated nf_ajax_submit. Fills required fields, puts the payload in the textarea, and prints the edit URL. Does not open a browser |
--page | Page that embeds the form. Default crawls /, /contact/, and the REST page/post list |
--field | Textarea field id. Default is the first non-RTE textarea |
--payload | Textarea value. Default closes the admin <textarea> with an img onerror. In cmd.exe do not wrap this in single quotes (< is a redirect). Omit --payload, or use double quotes and single quotes inside alert: --payload "</textarea>" |
--name / --email | Fillers for other required fields (defaults Ada Lovelace / [email protected]) |
--insecure | Skip TLS verify |
--timeout | HTTP timeout seconds (default 30) |
--test reads /wp-content/plugins/ninja-forms/readme.txt (Stable tag) and a public page whose nfForms JSON has "type":"textarea" with the rich-text editor off. ≤ 3.15.3 plus that field is VULNERABLE. ≥ 3.15.4 is NOT_VULNERABLE. No form on the site is NEED_FORM.
--plant posts /wp-admin/admin-ajax.php action=nf_ajax_submit with the page's ajaxNonce. No WordPress account. Success prints PAYLOAD, SUB, and EDIT /wp-admin/post.php?post=ID&action=edit. The script runs when that submission is opened in wp-admin. The public form page does not.
Rich-text (textarea_rte) paragraphs are a different path and are skipped. The default Contact Me "Message" field is the usual target.
Stdout: VERSION, PAGE, FORM, FIELD, PRESENT, VULNERABLE, NOT_VULNERABLE, NOT_FOUND, NEED_FORM, NEED_NONCE, PLANTED, PAYLOAD, SUB, EDIT.
CVE: CVE-2026-94504. Plugin: ninja-forms. Patched in 3.15.4. Credit: Hippolyte Quéré (Hippie); 3.15.4 changelog also names Venkateswara Reddy Challa for the admin escaping fix.

