CVE-2026-102385 The encode and the decode cancel out in Ninja Forms
Intrudify's autonomous pentesting engine discovered an unauthenticated stored cross-site scripting vulnerability in Ninja Forms, affecting all versions up to and including 3.15.3. Submitted values are HTML-encoded on write and entity-decoded on read, so the submission model returns the exact bytes a visitor sent. The admin review screen prints one field type into a textarea with no escaping, and a visitor with no account can close that textarea and open a script tag.
Summary
Ninja Forms is a form builder for WordPress. Version 3.15.3 added two helpers to the submission path under the same issue: one HTML-encodes a field value when it is stored, and one entity-decodes it when it is read back. Intrudify's autonomous testing engine found that the pair compose into the identity function, so the read side returns the raw submitted bytes including <, >, quotes and a literal </textarea>.
One screen consumes that value without escaping it. The submission review metabox asks each field class to build its own admin markup, and the Paragraph Text field interpolates the value straight into a <textarea>. A visitor with no account submits the payload through the public form, and it executes when an administrator opens the entry to read it.
Technical detail
Two helpers that cancel
On write, includes/Helper.php:249 encodes the value with double encoding turned on. On read, includes/Helper.php:322 decodes every entity it can recognise:
// includes/Helper.php:249 - storing a submitted value
public static function encode_submission_value( $value, $allow_html = false )
{
...
if ( $allow_html ) return wp_kses_post( $value );
return htmlspecialchars( $value, ENT_QUOTES, 'UTF-8', true );
}
// includes/Helper.php:322 - reading it back
public static function decode_submission_value( $value )
{
...
return html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
}The fourth argument to htmlspecialchars() is $double_encode, and it is true. Every ampersand the visitor sent becomes &, so the encoding is lossless and the decode reverses it exactly. Composing the two returns the input.
Each half is defensible on its own. Encoding at rest keeps the database free of markup. Decoding on read gives consumers the text the visitor typed. Together they restore the attacker's bytes at the moment a consumer asks for them, and the read-side hardening that used to stand there is gone.
The read path
NF_Database_Models_Submission::get_field_value() at includes/Database/Models/Submission.php:184 ends on the decode:
$this->_field_values[ $field ] = get_post_meta( $this->_id, $field, TRUE );
$this->_field_values[ $field_ref ] = get_post_meta( $this->_id, $field, TRUE );
return WPN_Helper::decode_submission_value( $this->_field_values[ $field ] );Every caller of get_field_value() now receives raw markup. The value in the database is still safely encoded, so an audit that inspects storage sees nothing wrong.
The sink the field builds itself
NF_Admin_CPT_Submission registers the "User Submitted Values" metabox unconditionally, and its template at includes/Templates/admin-metabox-sub-fields.html.php:23 delegates the markup to each field class:
<td><?php echo $field_class->admin_form_element(
$field->get_id(),
$sub->get_field_value( $field->get_id() )
); ?></td>The base implementation at includes/Abstracts/Field.php:178 escapes. The Paragraph Text override at includes/Fields/Textarea.php:34 does not:
// includes/Abstracts/Field.php:178 - the base class
return '<input class="widefat" name="fields[' . absint( $id ) . ']" value="'
. esc_attr( $value ) . '" type="text" />';
// includes/Fields/Textarea.php:34 - the override
return "<textarea class='widefat' name='fields[$id]'>$value</textarea>";Ten other field classes override admin_form_element() and every one of them either escapes or emits a fixed string. The Paragraph Text override is the single place where the value lands in element content through string interpolation, and element content inside a <textarea> ends at the first </textarea>.
The write needs no account
The submission handler is registered on wp_ajax_nopriv_nf_ajax_submit, so it serves visitors with no session. It checks a nonce, and when that check fails it mints a valid one and returns it in the error body. The comment above it states the intent:
/**
* "Just in Time Nonce".
* If the nonce fails, then send back a new nonce for the form to resubmit.
* This supports the edge-case of 11:59:59 form submissions ...
*/
$current_time_stamp = time();
$new_nonce_name = 'ninja_forms_display_nonce_' . $current_time_stamp;
$this->_errors['nonce'] = array(
'new_nonce' => wp_create_nonce( $new_nonce_name ),
'nonce_ts' => $current_time_stamp
);This is a cache-friendliness measure, and it is doing the job it was written for. It also means the nonce never gated anything an attacker could not simply ask for. The same plugin registers wp_ajax_nopriv_nf_ajax_get_new_nonce, which hands one out directly.
The chain
- An unauthenticated attacker sends
action=nf_ajax_submitto/wp-admin/admin-ajax.phpwith a junk nonce. The error body carriesnew_nonceandnonce_ts. - The same request is repeated with those two values and a payload in a Paragraph Text field. The default "Contact Me" form satisfies this, where the Message field is field 3.
- The value is stored encoded.
wp post meta get <id> _field_3shows the entity-encoded payload, which is what makes the database look clean. - An administrator opens
/wp-admin/post.php?post=<id>&action=edit.get_field_value()decodes, the override interpolates, and the browser receives a closing textarea tag followed by a script element.
// the payload, submitted into the Message field
</textarea><script>alert(document.domain)</script>
// what the metabox sends to the administrator's browser
<td><textarea class='widefat' name='fields[3]'></textarea><script>alert(document.domain)</script></textarea></td> Verification
We reproduced this on a clean WordPress installation with Ninja Forms 3.15.3 active and the default "Contact Me" form published on a page. The attacker held no account and sent no cookie. Both requests were made with curl.
After the submission, the stored post meta held the entity-encoded payload, confirming that the write side encoded correctly. Opening the submission in wp-admin as an administrator executed alert(document.domain), and view-source showed the injected </textarea> closing the element early with the script element live after it.
As a negative control we submitted <img src=x onerror=alert(1)>, which carries no </textarea>. It renders inside the textarea as inert RCDATA text, visible to the administrator and never parsed as an element. The break-out is what makes the payload live, and a payload without one proves the screen is otherwise doing what it looks like it is doing.
Impact
The script runs in the /wp-admin/ origin inside an authenticated administrator session. WordPress ships no content security policy on wp-admin, so the payload can read any nonce on the page and issue any request the administrator could issue.
That is full control of the administrator session. The reachable end states include creating a second administrator account and installing a plugin, and plugin installation is arbitrary PHP execution on the host. The entry point is an anonymous form submission, so the distance from an unauthenticated visitor to code execution is one link in a review queue.
No configuration is involved. The metabox is registered unconditionally, the Paragraph Text field is present on the form the plugin ships, and reading a submission is the routine action the screen exists for.
Remediation
Update Ninja Forms to 3.15.5 or later.
Saturday Drive closed this across two releases. 3.15.4 escaped the server-rendered sink, wrapping the value in esc_textarea() at includes/Fields/Textarea.php:34 to match the esc_attr() the base class already applies.
3.15.5 closed a second path to the same stored value. The admin submissions app decoded the entity-encoded submission by assigning it to innerHTML on a live element, and 3.15.5 replaced that with DOMParser.parseFromString(). Assigning to innerHTML creates real elements in the current document, so an onerror handler on an injected img runs even though a script tag would not. DOMParser produces an inert document that loads no resources and fires no handlers. 3.15.5 is the version the vulnerability databases record as patched.
Escaping at the sink is the fix that holds. The encode and decode pair stays in place, and so does every other consumer of get_field_value(), so a sink added tomorrow inherits the same raw value. Anyone maintaining this code should treat the return of get_field_value() as attacker-controlled text and escape at each point of use.
The wider lesson
Two functions that are individually correct composed into a no-op. Encoding on write is good hygiene. Decoding on read is a reasonable convenience for consumers who want the text a visitor typed. Reviewing either one in isolation finds nothing, and the hardening that the read side used to provide disappeared without a single line looking wrong. Any codebase with an encoder at one end of a store and a decoder at the other is worth checking for this shape, and the check is cheap: feed one value through both and compare it to the input.
The second point is about inheritance. The base field class escapes. The override drops the escape while keeping the signature, the name and the contract, so a reader who has checked the base class has every reason to believe the subclass is covered. An override inherits an interface and makes its own promises about safety. When a base class performs a security function, every override is a place that function can quietly go missing, and the eleven overrides here needed eleven separate reads to establish that exactly one had lost it.
Disclosure timeline
- 2026-09-16 Vulnerability identified and full chain validated by Intrudify, reported via Patchstack
- 2026-09-21 Ninja Forms 3.15.4 published, escaping the server-rendered submission sink
- 2026-09-28 Ninja Forms 3.15.5 published, replacing the innerHTML decode in the admin submissions app
- 2026-09-30 Public disclosure · CVE-2026-102385 assigned
Questions
What is CVE-2026-102385?
CVE-2026-102385 is an unauthenticated stored cross-site scripting vulnerability in the Ninja Forms WordPress plugin, versions 3.15.3 and earlier. It was discovered autonomously by the Intrudify AI penetration testing engine. Submitted values are HTML-encoded on write and entity-decoded on read, so the submission model hands back the raw submitted bytes, and the admin submission review screen prints a Paragraph Text value into a textarea with no escaping.
Can CVE-2026-102385 be exploited without an account?
Yes. The write happens through wp_ajax_nopriv_nf_ajax_submit, which is open to visitors with no account. When that endpoint receives an invalid nonce it returns a fresh valid one in the error body, so a single extra request supplies the token. The attacker submits the payload into any public form that has a Paragraph Text field.
How do I fix CVE-2026-102385?
Update Ninja Forms to version 3.15.5 or later. The fix arrived in two parts. 3.15.4 escaped the value where the Paragraph Text field builds its admin markup, matching the esc_attr the base field class already applies. 3.15.5 replaced the innerHTML decode in the admin submissions app with DOMParser.parseFromString, which is the second path to the same stored value.