Todos los posts
Encontrando CVEs en WordPress: CVE-2026-39534, autorización ausente en WP Directory Kit

Encontrando CVEs en WordPress: CVE-2026-39534, autorización ausente en WP Directory Kit

Esta entrada documentaCVE-2026-39534Ficha completa del CVE →

Algunos plugins de WordPress se montan su propio MVC. Una clase controlador con métodos, un dispatcher que lee qué método llamar desde la petición, modelos que puedes cargar por nombre. El dispatcher se convierte en el único perímetro de cada método de cada controlador que tiene el plugin. Si ese perímetro no comprueba autenticación, cada método detrás de él es un endpoint no autenticado, dé igual lo que el método haga.

Esa es la forma de CVE-2026-39534. WP Directory Kit es un plugin de WordPress para montar directorios con búsqueda de listings, ubicaciones y categorías, con unas 3.000 instalaciones activas. Su controlador AJAX de frontend expone un método select_2_ajax que carga un modelo por nombre desde el cuerpo de la petición y devuelve las filas del modelo. Sin comprobación de autenticación. Sin nonce. Sin allowlist sobre el nombre del modelo. Mandas table=user_m y la respuesta es cada usuario agente del directorio que tiene el sitio, directo de la tabla de usuarios de WordPress. Patchstack gestionó el reporte. El fabricante parcheó en 1.5.1.

Así llega la petición al método vulnerable. El plugin registra dos hooks AJAX en includes/class-wpdirectorykit.php:

$this->loader->add_action('wp_ajax_wdk_public_action',        $plugin_public, 'ajax_public');
$this->loader->add_action('wp_ajax_nopriv_wdk_public_action', $plugin_public, 'ajax_public');

La variante _nopriv_ significa que visitantes anónimos pueden llamar la action. El handler es ajax_public, en public/class-wpdirectorykit-public.php línea 289:

public function ajax_public()
{
    $page = '';
    $function = '';

    if(isset($_POST['page']))     $page     = sanitize_text_field($_POST['page']);
    if(isset($_POST['function'])) $function = sanitize_text_field($_POST['function']);

    /* protect access only to ajax controller */
    if($page != 'wdk_frontendajax' && $page != 'wdk_backendajax') {
        exit(esc_html__('Access denied','wdk-bookings'));
    }

    $WMVC = &wdk_get_instance();
    $WMVC->load_controller($page, $function, array());
}

La comprobación es real pero estrecha. Rechaza cualquier petición que nombre el controlador equivocado, que es lo que el comentario promete. Lo que el comentario no dice, y lo que nada más en el dispatcher hace, es verificar al que llama. El hook nopriv acepta tráfico anónimo. El handler acepta tráfico anónimo. Pasada esta puerta, cada método público del controlador nombrado en $page es alcanzable mandando function=<método> en el mismo POST.

El controlador al que dispatcha es Wdk_frontendajax.php. Uno de sus métodos públicos es select_2_ajax, en la línea 554 del código fuente de 1.5.0. El extracto relevante:

public function select_2_ajax($output="", $atts=array(), $instance=NULL)
{
    $this->load->load_helper('listing');
    $this->load->model('listing_m');
    $this->load->model('listingfield_m');

    $data = array();
    $parameters = array();

    foreach ($_POST as $key => $value) {
        $parameters[$key] = sanitize_text_field($value);
    }

    if(empty($parameters['table'])) return false;

    $model_name = $parameters['table'];

    // ... montaje de columnas de búsqueda/key/print ...

    $this->load->model($model_name);

    // ... lógica del filtro de búsqueda ...

    $db_results = $this->$model_name->get_pagination($limit, $offset, $where);

    foreach($db_results as $row) {
        $results[] = [
            'id'   => wmvc_show_data($key_column, $row),
            'text' => $level_gen.esc_html__(trim(wmvc_show_data($print_column, $row)), 'wpdirectorykit'),
        ];
    }

    $data['results'] = $results;
    $this->output($data);
}

$parameters['table'] es el input del atacante. El método llama a $this->load->model($model_name) para cargarlo y luego a $this->$model_name->get_pagination(...) para volcar filas. La clase modelo tiene que existir para que la carga funcione, y esa es la única puerta blanda sobre el input. Cualquier modelo que cargue el plugin es alcanzable.

El plugin trae un modelo user_m que envuelve la tabla de usuarios de WordPress para el dashboard propio del plugin. Su get_pagination une wp_users con wp_usermeta y aplica por defecto un filtro meta_value LIKE '%wdk_agent%' sobre wp_capabilities, así que las filas que vuelven son los usuarios agentes del directorio, no la tabla completa de WordPress. La lista del SELECT está metida a fuego en el modelo: ID, user_login, user_nicename, user_email, display_name, user_status, user_url más un contador de listings. Esas son las columnas que el dispatcher puede leer por fila.

Los parámetros key_column y print_column los controla el atacante, pero solo como etiqueta. El dispatcher lee cada uno del objeto fila que el modelo devolvió. Si la columna no está en el SELECT fijo del modelo, la propiedad no existe y el valor vuelve vacío. user_pass no está en el SELECT, así que este método no expone hashes de contraseñas. La fuga son las columnas que el modelo ya trajo: ID, login, email, display name y el resto. Un único POST sin autenticación al endpoint admin-ajax con los parámetros adecuados devuelve el email y el login de cada usuario agente del directorio en el sitio, paginado. En un sitio que monta un directorio de listings de pago, los usuarios agentes son la base de clientes.

Patchstack puntuó esto como CVSS 7.5: vector de ataque red, sin autenticación, sin interacción del usuario, impacto alto en confidencialidad, sobre la base de que la divulgación sin control de los miembros del directorio es el peor caso en un despliegue típico de WPDK. El controlador del plugin es un proxy fino para leer cada fila de cualquier modelo que cargue, acotado por el filtro que ese modelo se haya metido en sus propias queries. No incluyo aquí el cuerpo exacto de la petición, la ruta no autenticada está en los registros de Patchstack y NVD enlazados desde la página del CVE si la necesitas para testear tu propia instalación.

El mismo fallo es alcanzable a través de cualquier modelo que cargue el plugin. listing_m, category_m, location_m, cada modelo de campo de listing. Cada uno es una query separada contra una tabla separada, sin autenticación en ninguna. En un sitio que usa el plugin para listings de pago, esa es la base de datos de clientes. En un sitio que lo usa para directorios solo para miembros, son todos los miembros.

El fix en 1.5.1 añade dos cosas al principio de select_2_ajax:

public function select_2_ajax($output="", $atts=array(), $instance=NULL)
{
    check_ajax_referer('wdk_secure_ajax', 'wdk_secure');

    $this->load->load_helper('listing');
    $this->load->model('listing_m');
    $this->load->model('listingfield_m');

    // ... recolección de parámetros sin cambios ...

    $model_name = $parameters['table'];

    // allow only 'listing_m', 'category_m', or 'location_m' for security
    $allowed_models = array('listing_m', 'category_m', 'location_m');
    if (!in_array($model_name, $allowed_models)) {
        return false;
    }

    // ... resto sin cambios ...
}

Dos cerrojos, pero no tiran del mismo peso:

  • Comprobación de nonce. check_ajax_referer('wdk_secure_ajax', 'wdk_secure') exige que la petición lleve un parámetro wdk_secure con un nonce válido para la action wdk_secure_ajax. El JS de frontend del plugin expone ese nonce vía wp_localize_script en cada página pública donde se cargue el script, así que un atacante determinado puede pillarlo del HTML renderizado y reutilizarlo. La comprobación sube el coste de la automatización y para CSRF. Por sí sola no mantiene fuera a un atacante no autenticado.
  • Allowlist de modelos. Mete a fuego los tres modelos que el método debe soportar. Este es el fix que carga el peso. Incluso con un nonce válido en la mano, un atacante no puede pivotar a user_m ni a ningún otro modelo que el plugin cargue. El volcado de la tabla de usuarios desaparece en cuanto el allowlist está en su sitio.

El nonce sube el coste. El allowlist contiene el radio de impacto. El CVE se cierra por el segundo.

La misma auditoría que destapó este BAC también encontró una inyección SQL en otros tres métodos del mismo controlador. El changeset de 1.5.1 mete los dos fixes, que es por qué el mismo parche cierra un CVE distinto.

La forma de CVE-2026-39534 es un patrón que busco en cualquier plugin que se monte su propio MVC. El dispatcher es un único punto que decide qué se llama. El trabajo del dispatcher es exigir tres cosas antes de invocar el método: quién está llamando (autenticación), si vino por la UI legítima (nonce) y si el método que ha nombrado es uno que queremos exponer (allowlist). Plugins que envuelven el dispatcher alrededor de decenas de métodos a menudo no comprueban ninguna de las tres. Los autores de los métodos asumen que el dispatcher comprobó. El autor del dispatcher asume que el método comprueba. Nadie comprueba.

El patrón más amplio, fuera de plugins WordPress, está en cualquier sitio donde un framework promueve "convención sobre configuración" para routing. Nombres de método desde URLs en Rails, nombres de action desde el cuerpo de la petición en clones de CodeIgniter, dispatchers RPC que cogen el nombre de la operación del JSON. Cada framework que hace esto exige al desarrollador pensar en autorización en el dispatcher, no en el método. Siempre que alguien tira de la comodidad del dispatch dinámico, el perímetro tiene que ir con ello.

Ahora la parte que importa si estás empezando. ¿Cómo encuentras esto tú?

Los plugins con MVC propio son una rebanada estrecha del ecosistema WordPress pero fértil. Plugins que se montaron su framework pronto (LatePoint, WP Directory Kit, varios sistemas de membresía y reservas) usan esta forma. Los bugs son sistemáticos cuando faltan las comprobaciones del perímetro.

Tres greps para sacar el patrón a la luz.

# 1. Hooks AJAX no autenticados de entrada
grep -rnE "add_action\(\s*['\"]wp_ajax_nopriv_" wp-content/plugins/target/

# 2. Invocación dinámica de método en PHP (p. ej. $this->$variable(...))
grep -rnE '\$this->\$[a-z_]+\(' wp-content/plugins/target/
grep -rnE 'call_user_func.*\$_(POST|GET|REQUEST)' wp-content/plugins/target/

# 3. Carga dinámica de modelo o clase desde input de la petición
grep -rnE '->load->model\(\s*\$' wp-content/plugins/target/
grep -rnE '->load_controller\(' wp-content/plugins/target/
grep -rnE 'new \$[a-z_]+\(' wp-content/plugins/target/

Cada grep es una capa distinta de la misma forma:

Grep 1 encuentra el hook de entrada. Si tienes un hook _nopriv_, tienes una ruta no autenticada hacia el plugin. Eso es normal para endpoints públicos de solo lectura como búsqueda y paginación. Se vuelve un problema cuando la ruta delega el dispatch al cuerpo de la petición.

Grep 2 encuentra dispatchers de método por variable. $this->$function(...) es la idiom clásica de PHP para "llama al método nombrado en $function". Cuando $function viene de $_POST, cada método público de la clase es alcanzable desde la petición.

Grep 3 encuentra carga dinámica donde el nombre de lo que se carga viene de la petición. ->load->model($model_name) es estilo CodeIgniter. new $class() es más general. En cualquiera de los dos casos, el atacante eligió qué se instancia, y la pregunta de auditoría pasa a ser "qué modelos o clases trae el plugin sobre los que el método de abajo va a operar".

Cuando tengas un hit, el recorrido de auditoría es:

  1. Vuelve del dispatcher al hook AJAX. Si el hook es nopriv y el dispatcher no llama a check_ajax_referer, current_user_can o is_user_logged_in, cada método que el dispatcher alcanza es no autenticado. Para y confirma.
  2. Avanza a un método al que el dispatcher llega. Mira si el método hace alguna autorización propia. La mayoría no, porque el autor del método confió en el dispatcher.
  3. Lista los métodos que el dispatcher alcanza. Para un dispatcher estilo CodeIgniter, son todos los métodos públicos de cada controlador que el dispatcher puede cargar. Para un dispatcher $this->$function, son todos los métodos públicos de la clase actual.
  4. Lista los modelos o clases de abajo que esos métodos cargan por nombre. Esa es la superficie de lectura.

El camino del exploit suele ser el mismo en los métodos que el dispatcher alcanza. Eliges uno. Montas la petición. La prueba de concepto es un curl. El trabajo del reportero es enseñar el bypass de autenticación y un ejemplo de exposición de datos. El fix suele añadir una comprobación de nonce en el dispatcher y aprietar el allowlist de input de cada método. Para este CVE en concreto, el techo del impacto lo pone lo que el get_pagination del modelo cargado seleccione: un método hermano select_2_ajax_user es alcanzable a través del mismo dispatcher y usa el mismo modelo user_m con un filtro de rol wdk_* algo más amplio, pero la misma lista de SELECT fija, así que la fuga tiene la misma forma por cualquiera de los dos caminos. Patchstack y NVD llevan el formato de la petición no autenticada para quien quiera testear su propia instalación.

Para el reporte, este lo gestionó Patchstack. WP Directory Kit está dentro del scope de Patchstack y de Wordfence en el momento de escribir esto. La auditoría produjo dos CVEs contra el mismo controlador con cinco días de diferencia y Patchstack llevó los dos. Lee las reglas actuales de cada programa antes de enviar y elige por tiempo de respuesta, recompensa y la relación previa del programa con el fabricante.

Cronología de CVE-2026-39534. 18 de febrero de 2026, vulnerabilidad descubierta, reportada a Patchstack ese mismo día. 8 de abril de 2026, CVE publicado y parche distribuido en 1.5.1. Siete semanas de punta a punta. La misma auditoría produjo CVE-2026-39531, una inyección SQL en tres métodos adyacentes del mismo controlador, publicada cinco días después y parcheada en la misma release 1.5.1.

El patrón que esto va redondeando entre los writeups hasta ahora: comprobaciones de propiedad ausentes (CVE-2025-3769), escapado de salida ausente (CVE-2025-4392), parametrización de input ausente (CVE-2026-39511) y ahora autorización ausente en el dispatcher (CVE-2026-39534). Cuatro clases de bug, una forma raíz cada una, todas embarcadas en plugins ahora mismo. El patrón dispatcher es el que peor escala, porque cada método detrás de un dispatcher roto es su propio bug. CVE-2026-39534 es un método. El mismo controlador tiene otros doce métodos públicos detrás del mismo dispatcher, y tres de ellos fueron el objeto de CVE-2026-39531, una inyección SQL publicada cinco días después en la misma release 1.5.1. El fix tuvo que aterrizar en el perímetro.

¿Quieres que encuentre esto en tu aplicación, o aprender a encontrarlo tú?

Solicitar un pentestReservar mentoría
← AnteriorEncontrando CVEs en WordPress: CVE-2026-39511, inyección SQL en WP Photo Album PlusSiguiente →Bypass del WAF de CrowdSec AppSec por chunked transfer encoding (CVE-2026-44982)