(647) 243-4688

On August 10th, 2026, we received a submission for an Unauthenticated Privilege Escalation vulnerability in Pods, a WordPress plugin with more than 100,000 active installations. This vulnerability allows unauthenticated attackers to escalate their privileges to administrator and perform various administrator actions, such as overwriting the password of any user account, including the site owner’s, resulting in complete site takeover.

Props to Nhien Pham (nhienit) who discovered and responsibly reported this vulnerability through the Wordfence Bug Bounty Program. This researcher earned a bounty of $3,900.00 for this discovery. Our mission is to secure WordPress through defense in depth, which is why we are investing in quality vulnerability research and collaborating with researchers of this caliber through our Bug Bounty Program. We are committed to making the WordPress ecosystem more secure through the detection and prevention of vulnerabilities, which is a critical element to the multi-layered approach to security.

Wordfence Premium, Wordfence Care, and Wordfence Response users received a firewall rule to protect against known exploits targeting this vulnerability in Pods, hosted on WordPress.org, on August 12, 2026. Sites using the free version of Wordfence will receive the same protection 30 days later on September 11, 2026.

We provided full disclosure details to the Pods team through our Wordfence Vulnerability Management Portal on August 12, 2026. The developer acknowledged the report and released the fully patched version on August 14, 2026. We would like to commend the Pods team for their prompt response.

Due to the critical severity of this vulnerability, the plugin vendor is working with the WordPress.org plugins team to push a forced update to the patched version, for anyone running a vulnerable version of the plugin. This means most sites should already be patched or patched soon, however, we urge users to verify that their sites were updated to the latest patched version of Pods, as soon as possible. The latest patched version is 3.3.9.1, and the fix has also been backported to all previous major releases, these patched versions are 3.2.8.3, 3.1.4.2, 3.0.10.4, 2.9.19.4, and 2.8.23.4.

Vulnerability Summary from Wordfence Intelligence

CVSS Rating
9.8 (Critical)
Affected Version(s)
Various
Patched Versions
2.8.23.4, 2.9.19.4, 3.0.10.4, 3.1.4.2, 3.2.8.3, 3.3.9.1
Bounty
$3,900.00

The Pods – Custom Content Types and Fields plugin for WordPress is vulnerable to Privilege Escalation via Authorization Bypass in all versions up to, and including, 3.3.9. The vulnerability exists because the pods_admin AJAX router funnels every access check — including the method allowlist, nonce verification, login enforcement, and capability gate — through pods_error(), which under the JSON meta-box-loader compatibility path only writes failures to the PHP error log and returns false instead of terminating the request, rendering all guards ineffective. This makes it possible for unauthenticated attackers to escalate their privileges to Administrator or overwrite the password of any user account, including the site owner’s, enabling complete site takeover, or perform another administrator action.

Technical Analysis

Pods is a popular WordPress plugin for creating and managing custom content types, fields, and taxonomies. As part of its admin functionality, the plugin registers a public AJAX action, pods_admin, which routes requests to various internal API methods.

Examining the code reveals that the plugin uses the admin_ajax() function in the PodsAdmin class to handle these requests. The function defines an allowlist of permitted methods, and then applies a series of access checks, verifying that the requested method is in the allowlist, that the user is logged in and the nonce is valid, and that the user has the required capabilities, before dispatching the request to the corresponding API method.

Each of these guards is designed to halt the request by calling the pods_error() function when a check fails. Unfortunately, the guards rely entirely on pods_error() to terminate execution, and pods_error() does not reliably do so.

public function admin_ajax() {

    if ( false === headers_sent() ) {
        pods_session_start();

        header( 'Content-Type: text/html; charset=' . get_bloginfo( 'charset' ) );
    }

    // Sanitize input
    // @codingStandardsIgnoreLine
    $params = pods_unslash( (array) $_POST );

    foreach ( $params as $key => $value ) {
        if ( 'action' === $key ) {
            continue;
        }

        // Fixup $_POST data @codingStandardsIgnoreLine
        $_POST[ str_replace( '_podsfix_', '', $key ) ] = $_POST[ $key ];

        // Fixup $params with unslashed data
        $params[ str_replace( '_podsfix_', '', $key ) ] = $value;

        // Unset the _podsfix_* keys
        unset( $params[ $key ] );
    }

    $params = (object) $params;

    $methods = [
        'add_pod'            => [ 'priv' => true ],
        'save_pod'           => [ 'priv' => true ],
        'load_sister_fields' => [ 'priv' => true ],
        'process_form'       => [ 'custom_nonce' => true ],
        // priv handled through nonce
        'upgrade'            => [ 'priv' => true ],
        'migrate'            => [ 'priv' => true ],
    ];

    /**
     * AJAX Callbacks in field editor
     *
     * @since unknown
     *
     * @param array     $methods Callback methods.
     * @param PodsAdmin $obj     PodsAdmin object.
     */
    $methods = apply_filters( 'pods_admin_ajax_methods', $methods, $this );

    if ( ! isset( $params->method ) || ! isset( $methods[ $params->method ] ) ) {
        pods_error( __( 'Invalid AJAX request', 'pods' ), $this );
    }

    $defaults = [
        'priv'         => null,
        'name'         => $params->method,
        'custom_nonce' => null,
    ];

    $method = (object) array_merge( $defaults, (array) $methods[ $params->method ] );

    if (
        true !== $method->custom_nonce
        && (
            ! is_user_logged_in()
            || ! isset( $params->_wpnonce )
            || false === wp_verify_nonce( $params->_wpnonce, 'pods-' . $params->method )
        )
    ) {
        pods_error( __( 'Unauthorized request', 'pods' ), $this );
    }

    // Cleaning up $params
    unset( $params->action );
    unset( $params->method );

    if ( true !== $method->custom_nonce ) {
        unset( $params->_wpnonce );
    }

    // Check permissions (convert to array to support multiple)
    if ( ! empty( $method->priv ) && ! pods_is_admin( [ 'pods' ] ) ) {
        if ( true !== $method->priv && pods_is_admin( $method->priv ) ) {
            // They have access to the custom priv.
        } else {
            // They do not have access.
            pods_error( __( 'Access denied', 'pods' ), $this );
        }
    }

    $params->method = $method->name;

    $method_name = $method->name;

    $params = apply_filters( "pods_api_{$method_name}", $params, $method );

    $api = pods_api();

    $api->display_errors = false;

    if ( 'upgrade' === $method->name ) {
        $output = (string) pods_upgrade( $params->version )->ajax( $params );
    } elseif ( 'migrate' === $method->name ) {
        $output = (string) apply_filters( 'pods_api_migrate_run', $params );
    } else {
        if ( ! method_exists( $api, $method->name ) ) {
            pods_error( __( 'API method does not exist', 'pods' ), $this );
        } elseif ( 'save_pod' === $method->name ) {
            if ( isset( $params->field_data_json ) && is_array( $params->field_data_json ) ) {
                $params->fields = $params->field_data_json;

                unset( $params->field_data_json );

                foreach ( $params->fields as $k => $v ) {
                    if ( empty( $v ) ) {
                        unset( $params->fields[ $k ] );
                    } elseif ( ! is_array( $v ) ) {
                        $params->fields[ $k ] = (array) @json_decode( $v, true );
                    }
                }
            }
        }

        // Dynamically call the API method
        $params = (array) $params;

        $output = call_user_func( [ $api, $method->name ], $params );
    }//end if

    // Output in json format
    if ( false !== $output ) {

        /**
         * Pods Admin AJAX request was successful
         *
         * @since  2.6.8
         *
         * @param array               $params AJAX parameters.
         * @param array|object|string $output Output for AJAX request.
         */
        do_action( "pods_admin_ajax_success_{$method->name}", $params, $output );

        if ( is_array( $output ) || is_object( $output ) ) {
            wp_send_json( $output );
        } else {
            // @codingStandardsIgnoreLine
            echo $output;
        }
    } else {
        pods_error( __( 'There was a problem with your request.', 'pods' ) );
    }//end if

    die();
    // KBAI!
}

Examining the pods_error() function reveals that when the plugin is handling what it considers a JSON request, the error mode is set to json. Within that mode, if the request includes the meta-box-loader parameter set to 1, the function merely writes the error to the PHP error log and then continues, ultimately returning false instead of stopping execution.

function pods_error( $error, $obj = null ) {
	global $pods_errors;

	$display_errors = $obj;
	if ( is_object( $obj ) && isset( $obj->display_errors ) ) {
		$display_errors = $obj->display_errors;
	}

	$error_mode = 'exception';

	if ( true === $display_errors ) {
		$error_mode = 'exit';
	} elseif ( false === $display_errors ) {
		$error_mode = 'exception';
	} elseif ( is_string( $display_errors ) ) {
		$error_mode = $display_errors;
	}

	if ( is_object( $error ) && $error instanceof Exception ) {
		$error_mode = 'exception';

		if ( 'final_exception' === $display_errors ) {
			$error_mode = 'exit';
		}

		/** @var Exception $error */
		$error = $error->getMessage();
	}

	/**
	 * @var string $error_mode Throw an exception, exit with the message, return false, or return WP_Error
	 */
	if ( ! in_array( $error_mode, [ 'exception', 'exit', 'false', 'wp_error', 'json' ], true ) ) {
		$error_mode = 'exception';
	}

	/**
	 * When running a Pods shortcode, never exit and only return exception.
	 */
	if ( pods_doing_shortcode() ) {
		$error_mode = 'exception';
	} elseif ( pods_doing_json() ) {
		$error_mode = 'json';
	}

	/**
	 * Filter the error mode used by pods_error.
	 *
	 * @param string                     $error_mode Error mode
	 * @param string|array               $error      Error message(s)
	 * @param object|boolean|string|null $obj
	 */
	$error_mode = apply_filters( 'pods_error_mode', $error_mode, $error, $obj );

	if ( is_array( $error ) ) {
		$error = array_map( 'wp_kses_post', $error );

		if ( 1 === count( $error ) ) {
			$error = current( $error );

			// Create WP_Error for use later.
			$wp_error = new WP_Error( 'pods-error-' . md5( $error ), $error );
		} else {
			// Create WP_Error for use later.
			$wp_error = new WP_Error();

			foreach ( $error as $error_message ) {
				$wp_error->add( 'pods-error-' . md5( $error_message ), $error_message );
			}

			if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
				$error = __( 'The following issue occurred:', 'pods' ) . "nn- " . implode( "n- ", $error );
			} else {
				$error = __( 'The following issues occurred:', 'pods' ) . "n<ul><li>" . implode( "</li>n<li>", $error ) . '</li></ul>';
			}
		}
	} else {
		if ( is_object( $error ) ) {
			$error = __( 'An unknown error has occurred', 'pods' );
		}

		$error = wp_kses_post( $error );

		// Create WP_Error for use later.
		$wp_error = new WP_Error( 'pods-error-' . md5( $error ), $error );
	}//end if

	$pods_errors = [];

	// Support testing debug messages.
	if ( function_exists( 'codecept_debug' ) ) {
		codecept_debug( 'Pods Debug Error: ' . $error );
		pods_debug( debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
	}

	if ( ! empty( $error ) ) {
		if ( 'exception' === $error_mode ) {
			$exception_bypass = apply_filters( 'pods_error_exception', null, $error );

			if ( null !== $exception_bypass ) {
				return $exception_bypass;
			}

			$pods_errors = $error;

			/**
			 * Allow filtering whether the fallback is enabled to catch uncaught exceptions.
			 *
			 * @since 2.8.0
			 *
			 * @param bool   $exception_fallback_enabled Whether the fallback is enabled to catch uncaught exceptions.
			 * @param string $error                      The error information.
			 */
			$exception_fallback_enabled = apply_filters( 'pods_error_exception_fallback_enabled', true, $error );

			if ( $exception_fallback_enabled ) {
				set_exception_handler( 'pods_error_exception' );
			}

			throw new Exception( wp_kses_post( $error ) );
		} elseif ( 'exit' === $error_mode ) {
			$die_bypass = apply_filters( 'pods_error_die', null, $error );

			if ( null !== $die_bypass ) {
				return $die_bypass;
			}

			// die with error
			if ( ! defined( 'DOING_AJAX' ) && ! headers_sent() && ( is_admin() || false !== strpos( (string) $_SERVER['REQUEST_URI'], 'wp-comments-post.php' ) ) ) {
				wp_die( wp_kses_post( $error ), '', [ 'back_link' => true ] );
			} else {
				die( wp_kses_post( sprintf( '<e>%s</e>', $error ) ) );
			}
		} elseif ( 'wp_error' === $error_mode ) {
			return $wp_error;
		} elseif ( 'json' === $error_mode ) {
			$meta_box_loader_compat = (int) pods_v( 'meta-box-loader', 'request', 0 );

			// Check if this is a back-compat meta box save request.
			if ( 1 === $meta_box_loader_compat ) {
				// Do not block this page.
				error_log( 'Pods Meta Save Error:' . $error ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			} else {
				wp_send_json( [
					'message' => $error,
				], 500 );
			}
		}//end if
	}//end if

	return false;
}

Because the json error mode is selected whenever the plugin detects a JSON request, an attacker can trigger this path by sending the accept application/json header along with the meta-box-loader parameter. In that state, each failed guard in admin_ajax() — the method allowlist check, the login and nonce check, and the capability check — only logs its error and allows execution to continue, rendering all of the access controls ineffective.

This is compounded by the fact that admin_ajax() calls pods_error() as a bare statement rather than returning its result. Since pods_error() ultimately returns false when it does not terminate the request, prefixing these calls with a return statement would have caused admin_ajax() to stop executing and abort the request. Because the return value is simply discarded, execution instead falls through each failed check and continues on to the method dispatch.

With every guard bypassed, execution proceeds to the dynamic method dispatch, where the attacker-supplied method is called on the Pods API object with the attacker-supplied parameters. By supplying the save_user method parameter, the request is forwarded to the save_user() function in the PodsAPI class, which does not enforce any login, capability, or ownership check of its own before calling WordPress core user write functions.

As a result, an unauthenticated attacker can send a request specifying a target user ID along with writable fields such as the user password. This can be used to overwrite the password of any existing user, including the site’s administrator, leading to a complete site takeover.

It is worth noting that save_user is only one of several administrator actions reachable through this bypass. Because the flaw defeats the access controls on the pods_admin router as a whole, other privileged API methods are exposed as well, which could be used to write PHP to a file, delete arbitrary files, or perform other administrator-level actions.

As with all privilege escalation vulnerabilities, this can lead to complete site compromise.

Wordfence Firewall

The following graphic demonstrates the steps to exploitation an attacker might take and at which point the Wordfence firewall would block an attacker from successfully exploiting the vulnerability.

pods admin save user howto wordfence firewall

Disclosure Timeline

2026-08-10

We received the vulnerability submission
A security researcher submitted an unauthenticated privilege escalation vulnerability in Pods through the Wordfence Bug Bounty Program.
2026-08-12

We validated the report and disclosed it to the vendor
Our team confirmed the proof of concept and sent full disclosure details to the developer through our Wordfence Vulnerability Management Portal.
2026-08-12

We deployed a firewall rule to Premium usersFirewall rule
Wordfence Premium, Wordfence Care, and Wordfence Response users received a firewall rule to protect against known exploits targeting this vulnerability in Pods, hosted on WordPress.org.
2026-08-12

Vendor acknowledged the report
The developer confirmed the issue and began working on a fix.
2026-08-14

We reviewed and approved the vendor’s patch
The vendor submitted a patch for review, which our team reviewed and approved.
2026-08-14

Vendor released patched version 3.3.9.1Patch
The fully patched versions, 2.8.23.4, 2.9.19.4, 3.0.10.4, 3.1.4.2, 3.2.8.3, and 3.3.9.1, were released and coordinated with the WordPress.org plugins team to force update impacted sites.
2026-09-11

We will deploy the firewall rule to free usersFirewall rule
Sites using the free version of Wordfence will receive the same protection 30 days later.
Wordfence action
Vendor / external action

Conclusion

In this blog post, we detailed an Unauthenticated Privilege Escalation vulnerability within the Pods plugin affecting all versions up to, and including, 3.3.9. This vulnerability allows unauthenticated threat actors to escalate their privileges to administrator, overwrite any user’s password, or perform other administrator actions, leading to complete site compromise. This is due to the plugin’s pods_admin AJAX router routing all of its access checks through an error function that fails to terminate the request.

We encourage WordPress users to verify that their sites are updated to the latest patched version of Pods as soon as possible considering the critical nature of this vulnerability.

Wordfence Premium, Wordfence Care, and Wordfence Response users received a firewall rule to protect against known exploits targeting this vulnerability in Pods, hosted on WordPress.org, on August 12, 2026. Sites using the free version of Wordfence will receive the same protection 30 days later on September 11, 2026.

If you know someone who uses this plugin on their site, we recommend sharing this advisory with them to ensure their site remains secure, as this vulnerability poses a significant risk.

The post 100,000 WordPress Sites Affected by Privilege Escalation Vulnerability in Pods WordPress Plugin appeared first on Wordfence.