Este artículo no pretende ser un ejemplo a seguir para la programación de aplicaciones (ni de lejos). Mi perfil profesional no es el de un desarrollador, y el hecho de que sea capaz de escribir código fuente que se compile y funcione apropiadamente (más o menos), no implica que yo sea un buen programador, ya que no lo soy (ojalá... pues hace años era uno de mis sueños no cumplidos... en fin). En resumen, el código fuente aquí incluido sólo tiene fines didácticos para el tema que nos ocupa, sin considerar la seguridad, el rendimiento, uso de patrones de diseño u otras consideraciones de diseño, ni ningún otro aspecto que sí debe considerarse en desarrollos profesionales.
Antes de continuar con este capítulo, recordar que en resumidas cuentas, tenemos dos formas de trabajar con Bases de Datos en ADO.Net para conseguir un conjunto de resultados, en función de cómo se gestione la conexión:
- Trabajar Orientado a la Conexión. En esta forma de trabajo, nos conectaremos a una base de datos (ej: SQL Server), ejecutaremos una consulta SQL o un procedimiento almacenado, y manteniendo la conexión a la base de datos abierta, recorreremos el conjunto o conjuntos de resultados de dicha ejecución, para finalmente cerrar la conexión a la base de datos. En algunos desarrollos, la conexión se mantiene abierta constantemente (esto es típico en antiguas aplicaciones cliente/servidor de Visual Basic, o al menos, yo me lo he encontrado más de una vez).
Habitualmente suele utilizarse un objeto de la clase SqlCommand en el caso de estar conectado a SQL Server a través de SQLClient, o bien, también suele utilizarse un objeto de la clase OldDbCommand en el caso de estar conectado a un origen de datos OLEDB (ej: SQL Server, Microsoft Access, Oracle, Informix, DB2, etc.). En ambos casos, asociaremos el objeto comando utilizado con un objeto conexión (SqlConnection u OleDbConnection, dependiendo del caso).
Seguidamente, para recuperar (leer) los datos de nuestra base de datos, suele utilizarse un objeto de la clase SqlDataReader en el caso de estar conectado a SQL Server a través de SQLClient, o bien, también suele utilizarse un objeto de la clase OleDbDataReader en el caso de estar conectado a un origen de datos OLEDB (ej: SQL Server, Microsoft Access, Oracle, Informix, DB2, etc.).
- Trabajar Orientado a la Desconexión. En esta forma de trabajo, nos conectaremos a una base de datos (ej: SQL Server), ejecutaremos una consulta SQL o un procedimiento almacenado, rellenaremos un objeto en memoria (DataSet) con el conjunto de resultados correspondiente y finalmente cerraremos la conexión a la base de datos, pudiendo acceder al anterior objeto en memoria para recorrer el conjunto de resultados (pero desconectados de la Base de Datos).
Habitualmente suele utilizarse un objeto de la clase SqlDataAdapter en el caso de estar conectado a SQL Server a través de SQLClient, o bien, también suele utilizarse un objeto de la clase OleDbDataAdapter en el caso de estar conectado a un origen de datos OLEDB (ej: SQL Server, Microsoft Access, Oracle, Informix, DB2, etc.). Tanto en un caso como en otro (SqlDataAdapter u OleDbDataAdapter) se debe especificar el comando (Procedimiento Almacenado, Consulta SQL o Transact-SQL) a ejecutar, ya sea especificando un objeto conexión (SqlConnection u OleDbConnection, dependiendo del caso) y la correspondiente cadena con dicho comando, o bien, especificando un objeto comando (es decir, un objeto SqlCommand u OleDbCommand, dependiendo del caso).
Seguidamente, suele utilizarse el método Fill del anterior objeto (es decir, del SqlDataAdapter u OleDbDataAdapter, dependiendo del caso), para rellenar un objeto de la clase DataSet, cerrando la conexión al finalizar el llenado del DataSet. De este modo, podremos acceder a dicho DataSet para recuperar (leer) los datos de nuestra base de datos, sin mantener una conexión abierta a la Base de Datos. También pueden modificarse los datos del DataSet, y seguidamente conciliar (sincronizar) dichos cambios en la base de datos (esto queda fuera del alcance del presente artículo).
En resumidas cuentas, aunque tengamos que utilizar un objeto SqlDataAdapter u OleDbDataAdapter para poder llenar una DataSet con su correspondiente método Fill, en ambos casos estaremos utilizando de forma implícita un objeto de comando (SqlCommand u OleDbCommand), como se hacía al trabajar orientado a la conexión.
Visto lo visto, vemos que podemos trabajar orientados a la conexión o a la desconexión, y también vemos que podemos trabajar con SQLClient (es decir, SQL Server, con las clases SqlConnection, SqlCommand, SqlDataAdapter, etc.) o con OleDb (es decir, con SQL Server o con cualquier otro motor de base de datos del cual tengamos el correspondiente Proveedor OLEDB, utilizando las clases OleDbConnection, OleDbCommand, OleDbDataAdapter, etc.).
Aclarar que con OLEDB también podemos acceder a SQL Server, pero evidentemente será preferible utilizar SQLClient, ya que de esta forma nos quitamos de medio la cada OLEDB, lo cual, resultará más óptimo. Claro está, que muchos prefieren utiliza OLEDB, para que de esta forma sea más rápido modificar sus desarrollos para funcionar contra otros motores de base de datos (yo prefiero para esto, hacer las cosas bien, y utilizar el patrón de diseño DAO: Data Access Objects, ahora para gustos hay colores).
Para los siguientes ejemplos, nos centraremos en la utilización de SQLClient para acceder a SQL Server, ya que en el caso de OLEDB la problemática y la forma de trabajar es la misma (algunas clases son propias de SQLClient o de OleDb, mientras que otras clases como la clase DataSet son comunes).
Del mismo modo, para estudiar SQL Injection no es especialmente importante trabajar orientado a la conexión u orientado a la desconexión, ya que lo verdaderamente importante es la forma en que se accede a la base de datos:
- Si se utilizan Consultas SQL o Procedimientos Almacenados.
- Si se utilizan Parámetros o se concatena la información variable.
Como consecuencia de esto, para los siguientes ejemplos nos centraremos en el modo de trabajo orientado a la desconexión (con DataSets), ya que se trabaja orientado a la conexión de forma muy parecida.
En concordancia con los anteriores capítulos de este artículo, hemos desarrollado un ejemplo en el Laboratorio de GuilleSQL con el caso típico de un formulario html para que los usuarios de una supuesta Aplicación ASP.Net se autentiquen, empleando para ello dos cajas de texto (txtUsuario y txtPassword) y un botón. En consecuencia, nos centraremos en el código de servidor que se ejecutará al hacer click sobre dicho botón del formulario html.
Ejecutar Consultas SQL al vuelo con ADO.Net y SQLClient
Este caso ya lo vimos anteriormente con una página ASP, y se trata de un método NO recomendado para acceder a una base de datos desde una aplicación. El código fuente utilizado para este ejemplo es el equivalente a utilizar en java objetos que implementen el interfaz java.sql.Statement.
El código fuente resultante es similar al siguiente:
// Deberemos referenciar los siguientes espacios de nombres using System.Data; using System.Data.SqlClient;
// Este es el código del Botón SqlConnection cnSql = new SqlConnection("Data Source=GuilleXP;Initial Catalog=GuilleSQL;trusted_connection=yes;"); String strSQL = "SELECT * FROM GuilleSQL.USUARIOS WHERE USU_ID='" + txtUsuario.Text.ToString() + "' AND USU_PWD='" + txtPassword.Text.ToString() + "'"; SqlDataAdapter miCommand = new SqlDataAdapter(strSQL, cnSql); miCommand.SelectCommand.CommandType = CommandType.Text;
DataSet miDS = new DataSet(); miCommand.Fill(miDS);
'**** '**** (c) www.guillesql.es '**** Controlar aquí si existe el Usuario con miDS.Tables[0].Rows.Count en un IF '**** y tomar los datos del Usuario (desde miDS) si el usuario existe '****
|
Al hacer las correspondientes pruebas en el Laboratorio de GuilleSQL, el resultado obtenido ha sido que NO estamos protegidos frente a ataques de SQL Injection, es más, este es el método de acceder a una Base de Datos más vulnerable a ataques SQL Injection. Si un usuario introduce los siguientes valores de entrada:
- txtUsuario: ' OR 'A'='A
- txtPassword: ' OR 'A'='A
La ejecución de la correspondiente consulta SQL por parte de la aplicación SI devolverá filas (comprobado en el Laboratorio de GuilleSQL), pues el atacante habrá conseguido su objetivo (debilitar la cláusula WHERE de la consulta SQL ejecutada), como vimos anteriormente en el ejemplo similar con ASP. Lo mismo ocurre con otros tipos de ataque de SQL Injection, es decir, al utilizar deliminadores de comentarios, intentar introducir código SQL invasor, etc, el código se ha comportado de la misma forma.
Así, si por ejemplo, intentamos introducir los siguientes datos de entrada:
- txtUsuario: ' OR 'A'='A
- txtPassword: '; DELETE FROM GuilleSQL.USUARIOS; --
El atacante conseguirá eliminar todas las filas de la tabla GuilleSQL.USUARIOS, al haber conseguido introducir el código SQL Invasor deseado (la sentencia DELETE).
Ejecutar Consultas SQL parametrizadas con ADO.Net. y SQLClient
La programación de base de datos con Consultas SQL Parametrizadas no ha sido contemplado anteriormente en este Artículo, resultando un caso de bastante interés (para SQL Injection y en general para la programación de base de datos) cuando se quiere acceder a un motor de base de datos para ejecutar Consultas SQL en vez de ejecutar Procedimientos Almacenados. La principal ventaja frente a la utilización de Consultas SQL al vuelo, es que se evitan las concatenaciones (principal factor de riesgo para SQL Injection) y se utilizan parámetros (validaciones implícitas de tipo y rango). Es equivalente a utilizar en java objetos que implementen el interfaz java.sql.PreparedStatement.
En consecuencia, en caso de no utilizar Procedimientos Almacenados para acceder a la base de datos, si es necesario utilizar Consultas SQL, la recomendación será utilizar las Consultas SQL de forma parametrizada, minimizando así los riesgos.
Finalmente, el código fuente resultante es similar al siguiente:
// Deberemos referenciar los siguientes espacios de nombres using System.Data; using System.Data.SqlClient;
// Este es el código del Botón SqlConnection cnSql = new SqlConnection("Data Source=GuilleXP;Initial Catalog=GuilleSQL;trusted_connection=yes;"); SqlDataAdapter miCommand = new SqlDataAdapter("SELECT * FROM GuilleSQL.USUARIOS WHERE USU_ID=@USU_ID AND USU_PWD=@USU_PWD", cnSql); miCommand.SelectCommand.CommandType = CommandType.Text;
SqlParameter pmReturn = new SqlParameter("@RETURN_VALUE", SqlDbType.Int, 4); pmReturn.Direction = ParameterDirection.ReturnValue; miCommand.SelectCommand.Parameters.Add(pmReturn);
SqlParameter pmUsuario = new SqlParameter("@USU_ID", SqlDbType.VarChar, 50); pmUsuario.Value = txtUsuario.Text.ToString(); miCommand.SelectCommand.Parameters.Add(pmUsuario);
SqlParameter pmPassword = new SqlParameter("@USU_PWD", SqlDbType.VarChar, 50); pmPassword.Value = txtPassword.Text.ToString(); miCommand.SelectCommand.Parameters.Add(pmPassword);
DataSet miDS = new DataSet(); miCommand.Fill(miDS);
'**** '**** (c) www.guillesql.es '**** Controlar aquí si existe el Usuario con miDS.Tables[0].Rows.Count en un IF '**** y tomar los datos del Usuario (desde miDS) si el usuario existe '****
|
Al hacer las correspondientes pruebas en el Laboratorio de GuilleSQL, el resultado obtenido ha sido que utilizando Consultas Parametrizadas estamos protegidos frente a ataques de SQL Injection (siempre que el comando ejecutado no incluya a su vez la ejecución de código SQL Dinámico, etc.). Si un usuario introduce los siguientes valores de entrada:
- txtUsuario: ' OR 'A'='A
- txtPassword: ' OR 'A'='A
La llamada al procedimiento almacenado no devolverá filas (comprobado en el Laboratorio de GuilleSQL), excepto que realmente existe un usuario con nombre ' OR 'A'='A y contraseña ' OR 'A'='A. Lo mismo ocurre con otros tipos de ataque de SQL Injection, es decir, al utilizar deliminadores de comentarios, intentar introducir código SQL invasor, etc, el código se ha comportado de forma segura.
En el caso particular de SqlDataAdaptar y SqlCommand para acceder a SQL Server, al ejecutar una traza con SQL Profiler, se puede comprobar que el correspondiente objeto SqlCommand generará una llamada al procedimiento almacenado sp_executesql. Así, si por ejemplo, intentamos introducir los siguientes datos de entrada:
- txtUsuario: ' OR 'A'='A
- txtPassword: '; DELETE FROM GuilleSQL.USUARIOS; --
No se obtendrá ninguna fila de resultado, y la traza de SQL Profiler revelará la siguiente llamada a sp_executesql:
exec sp_executesql N'SELECT * FROM USUARIOS WHERE USU_ID=@USU_ID AND USU_PWD=@USU_PWD',N'@USU_ID varchar(50),@USU_PWD varchar(50)',@USU_ID=''' or ''a''=''a',@USU_PWD='''; delete from usuarios; --' |
Como vemos, el código ejecutado en la base de datos (en el caso del ejemplo, SQL Server), es un código muy lejano del código que deseaba ejecutar el atacante cuando intentó utilizar técnicas de SQL Injection, y además, la forma parametrizada de invocar el procedimiento almacenado sp_executesql es una muestra objetiva de la seguridad de este modo de acceso a base de datos.
Ejecutar Procedimientos Almacenados al vuelo con ADO.Net y SQLClient
Este caso ya lo vimos anteriormente con una página ASP, y se trata de un método NO recomendado para acceder a una base de datos desde una aplicación. Aunque no resulta tan vulnerable como la utilización de Consultas SQL generadas como fruto de concatenaciones, la utilización de Procedimientos Almacenados sin Parametrizar, mantiene la existencia de riesgo de ataques SQL Injection. El código fuente utilizado para este ejemplo es el equivalente a utilizar en java objetos que implementen el interfaz java.sql.Statement.
El código fuente resultante es similar al siguiente:
// Deberemos referenciar los siguientes espacios de nombres using System.Data; using System.Data.SqlClient;
// Este es el código del Botón SqlConnection cnSql = new SqlConnection("Data Source=GuilleXP;Initial Catalog=GuilleSQL;trusted_connection=yes;"); String strSQL = "GuilleSQL.spGetUsuario '" + txtUsuario.Text.ToString() + "', '" + txtPassword.Text.ToString() + "'"; SqlDataAdapter miCommand = new SqlDataAdapter(strSQL, cnSql); miCommand.SelectCommand.CommandType = CommandType.Text;
DataSet miDS = new DataSet(); miCommand.Fill(miDS);
'**** '**** (c) www.guillesql.es '**** Controlar aquí si existe el Usuario con miDS.Tables[0].Rows.Count en un IF '**** y tomar los datos del Usuario (desde miDS) si el usuario existe '****
|
Al hacer las correspondientes pruebas en el Laboratorio de GuilleSQL, el resultado obtenido ha sido que NO estamos protegidos frente a ataques de SQL Injection. Si un usuario introduce los siguientes valores de entrada:
- txtUsuario: ' OR 'A'='A
- txtPassword: ' OR 'A'='A
Se producirá un error en tiempo de ejecución. Si bien, no se ha consegido debilitar la cláusula WHERE de la correspondiente consulta SQL, si es cierto que se ha conseguido un mal funcionamiento de la aplicación, excepto que se tenga programado una correcta gestión de errores (comprobado en el Laboratorio de GuilleSQL).
Por el contrario, si por ejemplo, intentamos introducir los siguientes datos de entrada:
- txtUsuario: ' OR 'A'='A
- txtPassword: '; DELETE FROM GuilleSQL.USUARIOS; --
El atacante conseguirá eliminar todas las filas de la tabla GuilleSQL.USUARIOS, al haber conseguido introducir el código SQL Invasor deseado (la sentencia DELETE). Para este tipo de ataques se mantiene completamente la vulverabilidad ante ataques SQL Injection (comprobado en el Laboratorio de GuilleSQL).
Ejecutar Procedimientos Almacenados de forma parametrizada con ADO.Net y SQLClient
Este caso también lo vimos anteriormente con una página ASP, y se trata del método recomendado para acceder a una base de datos desde una aplicación, eso sí, evidentemente, requiere el desarrollo, programación y mantenimiento de los correspondientes Procedimientos Almacenados en SQL Server (o en el motor de base de datos correspondiente). El código fuente utilizado para este ejemplo es el equivalente a utilizar en java objetos que implementen el interfaz java.sql.PreparedStatement.
El código fuente resultante es similar al siguiente:
// Deberemos referenciar los siguientes espacios de nombres using System.Data; using System.Data.SqlClient;
// Este es el código del Botón SqlConnection cnSql = new SqlConnection("Data Source=GuilleXP;Initial Catalog=GuilleSQL;trusted_connection=yes;"); SqlDataAdapter miCommand = new SqlDataAdapter("spGetUsuario", cnSql); miCommand.SelectCommand.CommandType = CommandType.StoredProcedure;
SqlParameter pmReturn = new SqlParameter("@RETURN_VALUE", SqlDbType.Int, 4); pmReturn.Direction = ParameterDirection.ReturnValue; miCommand.SelectCommand.Parameters.Add(pmReturn);
SqlParameter pmUsuario = new SqlParameter("@USU_ID", SqlDbType.VarChar, 50); pmUsuario.Value = txtUsuario.Text.ToString(); miCommand.SelectCommand.Parameters.Add(pmUsuario);
SqlParameter pmPassword = new SqlParameter("@USU_PWD", SqlDbType.VarChar, 50); pmPassword.Value = txtPassword.Text.ToString(); miCommand.SelectCommand.Parameters.Add(pmPassword);
DataSet miDS = new DataSet(); miCommand.Fill(miDS);
'**** '**** (c) www.guillesql.es '**** Controlar aquí si existe el Usuario con miDS.Tables[0].Rows.Count en un IF '**** y tomar los datos del Usuario (desde miDS) si el usuario existe '****
|
Como ya vimos anteriormente, en esta ocasión estamos protegidos ante ataques SQL Injection (siempre y cuando el procedimiento almacenado no utilice SQL Dinámico), ya que no construimos dinámicamente la cadena de texto SQL a ejecutar. En consecuencia, si un usuario introduce los siguientes valores de entrada:
- txtUsuario: ' OR 'A'='A
- txtPassword: ' OR 'A'='A
La llamada al procedimiento almacenado no devolverá filas (como hemos comprobado en las pruebas del Laboratorio de GuilleSQL), excepto que realmente existe un usuario con nombre ' OR 'A'='A y contraseña ' OR 'A'='A.
Además, como comentamos anterioremente en el ejemplo realizado con ASP, la utilización de parámetros implica la realización implícita de comprobaciones de tipo y rango. Sin embargo, con ADO.Net existe alguna diferencia frente a ADO. Por ejemplo, si introducimos una cadena demasiado larga en un parámetro de ADO se producirá el error ADODB.Command (0x800A0D5D), mientras que por el contrario, al introducir una cadena demasiado larga en un Parámetro de ADO.Net no se producirá ningún error, aunque la cadena será truncada (esto puede comprobarse fácilmente con una traza de SQL Profiler, es decir, la prueba del algodón ;-).
Conclusiones
Con todo lo visto hasta ahora, tanto en este capítulo como en los capítulos anteriores del presente artículo, para protegernos de SQL Injection, es recomendable realizar una correcta programación de acceso a base de datos, sin utilizar SQL Dinámico, pudiendo elegir principalmente entre:
- Utilizar Procedimientos Almacenados de forma parametrizada.
- Utilizar Consultas SQL de forma parametrizada.
En caso contrario, y como hemos podido comprobar de forma empírica durante las pruebas realizadas en el Laboratorio de GuilleSQL, seremos vulnerables ante ataques SQL Injection.