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
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.
Disclosure Timeline
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.
