viernes, 20 de abril de 2012

Introducción al Sql Injection en Sql Server

Desde hace algún tiempo intento participar lo más que puedo en los foros en español de la MSDN. Es un buen punto de encuentro para resolver dudas y la verdad es que de algunas respuestas se aprende muchísimo, e intentando ayudar a la gente refrescas muchos conocimientos que podrías tener en el baúl de los recuerdos.

Una de las cosas que me más sorprende es que en el foro de ADO.NET hay muchísimas preguntas en las que viendo el código se ve como la gente sigue trabajando con DataSet o DataTable. Las causas pueden ser muchas y este post no es el lugar más adecuado para discutir si este sistema de acceso a datos es mejor o peor que otros. Aunque como digo me sorprende, sobre todo si son aplicaciones nuevas teniendo en cuenta que hay alternativas que desde mi punto son mejores e incluso más fáciles de usar como Entity Framework, NHibernate o LLBLGen.

Otras cosas que se ven muchísimo, y es el motivo por el que escribo este post, es que se siguen concatenando cadenas para formar las consultas sql que luego se ejecutan a través de SqlCommand. Esto es un error desde muchos puntos de vista. El principal problema es que quedamos expuestos a ataques de Sql Injection, sin tener en cuenta que mantener este tipo de consultas es complicado.

 Este artículo no pretende ser una explicación completa y rigurosa sobre Sql Injection ya que existe gran cantidad de información al respecto en Internet pero si pretende ser una pequeña introducción sobre este tema.

Para este artículo creamos la sigiuente table en nuestro Sql Server
CREATE TABLE dbo.[User]
 (
 IdUser int NOT NULL IDENTITY (1, 1),
 Username varchar(50) NOT NULL,
 Password varchar(200) NOT NULL,
  Name varchar(50) NOT NULL
 )  ON [PRIMARY]
GO
ALTER TABLE dbo.[User] ADD CONSTRAINT
 PK_User PRIMARY KEY CLUSTERED 
 (
 IdUser
 ) WITH( STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]

GO
CREATE UNIQUE NONCLUSTERED INDEX IX_Username ON dbo.[User]
 (
 Username
 ) WITH( STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
GO
ALTER TABLE dbo.[User] SET (LOCK_ESCALATION = TABLE)
GO
COMMIT

Y añadimos un registro con la siguiente sentencia
INSERT INTO [User] ([Username], [Password], [Name])
VALUES ('admin', 'admin', 'Administrador')
Como vemos es una tabla muy simple que podemos usar para el inicio de sesión en nuestra aplicación. También crearemos un programa muy simple que tendrá el siguiente aspecto


Para seguir con la demostración y tomándome alguna licencia sobre la arquitectura de aplicaciones usaremos el siguiente código en el botón de iniciar sesión
private void buttonLogin_Click(object sender, EventArgs e)
{
 try {
  string connectionString = ConfigurationManager.ConnectionStrings["Foo"].ConnectionString;
  using (SqlConnection connection = new SqlConnection(connectionString))
  {
   connection.Open();
   string cmdText = "SELECT * FROM [User] WHERE [Username]='" + textUsername.Text + "' AND [Password]='" + textPassword.Text + "'";
   SqlCommand command = new SqlCommand(cmdText, connection);    
   SqlDataReader dataReader = command.ExecuteReader();
   if (dataReader.HasRows)
   {
    dataReader.Read();
    string name = dataReader["Name"].ToString();
    labelStatus.Text = "Bienvenido al sistema " + name;
   }
   else
    labelStatus.Text = "Nombre de usuario o contraseña incorrectos";
  }
  }
  catch (Exception ex)
  {
   labelStatus.Text = ex.Message;
  }
}
Como vemos el código es muy simple. Obtenemos la cadena de conexión del fichero de configuración y creamos una conexión y un comando. A este comando, le asignamos la consulta que creamos y la ejecutamos. Si nos devuelve un registro, mostramos el nombre del usuario y si no hay registros mostramos el clásico error de inicio de sesión incorrecto. En caso de excepción, mostramos el mensaje.

Si nos ponemos a hacer pruebas, veremos que el sistema se comporta como esperamos. Si usamos admin/admin el sistema nos da la bienvenida


Pero si usamos otra combinación el sistema nos devuelve un error


Hasta aquí todo bien. Pero ahora vamos a ponernos un poco juguetones, y vamos a suponer que sabemos que en el sistema hay un usuario admin (¿en que sistema no hay un usuario admin, administrador, administrator o root?) pero no sabemos sabemos la contraseña (como es lógico, sino esto no tendría sentido). Si ponemos admin' AND 1=1 --' como nombre de usuario sin importar lo que pongamos como contraseña obtenemos lo siguiente


¿Sorprendido? ¿No? Pues deberías. El problema está en la concatenación de la consulta que usamos para autenticar al usuario. Al usar ese nombre de usuario la consulta que se ejecuta es la siguiente
SELECT * FROM [User] WHERE [Username]='admin' AND 1=1 --'' AND [Password]='loquesea'
Y está consulta siempre nos devolverá el usuario admin sin necesidad de la contraseña. Es verdad que hemos usado algo de información, ya que conocíamos la existencia de un usuario admin, pero nos sirve de demostración de los peligros que corremos si no hacemos nuestras consultas de manera correcta.

Supongo que a estas alturas te estarás preguntando como podemos hacer para evitar esto. Pues existen dos formas
  1. Usar parámetros.
  2. Usar un procedimiento almacenado.
Para usar parámetros tan sólo debemos modificar nuestro código anterior de la siguiente forma
try
{
 string connectionString = ConfigurationManager.ConnectionStrings["Foo"].ConnectionString;
 using (SqlConnection connection = new SqlConnection(connectionString))
 {
  connection.Open();
  string cmdText = "SELECT * FROM [User] WHERE [Username]=@Username AND [Password]=@Password";
  SqlCommand command = new SqlCommand(cmdText, connection);
  command.Parameters.Add(new SqlParameter("@Username", textUsername.Text));
  command.Parameters.Add(new SqlParameter("@Password", textPassword.Text));
  SqlDataReader dataReader = command.ExecuteReader();
  if (dataReader.HasRows)
  {
   dataReader.Read();
   string name = dataReader["Name"].ToString();
   labelStatus.Text = "Bienvenido al sistema " + name;
  }
  else
   labelStatus.Text = "Nombre de usuario o contraseña incorrectos";
 }
}
catch (Exception ex)
{
 labelStatus.Text = ex.Message;
}
Y si ejecutamos nuestra aplicación con el usuario "tramposo" vemos que el sistema se comporta de la manera correcta.


El uso de los parámetros hace que la comparación se haga de manera literal y no se interprete.
[Username] = admin' AND 1=1 --'
No se interpretan ni las comillas simples ni el doble guion que sirve para poner comentarios.
Para usar la opción de un procedimiento almacenado, tendremos que crear éste, en la base de datos de la siguiente manera
CREATE PROCEDURE SP_Login
 @Username VARCHAR(50),
 @Password VARCHAR(200)
AS
BEGIN
 SELECT * FROM [User] WHERE [Username]=@Username AND [Password]=@Password
END
GO
Y para usarlo tendremos que modificar nuestro código, quedando éste así
try
{
 string connectionString = ConfigurationManager.ConnectionStrings["Foo"].ConnectionString;
 using (SqlConnection connection = new SqlConnection(connectionString))
 {
  connection.Open();
  string cmdText = "SP_LOGIN";
  SqlCommand command = new SqlCommand(cmdText, connection);
  command.CommandType = CommandType.StoredProcedure;
  command.Parameters.Add(new SqlParameter("@Username", textUsername.Text));
  command.Parameters.Add(new SqlParameter("@Password", textPassword.Text));
  SqlDataReader dataReader = command.ExecuteReader();
  if (dataReader.HasRows)
  {
   dataReader.Read();
   string name = dataReader["Name"].ToString();
   labelStatus.Text = "Bienvenido al sistema " + name;
  }
  else
   labelStatus.Text = "Nombre de usuario o contraseña incorrectos";
 }
}
catch (Exception ex)
{
 labelStatus.Text = ex.Message;
}
Y vemos que una vez más el sistema se comporta de la manera esperada.


El alcance de este tipo de ataques no solo se reduce a poder acceder al sistema conociendo un nombre de usuario, sino a cosas mucho más peligrosas, como poder incluso a borrar una tabla, aunque para esto hacen falta que se cumplan más requisitos como conocer el nombre de la tabla en nuestra base datos (que también se puede obtener via Sql Injection). Simplemente imaginemos que usamos como nombre de usuario admin' admin AND 1=1; DROP TABLE [User] --.

Como vemos se pueden correr muchos muchísimos peligros si hacemos las cosas sin pensar, y hacer las cosas bien no cuesta tanto.

Happy coding!

No hay comentarios:

Publicar un comentario