wstrzyknięcie kodu SQL

Kategoria OWASP: MASVS-CODE: Jakość kodu

Przegląd

Wstrzyknięcie kodu SQL wykorzystuje podatne na ataki aplikacje przez wstawianie kodu do instrukcji SQL w celu uzyskania dostępu do baz danych poza ich celowo udostępnionymi interfejsami. Atak może spowodować ujawnienie prywatnych danych, uszkodzenie zawartości bazy danych, a nawet naruszenie bezpieczeństwa infrastruktury backendu.

SQL może być podatny na wstrzykiwanie za pomocą zapytań, które są tworzone dynamicznie przez łączenie danych wejściowych użytkownika przed wykonaniem. Wstrzyknięcie kodu SQL, które jest kierowane na aplikacje internetowe, mobilne i dowolne aplikacje bazodanowe SQL, zwykle znajduje się na liście OWASP Top Ten najczęstszych luk w zabezpieczeniach internetowych. Osoby przeprowadzające ataki wykorzystały tę technikę w kilku głośnych przypadkach naruszenia bezpieczeństwa.

W tym podstawowym przykładzie nieprawidłowo sformatowane dane wejściowe użytkownika w polu numeru zamówienia mogą zostać wstawione do ciągu SQL i zinterpretowane jako to zapytanie:

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

Taki kod wygeneruje w konsoli internetowej błąd składni bazy danych, co wskazuje, że aplikacja może być podatna na wstrzyknięcie kodu SQL. Zastąpienie numeru zamówienia symbolem 'OR 1=1– umożliwia uwierzytelnianie, ponieważ baza danych ocenia instrukcję jako 'OR 1=1–, ponieważ 1 zawsze równa się 1.True

Podobnie to zapytanie zwraca wszystkie wiersze z tabeli:

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

Dostawcy treści

Dostawcy treści oferują mechanizm przechowywania danych w formie uporządkowanej, który może być ograniczony do aplikacji lub eksportowany w celu udostępniania innym aplikacjom. Uprawnienia należy ustawiać zgodnie z zasadą jak najmniejszych uprawnień. Eksportowany ContentProvider może mieć jedno określone uprawnienie do odczytu i zapisu.

Warto zauważyć, że nie wszystkie ataki SQL injection prowadzą do wykorzystania luki w zabezpieczeniach. Niektórzy dostawcy treści przyznają już czytelnikom pełny dostęp do bazy danych SQLite, więc możliwość wykonywania dowolnych zapytań nie daje im dużej przewagi. Wzorce, które mogą wskazywać na problem z bezpieczeństwem:

  • Wielu dostawców treści udostępnia jeden plik bazy danych SQLite.
    • W takim przypadku każda tabela może być przeznaczona dla innego dostawcy treści. Udane wstrzyknięcie kodu SQL w jednym dostawcy treści zapewni dostęp do wszystkich innych tabel.
  • Dostawca treści ma wiele uprawnień do treści w tej samej bazie danych.
    • Wstrzyknięcie kodu SQL w jednym dostawcy treści, który przyznaje dostęp na różnych poziomach uprawnień, może prowadzić do lokalnego obejścia ustawień bezpieczeństwa lub prywatności.

Wpływ

Wstrzyknięcie kodu SQL może ujawnić dane wrażliwe użytkownika lub aplikacji, obejść ograniczenia uwierzytelniania i autoryzacji oraz sprawić, że bazy danych będą podatne na uszkodzenie lub usunięcie. Może to mieć niebezpieczne i trwałe konsekwencje dla użytkowników, których dane osobowe zostały ujawnione. Dostawcy aplikacji i usług ryzykują utratę własności intelektualnej lub zaufania użytkowników.

Środki ograniczające ryzyko

Parametry do zastąpienia

Użycie znaku ? jako parametru wymiennego w klauzulach wyboru oraz odrębnej tablicy argumentów wyboru wiąże dane wejściowe użytkownika bezpośrednio z zapytaniem, zamiast interpretować je jako część instrukcji 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;

Dane wejściowe użytkownika są powiązane bezpośrednio z zapytaniem, a nie traktowane jako SQL, co zapobiega wstrzykiwaniu kodu.

Oto bardziej złożony przykład zapytania aplikacji zakupowej o pobranie szczegółów zakupu z parametrami, które można zastąpić:

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

Używanie obiektów PreparedStatement

Interfejs PreparedStatement wstępnie kompiluje instrukcje SQL jako obiekt, który można następnie wielokrotnie wykonywać w wydajny sposób. W przypadku PreparedStatement jako symbolu zastępczego parametrów używany jest znak ?, co sprawia, że ta próba wstrzyknięcia skompilowanego kodu jest nieskuteczna:

WHERE id=295094 OR 1=1;

W takim przypadku instrukcja 295094 OR 1=1 jest odczytywana jako wartość identyfikatora, co prawdopodobnie nie przyniesie żadnych wyników, podczas gdy surowe zapytanie zinterpretuje instrukcję OR 1=1 jako kolejną część klauzuli WHERE. Poniżej znajduje się przykład zapytania z parametrami:

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)

Korzystanie z metod zapytań

W tym dłuższym przykładzie argumenty selectionselectionArgs metody query() są połączone, aby utworzyć klauzulę WHERE. Argumenty są podawane oddzielnie, więc przed połączeniem są odpowiednio formatowane, co zapobiega wstrzyknięciu kodu 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
    );

Używaj prawidłowo skonfigurowanego obiektu SQLiteQueryBuilder.

Deweloperzy mogą dodatkowo chronić aplikacje, korzystając z klasy SQLiteQueryBuilder, która pomaga tworzyć zapytania wysyłane do obiektów SQLiteDatabase. Zalecane konfiguracje to:

Korzystanie z biblioteki Room

Pakiet android.database.sqlite udostępnia interfejsy API niezbędne do korzystania z baz danych na Androidzie. To podejście wymaga jednak pisania kodu niskiego poziomu i nie zapewnia weryfikacji zapytań SQL w czasie kompilowania. W miarę zmian w grafach danych trzeba ręcznie aktualizować powiązane zapytania SQL, co jest czasochłonne i może prowadzić do błędów.

Rozwiązaniem wysokiego poziomu jest użycie biblioteki trwałości danych Room jako warstwy abstrakcji dla baz danych SQLite. Wyposażenie pokoju obejmuje:

  • Klasa bazy danych, która służy jako główny punkt dostępu do połączenia z utrwalonymi danymi aplikacji.
  • Encje danych reprezentujące tabele bazy danych.
  • Obiekty umożliwiające dostęp do danych (DAO), które udostępniają metody umożliwiające aplikacji wykonywanie zapytań, aktualizowanie, wstawianie i usuwanie danych.

Zalety pokoju:

  • Weryfikacja zapytań SQL w czasie kompilowania.
  • Ograniczenie podatnego na błędy powtarzalnego kodu.
  • Uproszczona migracja bazy danych.

Sprawdzone metody

Wstrzyknięcie kodu SQL to potężny atak, przed którym trudno się w pełni zabezpieczyć, zwłaszcza w przypadku dużych i złożonych aplikacji. Należy wdrożyć dodatkowe środki bezpieczeństwa, aby ograniczyć powagę potencjalnych wad interfejsów danych, w tym:

  • solidne, jednokierunkowe i z ciągiem zaburzającym funkcje skrótu do szyfrowania haseł:
    • 256-bitowe szyfrowanie AES w przypadku zastosowań komercyjnych.
    • Klucze publiczne o długości 224 lub 256 bitów w przypadku kryptografii krzywych eliptycznych.
  • ograniczanie uprawnień,
  • precyzyjne strukturyzowanie formatów danych i sprawdzanie, czy dane są zgodne z oczekiwanym formatem;
  • Unikaj przechowywania danych osobowych lub poufnych użytkownika, jeśli to możliwe (np. wdrażaj logikę aplikacji przez haszowanie, a nie przesyłanie lub przechowywanie danych).
  • Ograniczanie liczby interfejsów API i aplikacji innych firm, które mają dostęp do danych wrażliwych.

Zasoby