Inyección de SQL

Categoría de OWASP: MASVS-CODE: Calidad de código

Descripción general

La inyección de SQL se aprovecha de aplicaciones vulnerables con la inserción de código en las instrucciones de SQL para acceder a las bases de datos subyacentes más allá de sus interfaces expuestas de forma intencional. El ataque puede exponer datos privados, corromper contenido de la base de datos o, incluso, poner en riesgo la infraestructura del backend.

SQL puede ser vulnerable a la inyección a través de consultas que se crean de forma dinámica por medio de la concatenación de entradas del usuario antes de la ejecución. La inyección de SQL, que se orienta a aplicaciones web, para dispositivos móviles y para cualquier aplicación de base de datos SQL, suele aparecer en el documento OWASP Top 10 de vulnerabilidades web. Los atacantes usaron la técnica en varias vulneraciones de alto perfil.

En este ejemplo básico, una entrada sin escape de un usuario en un cuadro de número de pedido se puede insertar en la cadena de SQL y se puede interpretar como la siguiente consulta:

SELECT * FROM users WHERE email = 'example@example.com' AND order_number = '251542'' LIMIT 1

Este código generaría un error de sintaxis de la base de datos en una consola web, que muestra que la aplicación puede ser vulnerable a la inyección de SQL. Reemplazar el número de pedido por 'OR 1=1– significa que se puede lograr la autenticación, dado que la base de datos evalúa la instrucción como True, ya que uno siempre equivale a uno.

Del mismo modo, esta consulta muestra todas las filas de una tabla:

SELECT * FROM purchases WHERE email='admin@app.com' OR 1=1;

Proveedores de contenido

Los proveedores de contenido ofrecen un mecanismo de almacenamiento estructurado que puede limitarse a una aplicación o exportarse para compartirlo con otras apps. Los permisos se deben configurar según el principio de privilegio mínimo; un ContentProvider exportado puede tener un solo permiso especificado para lectura y escritura.

Cabe señalar que no todas las inyecciones de SQL llevan a la vulneración. Algunos proveedores de contenido ya otorgan a los lectores acceso completo a la base de datos SQLite; poder ejecutar consultas arbitrarias otorga muy pocas ventajas. Los patrones que pueden representar un problema de seguridad incluyen los siguientes:

  • Varios proveedores de contenido que comparten un solo archivo de base de datos SQLite.
    • En este caso, cada tabla puede estar destinada a un proveedor de contenido único. Una inyección de SQL exitosa en un proveedor de contenido otorgaría acceso a cualquier otra tabla.
  • Un proveedor de contenido tiene varios permisos para el contenido dentro de la misma base de datos.
    • La inyección de SQL en un solo proveedor de contenido que otorga acceso con diferentes niveles de permiso podría llevar a una omisión local de la configuración de seguridad o privacidad.

Impacto

La inyección de SQL puede exponer datos sensibles de usuarios o aplicaciones, superar las restricciones de autenticación y autorización, y provocar que las bases de datos queden vulnerables ante la corrupción o la eliminación. Los impactos pueden incluir implicaciones peligrosas y duraderas para los usuarios cuyos datos personales estuvieron expuestos. Los proveedores de apps y servicios corren el riesgo de perder la propiedad intelectual o la confianza del usuario.

Mitigaciones

Parámetros reemplazables

El uso de ? como parámetro reemplazable en cláusulas de selección y un array independiente de argumentos de selección vincula la entrada del usuario directamente a la consulta en lugar de interpretarla como parte de una instrucción de SQL.

Kotlin

// Constructs a selection clause with a replaceable parameter.
val selectionClause = "var = ?"

// Sets up an array of arguments.
val selectionArgs: Array<String> = arrayOf("")

// Adds values to the selection arguments array.
selectionArgs[0] = userInput

Java

// Constructs a selection clause with a replaceable parameter.
String selectionClause =  "var = ?";

// Sets up an array of arguments.
String[] selectionArgs = {""};

// Adds values to the selection arguments array.
selectionArgs[0] = userInput;

La entrada del usuario está vinculada directamente a la consulta en lugar de tratarse como SQL, lo que evita la inyección de código.

A continuación, veremos un ejemplo más elaborado en el que se muestra la consulta de una app de compras para recuperar detalles de compra con parámetros reemplazables:

Kotlin

fun validateOrderDetails(email: String, orderNumber: String): Boolean {
    val cursor = db.rawQuery(
        "select * from purchases where EMAIL = ? and ORDER_NUMBER = ?",
        arrayOf(email, orderNumber)
    )

    val bool = cursor?.moveToFirst() ?: false
    cursor?.close()

    return bool
}

Java

public boolean validateOrderDetails(String email, String orderNumber) {
    boolean bool = false;
    Cursor cursor = db.rawQuery(
      "select * from purchases where EMAIL = ? and ORDER_NUMBER = ?",
      new String[]{email, orderNumber});
    if (cursor != null) {
        if (cursor.moveToFirst()) {
            bool = true;
        }
        cursor.close();
    }
    return bool;
}

Usa objetos PreparedStatement

La interfaz PreparedStatement compila previamente las instrucciones de SQL como un objeto que, luego, se puede ejecutar de manera eficiente varias veces. PreparedStatement usa ? como marcador de posición para parámetros, lo que haría que el siguiente intento de inyección compilado no fuera eficaz:

WHERE id=295094 OR 1=1;

En este caso, la instrucción 295094 OR 1=1 se lee como el valor para el ID, probablemente sin resultados, mientras que una consulta sin procesar interpretaría la instrucción OR 1=1 como otra parte de la cláusula WHERE. En el siguiente ejemplo, se muestra una consulta parametrizada:

Kotlin

val pstmt: PreparedStatement = con.prepareStatement(
        "UPDATE EMPLOYEES SET ROLE = ? WHERE ID = ?").apply {
    setString(1, "Barista")
    setInt(2, 295094)
}

Java

PreparedStatement pstmt = con.prepareStatement(
                                "UPDATE EMPLOYEES SET ROLE = ? WHERE ID = ?");
pstmt.setString(1, "Barista")
pstmt.setInt(2, 295094)

Usa métodos de consulta

En este ejemplo más largo, selection y selectionArgs del método query() se combinan para crear una cláusula WHERE. Dado que los argumentos se proporcionan por separado, se escapan antes de su combinación, lo que evita la inyección de SQL.

Kotlin

val db: SQLiteDatabase = dbHelper.getReadableDatabase()
// Defines a projection that specifies which columns from the database
// should be selected.
val projection = arrayOf(
    BaseColumns._ID,
    FeedEntry.COLUMN_NAME_TITLE,
    FeedEntry.COLUMN_NAME_SUBTITLE
)

// Filters results WHERE "title" = 'My Title'.
val selection: String = FeedEntry.COLUMN_NAME_TITLE.toString() + " = ?"
val selectionArgs = arrayOf("My Title")

// Specifies how to sort the results in the returned Cursor object.
val sortOrder: String = FeedEntry.COLUMN_NAME_SUBTITLE.toString() + " DESC"

val cursor = db.query(
    FeedEntry.TABLE_NAME,  // The table to query
    projection,            // The array of columns to return
                           //   (pass null to get all)
    selection,             // The columns for the WHERE clause
    selectionArgs,         // The values for the WHERE clause
    null,                  // Don't group the rows
    null,                  // Don't filter by row groups
    sortOrder              // The sort order
).use {
    // Perform operations on the query result here.
    it.moveToFirst()
}

Java

SQLiteDatabase db = dbHelper.getReadableDatabase();
// Defines a projection that specifies which columns from the database
// should be selected.
String[] projection = {
    BaseColumns._ID,
    FeedEntry.COLUMN_NAME_TITLE,
    FeedEntry.COLUMN_NAME_SUBTITLE
};

// Filters results WHERE "title" = 'My Title'.
String selection = FeedEntry.COLUMN_NAME_TITLE + " = ?";
String[] selectionArgs = { "My Title" };

// Specifies how to sort the results in the returned Cursor object.
String sortOrder =
    FeedEntry.COLUMN_NAME_SUBTITLE + " DESC";

Cursor cursor = db.query(
    FeedEntry.TABLE_NAME,   // The table to query
    projection,             // The array of columns to return (pass null to get all)
    selection,              // The columns for the WHERE clause
    selectionArgs,          // The values for the WHERE clause
    null,                   // don't group the rows
    null,                   // don't filter by row groups
    sortOrder               // The sort order
    );

Usa SQLiteQueryBuilder configurado correctamente

Los desarrolladores pueden proteger aún más las aplicaciones con SQLiteQueryBuilder, una clase que ayuda a compilar consultas que se enviarán a objetos SQLiteDatabase. Las configuraciones recomendadas incluyen las siguientes:

Usa la biblioteca de Room

El paquete android.database.sqlite proporciona las APIs necesarias para usar bases de datos en Android. Sin embargo, este enfoque requiere escribir código de bajo nivel y carece de la verificación en tiempo de compilación de las consultas de SQL sin procesar. A medida que cambian los grafos de datos, las consultas de SQL afectadas deben actualizarse de forma manual, un proceso lento y propenso a errores.

Una solución de alto nivel es usar la biblioteca de persistencias Room como una capa de abstracción para las bases de datos SQLite. Las funciones de Room constan de lo siguiente:

  • Una clase de base de datos que funciona como punto de acceso principal para conectarse a los datos guardados de la app
  • Entidades de datos que representan las tablas de la base de datos
  • Objetos de acceso a datos (DAO), que proporcionan métodos que la app puede usar para consultar, actualizar, insertar y borrar datos

Los beneficios de Room incluyen los siguientes:

  • Verificación del tiempo de compilación de las consultas en SQL
  • Reducción del código estándar propenso a errores
  • Migración de base de datos optimizada

Prácticas recomendadas

La inyección de SQL es un potente ataque que puede ser difícil de contrarrestar por completo, en especial en el caso de aplicaciones grandes y complejas. Se deben tener en cuenta consideraciones de seguridad adicionales para limitar la gravedad de las fallas potenciales en las interfaces de datos, incluidas las siguientes:

  • Hashes sólidos, unidireccionales y salados para encriptar contraseñas:
    • AES de 256 bits para aplicaciones comerciales
    • Tamaños de claves públicas de 224 o 256 bits para criptografía de curva elíptica
  • Limitación de permisos
  • Estructurar de forma precisa los formatos de datos y verificar que los datos cumplan con el formato esperado
  • Evitar almacenar datos del usuario sensibles o personales siempre que sea posible (por ejemplo, implementar la lógica de la aplicación mediante un hash en lugar de transmitirlos o almacenarlos)
  • Minimizar las APIs y las aplicaciones de terceros que acceden a datos sensibles

Recursos