Todos los posts
Dos líneas de defensa que abrieron la puerta a la inyección SQL

Dos líneas de defensa que abrieron la puerta a la inyección SQL

Dos líneas de código. Ambas escritas para proteger la aplicación. Juntas, crearon una inyección SQL crítica.

Durante una sesión de bug bounty centrada en plugins de WordPress, encontré una inyección SQL en un sitio que parecía ya corregido. El desarrollador había hecho lo correcto. Usó $wpdb->prepare(), la función de WordPress diseñada específicamente para prevenir inyecciones SQL. Los placeholders estaban en su sitio. La entrada del usuario se pasaba mediante %s. El escapado lo gestionaba el framework.

Y aun así, la vulnerabilidad era real.

El problema estaba en cómo se había construido la sentencia preparada. La consulta usaba una cláusula IN. Ya conoces el patrón: SELECT algo FROM tabla WHERE id IN (1, 2, 3). El desarrollador necesitaba pasar una lista dinámica de valores a esa cláusula.

En lugar de crear placeholders individuales para cada valor, construyó una cadena separada por comas con la entrada del usuario y la pasó entera como un único placeholder %s. Desde la perspectiva de WordPress, era una sola cadena. Así que prepare() la trató como una sola cadena. Envolvió toda la lista entre comillas y escapó las comillas internas que pudieran romper la sintaxis SQL.

// Línea 1 (la sentencia preparada "segura")
$ids_csv = $_GET['ids']; // p. ej. "1,2,3"
$query = $wpdb->prepare(
    "SELECT * FROM {$wpdb->prefix}orders WHERE id IN (%s)",
    $ids_csv
);

La consulta funcionaba en pruebas. Los valores estaban escapados. Prepare() cumplió su función. Pero el resultado no era el que el desarrollador esperaba. La cláusula IN ahora comparaba contra una única cadena entrecomillada en lugar de una lista de valores individuales. Funcionalmente, la consulta se rompió. Dejó de devolver los resultados esperados.

Así que alguien añadió una segunda línea para arreglarlo: stripslashes().

// Línea 2 (el "arreglo" que vuelve a abrir la puerta)
$query = $wpdb->prepare(
    "SELECT * FROM {$wpdb->prefix}orders WHERE id IN (%s)",
    $ids_csv
);
$query = stripslashes( $query );
$wpdb->get_results( $query );

Esa función hizo exactamente lo que su nombre indica. Eliminó las barras invertidas que $wpdb->prepare() había añadido para escapar caracteres peligrosos. La consulta volvió a funcionar. La cláusula IN devolvió resultados correctos. El desarrollador siguió adelante.

Pero esas barras invertidas no eran decoración. Eran la capa de seguridad. Al eliminarlas, la segunda línea deshizo todo lo que la primera había hecho para prevenir la inyección. La entrada controlada por el usuario ahora fluía hacia la consulta SQL sin el escapado adecuado.

Dos funciones. Una para proteger la consulta. Otra para hacerla funcionar. La combinación abrió la puerta a la inyección SQL.

Este es el tipo de vulnerabilidad que es fácil pasar por alto en una revisión de código. Cada línea tiene sentido por sí sola. Prepare() es el enfoque correcto. Stripslashes() resuelve un problema real al que se enfrentaba el desarrollador. El problema solo se hace visible cuando entiendes cómo interactúan.

He visto patrones similares antes. Un desarrollador implementa un control de seguridad correctamente, luego se encuentra un efecto secundario que rompe la funcionalidad. Bajo presión para entregar, añade un parche que neutraliza la protección original. La aplicación funciona. Las pruebas pasan. La vulnerabilidad llega a producción.

Para entender por qué, hay que saber cómo funciona %s. El placeholder %s en $wpdb->prepare() está diseñado para un único valor escalar, no para una lista. Cuando pasas una cadena separada por comas como "1, 2, 3" en un solo %s, WordPress la trata como un valor atómico. La consulta resultante se parece a WHERE id IN ('1, 2, 3') en lugar de WHERE id IN (1, 2, 3). Funcionalmente incorrecto, pero de forma segura.

La solución correcta habría sido generar un placeholder por valor. Construir un array de tokens %d (o %s) que coincida con el número de valores de entrada, unirlos con comas y pasar cada valor individualmente a prepare(). Así, cada valor recibe su propio escapado y la cláusula IN funciona como se espera.

// Correcto (un placeholder por valor, sin stripslashes())
$ids = array_map( 'intval', (array) $_GET['ids'] );
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
$query = $wpdb->prepare(
    "SELECT * FROM {$wpdb->prefix}orders WHERE id IN ($placeholders)",
    $ids
);
$wpdb->get_results( $query );

En su lugar, el desarrollador eligió el camino que restauraba la funcionalidad más rápido. Comprensible. Pero peligroso.

Lo que hace que esto sea difícil de detectar es que ambas líneas tienen sentido por separado. Si le preguntaras al desarrollador por qué usó prepare(), te daría una respuesta correcta sobre la prevención de inyecciones SQL. Si le preguntaras por qué añadió stripslashes(), te explicaría el problema del escapado con la cláusula IN. Ambas respuestas son razonables. La combinación es el problema.

Esto es algo que busco específicamente cuando reviso plugins de WordPress. Cada vez que prepare() se usa cerca de una cláusula IN, existe la posibilidad de que alguien haya parcheado el escapado de una forma que anula la protección. Es un antipatrón conocido en el ecosistema WordPress, pero sigue apareciendo porque la solución correcta requiere más esfuerzo que la incorrecta.

Durante la sesión, confirmé la inyección, documenté los pasos de reproducción y lo reporté por el canal apropiado. El hallazgo fue confirmado y se asignó un CVE.

La conclusión es sencilla: la seguridad no se trata solo de usar las funciones correctas. Se trata de entender qué hacen realmente esas funciones y asegurarte de que el código que las rodea no deshaga su trabajo. Un prepare() seguido de un stripslashes() es peor que no usar prepare(), porque crea la ilusión de protección donde no existe ninguna.

La próxima vez que veas una función de seguridad en el código, no te limites a comprobar que está ahí. Comprueba qué pasa con su resultado.

¿Quieres que encuentre esto en tu aplicación antes de que lo haga un atacante?

Solicitar un pentest
← AnteriorConoce a tu público: traducir hallazgos técnicos en impacto de negocioSiguiente →Deja de ignorar los archivos JavaScript