Inyecciones SQL
27 de Octubre del 2008En el post anterior sobre seguridad web hablé sobre las inyecciones de código e incluí un ejemplo ejemplo de inyección SQL. Hoy voy a ampliar la información sobre este tipo de inyección, explicando los métodos para evitarla.
Una de las formas de evitar la inyección SQL es validar los datos enviados por el usuario antes de realizar cualquier acceso a la base de datos; es decir, comprobar que los valores estén dentro del rango permitido por la web. Un ejemplo concreto sería una web que dispusiese de varias secciones de noticias, agrupadas en varias categorías, y que cada sección mostrase un listado de dichas noticias en su categoría.
Para ello podríamos tener una tabla noticias en la que uno de los campos fuese categoria. Supongamos que dicha categoría pudiese tomar 3 valores, por ejemplo; internacional, nacional y deportes. En este caso, el dato recibido desde el usuario sería la categoría a la que quiere acceder (podría llegar desde cualquier vía, como ejemplo por GET: www.dominio.com/noticias.php?categoria=deportes) . Si simplemente tomamos dicho dato y hacemos un SELECT * FROM noticias WHERE categoria=’$categoria’, tenemos la posibilidad de una inyección, así que antes de realizar el acceso a la base de datos, comprobaríamos que $categoria fuese uno de los 3 posibles valores, y de no ser así, terminar la ejecución del script, o mostrar una página 404 o a una página por defecto (el listado de categorías de nuevo, por ejemplo. Yo recomiendo la opción del 404, aunque no por motivos de seguridad… otro día escribiré un artículo al respecto).
No siempre se pueden validar los datos de esta forma, en bastantes ocasiones el rango permitido es demasiado amplio y/o no es fijo (por ejemplo, si se tratase de una búsqueda en los títulos de las noticias, el acceso a la base de datos sería necesario). Para este tipo de casos, mucho más frecuentes, se pueden usar dos alternativas: sanear los datos o usar sentencias preparadas.
Sanear los datos
Se trata de agregar a los datos enviados por el usuario los caracteres necesarios de escape para que la base de datos diferencie entre la sentencia SQL y los datos. Un ejemplo concreto: supongamos una tabla llamada grupos de grupos musicales en la que tengo un campo nombre. Quiero insertar al grupo Guns N’ Roses. Dado que contiene una comilla no podría hacerlo directamente, así que tendría que agregar el caracter de escape (contrabarra en MySQL) antes de la comilla, es decir: Guns N\’ Roses.
PHP dispone de varias funciones para ello. Una de ellas es general y no depende de ninguna base de datos en concreto; addslashes. Las otras son dependientes de la base de datos que usemos, en MySQL en concreto es mysql_real_escape_string.
El uso de addslashes es muy frecuente y aparece en muchos ejemplos en internet, pero tiene tres posibles problemas, dos de seguridad y uno de integridad de datos. Uno de ellos es que si la directiva de PHP magic_quotes_sybase está activada y lo usamos con una base de datos que no sea Sybase, entonces los caracteres no serán reemplazados correctamente (en principio esta directiva no tiene por qué estar activada si tenemos el hospedaje más común en php/mysql, pero si no se comprueba no se puede saber cual es su estado). Otro problema es que addslashes no contempla todos los tipos de escape que MySQL requiere para ficheros binarios, no es un problema de seguridad como tal, pero sería un problema de integridad de datos a la hora de insertar, por ejemplo, ficheros directamente en la base de datos.
Por último, existe un problema con addslashes cuando una tabla en la base de datos está creada para ciertos tipos de codificación de caracteres multibyte, por ejemplo para el chino simplificado GBK. Addslashes funciona byte por byte, agregando la contrabarra siempre que encuentre un valor de byte que se corresponda con los que escapa. En GBK existen caracteres que ocupan más de un byte y cuyo último byte se corresponde con la contrabarra, por ejemplo: ¿\ (nota: si estos dos caracteres se mostrasen en GBK, se vería un caracter chino). Si aplico addslashes a ¿’ obtengo ¿\’ . Al realizar un acceso a la base de datos en la tabla GBK, leerá un caracter multibyte y una comilla, y aquí tenemos el problema. Si la codificación es latin-1 o UTF-8 no hay problema alguno.
Mysql_real_escape_string, pese a tener menos problemas que addslashes, también está sujeto a los problemas de codificación multibyte. En las versiones más recientes de MySQL ya está corregido (a partir de 5.0.22), pero en las anteriores trabajar con codificaciones multibyte que no sea UTF-8 puede dar problemas similares al descrito en el párrafo anterior.
Sentencias preparadas
La mejor forma de evitar los problemas descritos anteriormente, es usando de las sentencias preparadas. Mediante las sentencias preparadas, separamos la consulta SQL de los datos, enviándolos de forma separada. Para usar sentencias preparadas en PHP con MySQL, se requiere la extensión mysqli, que no se encuentra instalada por defecto (aunque estará disponible normalmente en la mayoría de los hostings). Mediante mysqli_prepare podemos crear nuestras sentencias preparadas.
Para no tener problemas con caracteres multibyte que no sean UTF-8, además de usar mysqli con sentencias preparadas, hay que comprobar que la versión de MySQL sea reciente. Si la versión no es reciente, entonces no dispondrá de soporte para sentencias preparadas y mysqli hará una simulación de dichas sentencias usando mysql_real_escape_string, teniendo exactamente el mismo problema que si se usase directamente.
Un problema reciente con MySQL
Desde hace pocas semanas se ha mostrado un nuevo problema con MySQL. Cuando realizamos un SELECT con una comparación de cadenas, los caracteres de espacio al final de la comparación no son tenidos en cuenta. Por otro lado, si en un INSERT los datos para un campo de texto ocupa más espacio que el tamaño máximo para dicho campo, será recortado. Supongamos web con registro de usuarios y una tabla usuarios con nombre (de 15 caracteres) y password:
Cuando un usuario solicita registrarse, primero compruebo que el nombre de usuario no exista. Si no existe, realizo el insert en la base de datos.
Supongamos que ya existe un usuario con nombre admin. Alguien intenta registrarse con nombre “admin 1″ (admin seguido de 10 espacios y un 1). Al realizar el select para comprobar si existe el usuario, dado que el tamaño máximo para nombre es 15 y mi entrada tiene 16 caracteres, no devolverá ningún resultado, por tanto supondrá que dicho usuario no existe, y procederá a la inserción. En la inserción, el dato será recortado, quedando como admin seguido de 10 espacios.
Al registrarme en la web, podré usar directamente “admin”, sin espacios pero con la clave que ya conozco, dado que un select no tendrá en cuenta los espacios. Si más tarde el script hace otra consulta a la tabla de usuarios usando sólo el nombre “admin”, el resultado que devolverá será el primero que encuentre, es decir, el original.
Bien, para que todo esto resulte un problema, se tienen que dar una serie de circunstancias muy concretas, pero es un riesgo de seguridad en cualquier caso. Las medidas a tomar son las de medir la longitud máxima por cada dato que envía el usuario.
Nota: si el nombre de usuario es primary key no hay problema, dado que MySQL no permitiría la inserción como ocurriría en un campo que no lo fuese. Por otro lado, si no es primary key, pero no tenemos en cuenta el nombre de usuario sino su id durante el resto de la ejecución del script, tampoco sería un problema.
Para finalizar, magic_quotes_gpc
PHP tiene una opción (magic_quotes_gpc) mediante la cual los datos recibidos desde el usuario (get/post/cookie) son pasados por addslashes antes de la ejecución del script. Esta opción está desaconsejada y en la versión 6 de PHP ha sido eliminada, pero aún así, por motivos de compatibilidad con código ya existente, los servidores de hosting suelen seguir teniéndola activada.
En el caso de que esté activada, para no tener datos escapados por duplicado, tendremos que comprobar si están activadas mediante get_magic_quotes_gpc, y en el caso de que lo estén, realizar un stripslashes para eliminar el escape. Si usamos consultas preparadas usaremos los datos tras estos pasos directamente, y si no las usamos, las volveremos a escapar mysql_real_escape_string.