Multiple High Severity Vulnerabilities in Ninja Forms Plugin

Published 27 July 2023
Updated 29 November 2023
Rafie Muhammad
Security Researcher at Patchstack
Table of Contents

This blog post is about vulnerabilities in Ninja Forms plugin vulnerabilities. If you're a Ninja Forms user, please update the plugin to at least version 3.6.26.

Patchstack Developer and Business users are protected from the vulnerability. You can also sign up for the Patchstack Community plan to be notified about vulnerabilities as soon as they become disclosed.

For plugin developers, we have security audit services and Threat Intelligence Feed API for hosting companies.

About the Ninja Forms plugin

The plugin Ninja Forms versions 3.6.25 and below, free version), which has over 900,000 active installations is known as the more popular forms builder plugin in WordPress. This plugin is developed by Saturday Drive.

vulnerabilities in Ninja Forms

This plugin is a free form builder plugin for WordPress that enables us to build just about any type of form we could imagine. Ranging from simple contact forms to event registrations, file uploads, payments, and more.

The security vulnerability

This plugin suffers from multiple vulnerabilities. The first vulnerability is a POST-based reflected XSS. This vulnerability could allow any unauthenticated user to steal sensitive information to, in this case, privilege escalation on the WordPress site by tricking privileged users into visiting the crafted website. The described vulnerability was fixed in version 3.6.26 and assigned CVE-2023-37979.

The second and third vulnerabilities are broken access controls on the form submissions export feature. This vulnerability allows Subscriber and Contributor role users to export all of the Ninja Forms submissions on a WordPress site. The described vulnerability was also fixed in version 3.6.26 and assigned CVE-2023-38393 and CVE-2023-38386.

Reflected XSS

The initial entry point of this vulnerability exists in the route function:

public function route()
	register_shutdown_function( array( $this, 'shutdown' ) );

	$method = strtolower( $_SERVER['REQUEST_METHOD'] );

		* Request Method Override
		* Allows for a POST request to function as another Request Method
		*   by passing a `method_override` value as a request parameter.
		* For example, some servers do not support the DELETE request method.
	if( 'post' == $method and isset( $_REQUEST[ 'method_override' ] ) ){
		$method = sanitize_text_field( $_REQUEST[ 'method_override' ] );

	if( ! method_exists( $this, $method ) ){
		$this->_errors[] = esc_html__( 'Endpoint does not exist.', 'ninja-forms' );
	 * This call get the $_REQUEST info for the call(post, get, etc.)
	 * being called.
	$request_data = $this->get_request_data();

	try {
		$data = $this->$method($request_data);
		$this->_respond( $data );
-------------------------- CUT HERE --------------------------

The above route function could be called from an ajax action named nf_batch_process. Notice on the function that we could override the $method using $_REQUEST[ 'method_override' ] parameter if the original HTTP method performed is a POST request.

The code then will call this->$method() with $request_data as input parameter. The $request_data value is constructed from get_request_data function :

protected function get_request_data()
    $request_data = array();

    if (isset($_REQUEST['batch_type']) && $_REQUEST['batch_type']) {
        $request_data['batch_type'] = WPN_Helper::sanitize_text_field($_REQUEST['batch_type']);

    if (isset($_REQUEST['data']) && $_REQUEST['data']) {
        // @TODO: Find a way to safely sanitize this later.
        // sanitize_text_field overcorrects, breaking "actual" data.
        $request_data['data'] = $_REQUEST['data'];

    if (isset($_REQUEST['security']) && $_REQUEST['security']) {
        $request_data['security'] = WPN_Helper::sanitize_text_field($_REQUEST['security']);

    if (isset($_REQUEST['action']) && $_REQUEST['action']) {
        $request_data['action'] = WPN_Helper::sanitize_text_field($_REQUEST['action']);

    return $request_data;

The interesting part is which function could we call on the $method variable. Turns out that we could call the _respond function itself which originally act as a wrapper function to output data :

protected function _respond( $data = array() )
    if( empty( $data ) ){
        $data = $this->_data;

    if( isset( $this->_data['debug'] ) ) {
        $this->_debug = array_merge( $this->_debug, $this->_data[ 'debug' ] );

    if( isset( $this->_data['errors'] ) && $this->_data[ 'errors' ] ) {
        $this->_errors = array_merge( $this->_errors, $this->_data[ 'errors' ] );

    // allow for accessing and acting on $data before responding
    do_action( 'ninja_forms_before_response', $data );

    $response = array( 'data' => $data, 'errors' => $this->_errors, 'debug' => $this->_debug );

    echo wp_json_encode( $response );

    wp_die(); // this is required to terminate immediately and return a proper response

Notice that the function will output $response that could be constructed from our $data variable with echo wp_json_encode().

This will still make HTTP response output as text/html. Looking back at the get_request_data function, we could inject the XSS payload via $_REQUEST['data'] since there is no sanitization or escaping implemented.

Subscriber+ Broken Access Control

The underlying vulnerability exist in the processing function:

public function processing() {

	// Get our passed arguments. These come from the querysting of the processing page.
	if ( isset ( $_REQUEST['args'] ) ) {
		$this->args = WPN_Helper::sanitize_text_field($_REQUEST['args']);
		if ( isset ( $this->args['redirect'] ) ) {
			if( wp_validate_redirect( $this->args['redirect'] ) ){
				$this->redirect = wp_sanitize_redirect( $this->args['redirect'] );
	} else {
		$this->args = array();

	// Get our current step.
	$this->step = isset ( $_REQUEST['step'] )? esc_html( $_REQUEST['step'] ) : 'loading';

	// Get our total steps
	$this->total_steps = isset ( $_REQUEST['total_steps'] )? esc_html( $_REQUEST['total_steps'] ) : 0;

	// If our step is loading, then we need to return how many total steps there are along with the next step, which is 1.
	if ( 'loading' == $this->step ) {
		$return = $this->loading();
		if ( ! isset ( $return['step'] ) ) {
			$saved_step = get_user_option( 'nf_step_processing_' . $this->action . '_step' );
			if ( ! empty ( $saved_step ) ) {
				$this->step = $saved_step;
			} else {
				$this->step = 1;

			$return['step'] = $this->step;
		if ( ! isset ( $return['complete'] ) ) {
			$return['complete'] = false;
	} else { // We aren't on the loading step, so do our processing.
		$return = $this->step();
-------------------------- CUT HERE --------------------------

The processing function is set to be a function handler of nf_download_all_subs ajax action and there is no proper permission check. The function will call $this->step() function which will export all of the submissions to the $this->args['filename'] we specified.

Contributor+ Broken Access Control

The underlying vulnerability exists in the export_listen function:

-------------------------- CUT HERE --------------------------
if (isset ($_REQUEST['download_file']) && !empty($_REQUEST['download_file'])) {

    // Open our download all file
    $filename = esc_html($_REQUEST['download_file']);

    $upload_dir = wp_upload_dir();

    $file_path = trailingslashit($upload_dir['path']) . $filename . '.csv';

    if (file_exists($file_path)) {
        $myfile = file_get_contents($file_path);
    } else {
        $redirect = esc_url_raw(remove_query_arg(array('download_file', 'download_all')));


    $form_name = Ninja_Forms()->form(absint($_REQUEST['form_id']))->get()->get_setting('title');
    $form_name = sanitize_title($form_name);

    $today = date('Y-m-d', current_time('timestamp'));

    $filename = apply_filters('ninja_forms_download_all_filename', $form_name . '-all-subs-' . $today);

    header('Content-type: application/csv');
    header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
    header('Pragma: no-cache');
    header('Expires: 0');

    echo $myfile;

-------------------------- CUT HERE --------------------------

The function is set to be a function handler of load-edit.php hook which could be triggered when a user for example visits the /wp-admin/edit.php endpoint. Since there is no proper permission check, by default, user with the minimum role of Contributor could export form submissions.

The patch

For the reflected XSS issue, the vendor decided to restrict which method the user could access from the function. This patch is enough to prevent the user from directly calling the _respond function to trigger the XSS. The patch can be seen below :

vulnerabilities in Ninja Forms

For the Subscriber+ broken access control, adding a permission check should also fix the reported issue. The patch can be seen below :

vulnerabilities in Ninja Forms
vulnerabilities in Ninja Forms

For the Contributor+ broken access control, adding a permission check should also fix the reported issue. The patch can be seen below :

vulnerabilities in Ninja Forms


In some cases, a plugin or theme code needs to call a certain function or class from user supplied string. Always try to check and restrict which function or class the user can directly call. Also, pay extra attention to an export data action and always implement permission or access control checks to the related functions.


22 June, 2023We found the vulnerability and reached out to the plugin vendor.
04 July, 2023Ninja Forms version 3.6.26 was published to patch the reported issue.
25 July, 2023Added the vulnerabilities to the Patchstack vulnerability database.
27 July, 2023Security advisory article publicly released.

Help us make the Internet a safer place

Making the WordPress ecosystem more secure is a team effort, and we believe that plugin developers and security researchers should work together.

  • If you're a plugin developer, join our mVDP program that makes it easier to report, manage, and address vulnerabilities in your software.
  • If you're a security researcher, join Patchstack Alliance to report vulnerabilities & earn rewards.

The latest in Security Advisories

Looks like your browser is blocking our support chat widget. Turn off adblockers and reload the page.