Neutron, our AI engine, scored 97.1% on UC Berkeley's CyberGym benchmark. Learn more

Deep Dive

Deep Dive

LatePoint Booking Plugin Account Takeover (CVE-2026-118066)

An unauthenticated SQL injection in LatePoint lets an attacker take over a customer account. Fixed in 5.7.7; 5.7.8 completes the hardening.

TL;DR

  • An unauthenticated attacker can reset the password of a LatePoint customer account by sending the literal string IS NOT NULL as a password-reset token. Wordfence reported the flaw, the CVE record credits Jakub Herman, and it was published on 2026-11-07.
  • LatePoint 5.7.6 and earlier are affected. The plugin has 100,000 active installs. The flaw is in the query builder that turns the sentinel into SQL, reached through two public AJAX routes.
  • The CVE record rates it CVSS v3.1 9.1 Critical. No exploitation in the wild is reported, and the CVE is not in CISA KEV as of 2026-11-07.
  • Update to 5.7.8. If you cannot update, block the two documented request patterns and audit customer password resets; see "How do I fix it?".

Am I affected?

You are exposed only when all of these conditions hold:

  • The site runs the LatePoint plugin at 5.7.6 or earlier.
  • The plugin is active, so its public AJAX routes answer requests.
  • At least one customer record exists with a non-null account_nonse. LatePoint creates this value for customers, so a site with a single booking is enough.
  • The attacker can learn a customer email address. The advisory's chain reads one from an order intent; a public booking confirmation or a staff directory can also supply one.
  • For the linked WordPress account impact, the wp_users_as_customers option is enabled.

The vulnerable code is in the plugin's core customer and query classes. No optional module is needed. The two routes are registered for logged-out visitors, so the attacker does not need an account on the site.

Branch Affected versions Fixed version
LatePoint 5.7.6 and earlier 5.7.7 (2026-11-02), then 5.7.8 (2026-11-03) for the complete model fix

The CVE record lists 5.7.6 and earlier as affected. The 5.7.7 release blocks the two documented request paths. The 5.7.8 release removes the literal-string branch in the query builder, so use 5.7.8.

Check the installed version with WP-CLI:

wp plugin get latepoint --field=version

Without WP-CLI, read the plugin header:

grep -m1 "Version:" wp-content/plugins/latepoint/latepoint.php

Any version up to 5.7.6 is affected.

How do I fix it?

  1. Update LatePoint to 5.7.8 through the WordPress admin, or run wp plugin update latepoint. The plugin page lists 5.7.8 as the current release, published on 2026-11-03.
  2. Confirm the new version with wp plugin get latepoint --field=version.
  3. If the site runs a cached copy of the plugin files, clear the cache and confirm the plugin header shows 5.7.8.
  4. Review customer password resets and sign-ins for the period before the update. The check steps are in "How do I check if I was compromised?".

If you cannot update, disable LatePoint until you can. Disabling the plugin removes the booking and customer cabinet pages, so plan a maintenance window.

A web application firewall rule can reduce exposure, but it does not fix the code. The two request patterns to block are:

  • route_name=customer_cabinet__change_password with a password_reset_token value that contains IS NOT NULL.
  • route_name=steps__start_from_order_intent with an order_intent_key value that contains IS NOT NULL.

Match the URL-decoded parameter value, case-insensitively. Attackers can vary the encoding, so a rule that matches only the literal string IS+NOT+NULL will miss other forms. The rule also blocks a legitimate reset only if a real token contains a space, which the plugin's hex token never does.

The vendor lists no workaround. Restricting the accounts that can reach the customer cabinet does not help, because the routes are public by design.

What is the root cause?

The query builder treats a WHERE value equal to the literal string IS NOT NULL as a SQL keyword instead of a value to bind. LatePoint's OsModel::build_conditions_query() compares each value with the string and appends the keyword to the SQL text. It adds no placeholder and no bind value for that branch.

The vulnerable branch in lib/models/model.php at 5.7.6:

} elseif ( $value === 'IS NULL' ) {
    // IS NULL
    $temp_query = $this->with_table_name( $column ) . ' IS NULL ';
} elseif ( $value === 'IS NOT NULL' ) {
    // IS NOT NULL
    $temp_query = $this->with_table_name( $column ) . ' IS NOT NULL ';
} elseif ( is_array( $value ) && ! empty( $value ) ) {
    $temp_query = $this->with_table_name( $column ) . ' IN ' . OsModel::where_in_array_to_string( $value );
} else {
    // Add to list of query values
    $where_values[] = $value;
}

The customer helper passes a request value straight into that builder:

public static function get_by_account_nonse( $account_nonse ) {
    if ( empty( $account_nonse ) ) {
        return false;
    }
    $account_nonse = sanitize_text_field( $account_nonse );
    $customer      = new OsCustomerModel();

    return $customer->where( [ 'account_nonse' => $account_nonse ] )->set_limit( 1 )->get_results_as_models();
}

sanitize_text_field() strips tags and extra whitespace. It keeps spaces and SQL keywords, so IS NOT NULL passes through unchanged. The helper then builds a query whose condition is account_nonse IS NOT NULL. Every customer with a non-null account_nonse matches, and set_limit(1) returns one row.

The password reset endpoint uses that helper as its authentication check:

} elseif ( ! empty( $params['password_reset_token'] ) ) {
    $params['password_reset_token'] = sanitize_text_field( $params['password_reset_token'] );
    $customer                       = OsCustomerHelper::get_by_account_nonse( $params['password_reset_token'] );

    // Validate token expiration (15 minutes)
    if ( $customer ) {
        $created_at = OsMetaHelper::get_customer_meta_by_key( 'password_reset_token_created_at', $customer->id );

The endpoint treats the returned customer as the owner of the reset token. It does not compare the token with a stored value. The only remaining check is the 15-minute freshness timestamp, which the attacker can start by requesting a reset for the target address.

The same pattern appears in the order-intent route. OsOrderIntentHelper::get_order_intent_by_intent_key() passes intent_key into where(), so params[order_intent_key]=IS NOT NULL becomes intent_key IS NOT NULL and returns the first order intent. The booking form then renders that record's customer name and email.

The 5.7.5 release added an is_scalar() check to a different lookup, the customer uuid in OsStepsHelper::set_customer_object(). That check rejects a nested array, but it accepts the scalar string IS NOT NULL, so it does not close this path.

How is it exploited?

  1. The attacker learns a customer email address. The advisory's chain sends params[order_intent_key]=IS NOT NULL to the public steps__start_from_order_intent route. The query returns the first order intent, and the rendered booking form shows that record's customer name and email.
  2. The attacker requests a password reset for that address through the public customer_cabinet__request_password_reset_token route, with params[password_reset_email]=<email>. LatePoint emails a token to the customer and saves password_reset_token_created_at for the customer. The 15-minute window starts.
  3. The attacker posts to the public customer_cabinet__change_password route with params[password_reset_token]=IS NOT NULL and a new password. The endpoint looks up the customer through the sentinel and returns the first matching row, which the advisory describes as the lowest-id customer.
  4. The endpoint reads that customer's reset timestamp. The timestamp is fresh because of the reset request, so the 15-minute check passes. The endpoint then calls update_password() with the attacker's password.
  5. The attacker signs in as the customer. The account now exposes the customer's bookings, orders and personal data. If wp_users_as_customers is enabled, the linked WordPress account is reachable through the same customer record.

The advisory documents the request parameters. Adapted from the advisory, the password change request has this shape:

POST /wp-admin/admin-ajax.php
Content-Type: application/x-www-form-urlencoded

action=latepoint_route_call&route_name=customer_cabinet__change_password&params[password_reset_token]=IS+NOT+NULL&params[password]=<new-password>&params[password_confirmation]=<new-password>

The order-intent request has the same shape with route_name=steps__start_from_order_intent and params[order_intent_key]=IS+NOT+NULL.

The attacker does not need the emailed token. The sentinel replaces the token lookup, and the reset request only starts the freshness timer.

How does the attack flow?

An unauthenticated attacker turns a request string into a SQL condition, takes over the first matching customer, and starts the reset timer that the endpoint checks.

flowchart LR A["Unauthenticated request"] --> B["password_reset_token set to IS NOT NULL"] B --> C["sanitize_text_field keeps the string"] C --> D["build_conditions_query emits account_nonse IS NOT NULL"] D --> E["set_limit(1) returns the first customer"] E --> F["Fresh reset timestamp passes the 15-minute check"] F --> G["Attacker password written to the customer account"]

What can an attacker do?

An unauthenticated attacker can take over one LatePoint customer account per request and read or change that customer's bookings, orders and personal data. The account that gets overwritten is the first row the sentinel query returns, which the advisory describes as the lowest-id customer.

The impact depends on these conditions:

  • The site runs LatePoint 5.7.6 or earlier and the plugin is active.
  • The attacker can reach the public AJAX routes.
  • A customer record has a non-null account_nonse.
  • The attacker can learn a customer email address and request a reset for it.
  • The customer's reset timestamp is within the 15-minute window when the password change request arrives.
  • For the linked WordPress account, wp_users_as_customers is enabled.

The CVE record scores confidentiality and integrity as high and availability as none. The attacker can read the customer's data and change the password, but the flaw does not execute code and does not change the security scope. The plugin links WordPress accounts only to customer-safe roles, so the linked account is a subscriber or customer, not an administrator, unless the site has changed that configuration.

The advisory does not demonstrate a path to the WordPress administrator account. It describes customer account takeover, with the linked WordPress subscriber or customer account when the option is enabled.

Why does the fix work?

The fix stops the request string from reaching the SQL keyword branch. LatePoint 5.7.7 adds a strict character check at both documented entry points. LatePoint 5.7.8 removes the literal IS NOT NULL branch from the query builder, so only an internal marker object can request the keyword.

The 5.7.7 guard in lib/controllers/customer_cabinet_controller.php:

             } elseif ( ! empty( $params['password_reset_token'] ) ) {
                 $params['password_reset_token'] = sanitize_text_field( $params['password_reset_token'] );
-                $customer                       = OsCustomerHelper::get_by_account_nonse( $params['password_reset_token'] );
+                // Reset token is a hex key - a value with spaces/operators (e.g. "IS NOT NULL") is never valid.
+                if ( is_string( $params['password_reset_token'] ) && ctype_alnum( $params['password_reset_token'] ) ) {
+                    $customer = OsCustomerHelper::get_by_account_nonse( $params['password_reset_token'] );
+                }

The 5.7.7 guard in lib/controllers/steps_controller.php:

         public function start_from_order_intent() {
-            $order_intent = OsOrderIntentHelper::get_order_intent_by_intent_key( $this->params['order_intent_key'] );
+            // Intent key is a hex token - a value with spaces/operators (e.g. "IS NOT NULL") is never valid.
+            $order_intent_key = sanitize_text_field( $this->params['order_intent_key'] ?? '' );
+            $order_intent     = ( is_string( $order_intent_key ) && ctype_alnum( $order_intent_key ) ) ? OsOrderIntentHelper::get_order_intent_by_intent_key( $order_intent_key ) : new OsOrderIntentModel();

ctype_alnum() rejects the space in IS NOT NULL, so the lookup is skipped and the request fails closed. The same release adds null_condition() and not_null_condition() marker objects and where_null() and where_not_null() helpers, so internal code can still express a NULL comparison without a request string.

The 5.7.8 change in lib/models/model.php removes the literal branch:

-                    } elseif ( $value === 'IS NULL' || $value === self::null_condition() ) {
+                    } elseif ( $value === self::null_condition() || ( $value === 'IS NULL' && self::is_legacy_null_condition() ) ) {
                         // IS NULL
                         $temp_query = $this->with_table_name( $column ) . ' IS NULL ';
-                    } elseif ( $value === 'IS NOT NULL' || $value === self::not_null_condition() ) {
+                    } elseif ( $value === self::not_null_condition() ) {
                         // IS NOT NULL
                         $temp_query = $this->with_table_name( $column ) . ' IS NOT NULL ';
                     } elseif ( is_array( $value ) && ! empty( $value ) ) {

After 5.7.8, the string IS NOT NULL falls through to the bound-value branch. The IS NULL literal remains only for an older LatePoint Pro add-on, and only when the add-on version is earlier than 1.7.7. The CVE record treats 5.7.7 as the fixed release. The 5.7.8 change closes the model-level path for any other caller that passes request data into where().

How do I check if I was compromised?

Start with the logs that capture the request body, such as a web application firewall audit log or a reverse-proxy request log. LatePoint reads the route and parameters from the POST body, so a standard access log records only the admin-ajax.php request. If the attacker sends the parameters in the query string, the access log carries them.

grep -Ei 'route_name=(customer_cabinet__change_password|steps__start_from_order_intent)' /var/log/apache2/*access*.log /var/log/nginx/*access*.log /var/log/modsec_audit.log 2>/dev/null

Search the matching lines for the sentinel, in URL-decoded form and case-insensitively. The value can appear as IS+NOT+NULL, IS%20NOT%20NULL or a mixed encoding. A hit is a lead, not proof, because a scanner can send the same string.

Check the reset timestamps for customers you did not expect:

wp db query "SELECT object_id, meta_value FROM wp_latepoint_customer_meta WHERE meta_key='password_reset_token_created_at' ORDER BY meta_value DESC LIMIT 50;"

Replace wp_ with your table prefix. A reset timestamp is written when LatePoint sends a reset email. Compare the rows with your support records. A reset the customer did not request, followed by a sign-in from an unfamiliar address, is a stronger signal.

LatePoint does not keep an audit log of customer password changes. Compare the customer's password-change reports with the reset rows, and review the web log for sign-ins to the customer cabinet around the same time.

The vendor publishes no indicators of compromise. An attempt that failed leaves no vendor-specific trace, and a successful attempt may leave only the changed password and the reset timestamp.

Timeline

  • 2026-10-27: CVE-2026-118066 is reserved.
  • 2026-11-02: LatePoint 5.7.7 is released with the two entry-point guards and the marker helpers.
  • 2026-11-03: LatePoint 5.7.8 is released and removes the literal IS NOT NULL branch from the query builder.
  • 2026-11-07: the CVE record is published at 11:26 UTC, and advisory GHSA-rmmm-8g4j-3qgc is published at 12:31 UTC.

The two fix releases landed within two days, and the CVE record followed four days after 5.7.8.
Figure 1: From the 5.7.7 fix to 5.7.8 and the CVE record. Source: LatePoint changelog and the CVE record, checked November 7, 2026

FAQ

Does the attacker need an account on the site?

No. The two routes are registered for logged-out visitors, and the password change endpoint skips the nonce check when it receives a reset token. The attacker only needs network access to the site.

Is 5.7.7 enough?

It blocks the two documented request paths. The 5.7.7 query builder still treats the literal IS NOT NULL string as SQL, so a different caller that passes request data into where() can still reach the branch. The 5.7.8 release removes that branch. Update to 5.7.8.

Can the attacker take over the WordPress administrator account?

Not by default. The flaw takes over a LatePoint customer record. The plugin links a customer to a WordPress account only when wp_users_as_customers is enabled, and it restricts that link to customer-safe roles. A site that has changed the role mapping should review its configuration.

Was this exploited in the wild?

No exploitation in the wild is reported as of 2026-11-07, and the CVE is not in CISA KEV. The advisory includes the request parameters, so the technique is public.

What if I cannot update right away?

Disable LatePoint until you can update. If the booking flow must stay online, block the two request patterns at the web application firewall and audit reset timestamps daily. The firewall rule reduces exposure but does not correct the query builder.

Does a failed request prove the site is safe?

No. The sentinel returns the first matching customer, so a request can succeed even when the attacker guessed the wrong email for the reset timer. A failed password change can still have matched a customer and failed only at the freshness check. Review the logs and the reset rows together.

References

  • GitHub advisory GHSA-rmmm-8g4j-3qgc, published 2026-11-07, with the affected range, the vulnerable code references and the request parameters: https://github.com/advisories/GHSA-rmmm-8g4j-3qgc (checked November 7, 2026)
  • CVE-2026-118066 record, published 2026-11-07, CVSS v3.1 9.1 and CWE-287: https://www.cve.org/CVERecord?id=CVE-2026-118066 (checked November 7, 2026)
  • Wordfence threat-intelligence advisory for CVE-2026-118066, the CNA record: https://www.wordfence.com/threat-intel/vulnerabilities/id/2f7f5dc8-8cf7-493d-b012-89ff36a4dc02 (checked November 7, 2026)
  • LatePoint changelog, 5.7.7 on 2026-11-02 and 5.7.8 on 2026-11-03: https://wordpress.org/plugins/latepoint/#developers (checked November 7, 2026)
  • LatePoint lib/models/model.php at 5.7.6, the IS NOT NULL branch: https://plugins.trac.wordpress.org/browser/latepoint/tags/5.7.6/lib/models/model.php#L411 (checked November 7, 2026)
  • LatePoint lib/helpers/customer_helper.php at 5.7.6, get_by_account_nonse(): https://plugins.trac.wordpress.org/browser/latepoint/tags/5.7.6/lib/helpers/customer_helper.php#L228 (checked November 7, 2026)
  • LatePoint lib/controllers/customer_cabinet_controller.php at 5.7.6, change_password(): https://plugins.trac.wordpress.org/browser/latepoint/tags/5.7.6/lib/controllers/customer_cabinet_controller.php#L468 (checked November 7, 2026)
  • LatePoint lib/controllers/steps_controller.php at 5.7.6, start_from_order_intent(): https://plugins.trac.wordpress.org/browser/latepoint/tags/5.7.6/lib/controllers/steps_controller.php#L187 (checked November 7, 2026)
  • LatePoint changeset 3779410, the security change referenced by the advisory: https://plugins.trac.wordpress.org/changeset?reponame=&old=3779410%40latepoint&new=3779410%40latepoint (checked November 7, 2026)
  • LatePoint lib/models/model.php at 5.7.8, the marker-object branch: https://plugins.trac.wordpress.org/browser/latepoint/tags/5.7.8/lib/models/model.php#L455 (checked November 7, 2026)
  • WordPress.org plugin API, latepoint, 100,000 active installs and 1,841,969 downloads: https://wordpress.org/plugins/latepoint/ (checked November 7, 2026)
  • CISA Known Exploited Vulnerabilities catalog JSON feed, checked for CVE-2026-118066 on November 7, 2026 (not listed): https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json (checked November 7, 2026)