SQL 注入

OWASP 类别: MASVS-CODE: 代码质量

概览

SQL 注入通过向 SQL 语句插入代码来利用易受攻击的应用,从而访问底层数据库超出其有意公开接口的部分。这种攻击可能暴露私人数据、破坏数据库内容,甚至危及后端基础设施。

通过在执行前连接用户输入动态创建的查询,SQL 可能会受到注入攻击。SQL 注入针对网络、移动端和任何 SQL 数据库应用,通常列入网络漏洞的 OWASP 十大榜单。攻击者在几次高知名度的泄露事件中使用了此技术。

在这个基本示例中,用户在订单号输入框中输入未转义的内容,可以插入到 SQL 字符串中,并被解释为以下查询

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

这样的代码会在 web 控制台生成一个数据库语法错误,这表明应用可能容易受到 SQL 注入攻击。将订单号替换为 'OR 1=1– 意味着可以实现身份验证,因为数据库会将该语句评估为 True,因为一始终等于一。

同样,此查询返回表中所有行

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

内容提供器

内容提供器提供一种结构化存储机制,可以限制在应用内部使用,也可以导出供其他应用共享。应基于最小权限原则设置权限;导出的 ContentProvider 可以针对读取和写入设置单个指定的权限。

值得注意的是,并非所有 SQL 注入都会导致漏洞利用。一些内容提供器已经授予读取者对 SQLite 数据库的完全访问权限;能够执行任意查询的好处很小。可能代表安全问题的模式包括:

  • 多个内容提供器共享同一个 SQLite 数据库文件。
    • 在这种情况下,每个表可能意图用于唯一的内容提供器。在一个内容提供器中成功的 SQL 注入将授予对任何其他表的访问权限。
  • 内容提供器在同一数据库内针对不同内容具有多个权限。
    • 在单个内容提供器中进行 SQL 注入,如果该提供器授予具有不同权限级别的内容访问权限,可能导致本地绕过安全或隐私设置。

影响

SQL 注入可以暴露敏感的用户或应用数据,绕过身份验证和授权限制,并使数据库容易受到损坏或删除。其影响可能对个人数据被暴露的用户产生危险和持久的后果。应用和服务提供商面临丢失知识产权或用户信任的风险。

缓解措施

可替换参数

在选择子句中使用 ? 作为可替换参数,并使用单独的选择参数数组,将用户输入直接绑定到查询,而不是将其解释为 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;

用户输入直接绑定到查询,而不是被视为 SQL,从而防止代码注入。

这里有一个更详细的示例,展示了购物应用如何使用可替换参数查询购买详情:

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;
}

使用 PreparedStatement 对象

PreparedStatement 接口将 SQL 语句预编译为对象,然后可以高效地多次执行。PreparedStatement 使用 ? 作为参数的占位符,这将使以下编译后的注入尝试无效

WHERE id=295094 OR 1=1;

在这种情况下,295094 OR 1=1 语句被读取为 ID 的值,很可能没有结果,而原始查询会将 OR 1=1 语句解释为 WHERE 子句的另一部分。以下示例显示了一个参数化查询

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)

使用 query 方法

在这个更长的示例中,query() 方法的 selectionselectionArgs 被组合起来形成 WHERE 子句。由于参数是单独提供的,它们在组合之前经过转义,从而防止了 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
    );

使用正确配置的 SQLiteQueryBuilder

开发者可以通过使用 SQLiteQueryBuilder 进一步保护应用,这是一个帮助构建发送到 SQLiteDatabase 对象查询的类。推荐的配置包括:

使用 Room 库

android.database.sqlite 包提供了在 Android 上使用数据库所需的必要 API。然而,这种方法需要编写低级代码,并且缺乏对原始 SQL 查询的编译时验证。随着数据图发生变化,受影响的 SQL 查询需要手动更新 — 这是一个耗时且容易出错的过程。

一个高级解决方案是使用 Room 持久性库 作为 SQLite 数据库的抽象层。Room 的特性包括:

  • 一个数据库类,作为连接应用持久化数据的主入口点。
  • 代表数据库表的数据实体。
  • 数据访问对象 (DAO),提供应用可用于查询、更新、插入和删除数据的方法。

Room 的优点包括:

  • SQL 查询的编译时验证。
  • 减少易出错的模板代码。
  • 简化的数据库迁移。

最佳实践

SQL 注入是一种强大的攻击,要完全抵御它可能很困难,特别是对于大型复杂的应用。应采取额外的安全措施来限制数据接口中潜在缺陷的严重性,包括:

  • 使用健壮、单向且加盐的哈希算法加密密码
    • 商业应用使用 256 位 AES。
    • 椭圆曲线加密使用 224 位或 256 位公钥大小。
  • 限制权限。
  • 精确地构建数据格式并验证数据是否符合预期格式。
  • 尽可能避免存储个人或敏感用户数据(例如,通过哈希而非传输或存储数据来实现应用逻辑)。
  • 最小化访问敏感数据的 API 和第三方应用。

资源