Cyberbezpieczeństwo okiem programisty

SQL injection w Django (CVE-2021-35042)

S

Artykuł jest poświęcony stosunkowo świeżej podatności, bo z roku 2021, znalezionej w kodzie frameworka webowego Django. Umożliwia ona wstrzyknięcie własnego kodu SQL do klauzuli ORDER BY, pod pewnymi warunkami. Zapraszam do dalszej lektury.

Uwaga! Ten wpis powstał wyłącznie w celach edukacyjnych. Informacje w nim zawarte mogą służyć jedynie do testowania podatności w kontrolowanych środowiskach, do których mamy autoryzowany dostęp.

abstrakt

Ogólne informacje o zagrożeniu, wraz z klasyfikacją CVSS (Common Vulnerability Scoring System), można znaleźć m.in. na stronie amerykańskiej agencji federalnej NIST. Dowiadujemy się więc, że:

  • Identyfikator podatności to CVE-2021-35042.
  • Podatne wersje Django to 3.1.x (< 3.1.13) oraz 3.2.x (< 3.2.5).

Z oficjalnego raportu dowiadujemy się, że jeśli do metody QuerySet.order_by() przekazujemy łańcuchy znaków przychodzące bezpośrednio od klienta (głównie z przeglądarki), bez żadnej weryfikacji, narażamy się na potencjalny atak SQL injection.

Kiedy próbujemy posortować dane używając pełnej nazwy kolumny w bazie danych, zamiast pola określonego w modelu (np. order_by('articlel_article.id')), metoda nie sprawdza poprawności nazwy kolumny docelowej. Jedynie pojawia się ostrzeżenie o tym, że takie podejście jest już przestarzałe i zaleca się użycia wyrażeń RawSQL. Nie powoduje to jednak żadnego błędu i przekazany łańcuch znaków jest później używany do sklejenia końcowego zapytania SQL przez Django ORM.

Poniżej znajduje się dokładna analiza przedstawionego problemu wraz z przykładem jego wykorzystania. Podam przykład kodu aplikacji webowej, która jest podatna oraz pokażę jak wykorzystać ten błąd jeśli aplikacja korzysta z relacyjnych baz danych, takich jak SQLite, MySQL oraz PostgreSQL.

Jeśli jednak czas Cię goni, Drogi Czytelniku, przejdź proszę od razu do sekcji Łatanie Dziur.

Diabeł tkwi w szczegółach

SQL Injection

Nie chcę tutaj omawiać dokładnie na czym polega technika SQL injection, bo można by było napisać na ten temat niejeden solidny artykuł. Dlatego przedstawię jedynie ogólny zarys.

Ataki polegające na wstrzykiwaniu złośliwego kodu do działających aplikacji są znane już od dawna. Pozwalają one atakującym na dodanie swojego kodu (często złośliwego) do standardowych instrukcji wykonywanych przez aplikację/proces. Jednym z takich ataków jest SQL injection, polegający na wprowadzeniu własnego, nieprzewidzianego przez system zapytania SQL lub jego fragmentu. W przypadku aplikacji webowych jest to zazwyczaj możliwe przez niefrasobliwość programistów, którzy nie weryfikują danych otrzymanych bezpośrednio z przeglądarki. Otrzymane dane (np. nazwy kolumn, na podstawie których ma się odbywać sortowanie) mogą być później bezpośrednio użyte do sklejenia końcowego zapytania SQL wysyłanego do systemu bazodanowego.

Kiedy spotkamy się z taką sytuacją i wiemy, że to co wyślemy z przeglądarki nie zostanie w żaden sposób sprawdzone przed użyciem w końcowym zapytaniu SQL, otwiera się przed nami szeroki wachlarz możliwości. W zależności od skali błędu możemy spreparować takie zapytanie, które jest w stanie wyciągnąć wrażliwe dane, zmodyfikować już istniejące, a w skrajnych przypadkach nawet usunąć całą bazę danych!

Ataki SQL injection dzielą się na kilka różnych typów. W przypadku omawianego błędu użyjemy tzw. blind SQL injection, charakteryzujący się tym, że nie otrzymamy w rezultacie interesujących nas danych na srebrnej tacy. Będziemy musieli wstrzykiwać odpowiednie zapytania na ślepo i jedynie na podstawie zachowania się aplikacji uzyskamy interesującą nas odpowiedź.

Dodatkowe materiały omawiające to zagadnienie bardziej szczegółowo:

Generowanie zapytania przez Django ORM

Zanim przejdziemy do omawiania na czym dokładnie polega błąd w implementacji metody QuerySet.order_by(), przyjrzyjmy się mechanizmowi generowania zapytań SQL przez Django ORM. Załóżmy, że mamy zdefiniowany model, opisujący artykuł jakiegoś portalu, o nazwie Article:

from django.db import models

class Article(models.Model):
    author = models.CharField(max_length=1024)
    publication_date = models.DateField()
    title = models.CharField(max_length=1024)
    content = models.TextField()

    def __str__(self):
        return f'{self.title} ({self.author})'

Jeśli używamy wbudowanego mechanizmu migracji to powyższy model powinien zostać zmapowany na następującą relację (przykład tabeli w bazie SQLite):

CREATE TABLE "articles_article" (
    "id"	integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "content"	text NOT NULL,
    "author"	varchar(1024) NOT NULL,
    "publication_date"	date,
    "title"	varchar(1024) NOT NULL
);

Zobaczmy więc co się stanie jeśli zechcemy wyciągnąć z bazy wszystkie artykuły, posortowane po dacie publikacji (publication_date), począwszy od najnowszego:

Article.objects.order_by('-publication_date')

Dla przypomnienia: Article.objects jest obiektem klasy Manager, odpowiedzialnej za budowanie odpowiednich zestawów danych QuerySet, więc powyższy fragment kodu wywołuje metodę order_by na obiekcie QuerySet. Prześledźmy teraz co się dzieje pod spodem, na przykładzie kodu Django w wersji 3.2.4:

def order_by(self, *field_names):
        """Return a new QuerySet instance with the ordering changed."""
        assert not self.query.is_sliced, \
            "Cannot reorder a query once a slice has been taken."
        obj = self._chain()
        obj.query.clear_ordering(force_empty=False)
        obj.query.add_ordering(*field_names)
        return obj

obj.query jest obiektem klasy Query, na którym wykonywane są dwie operacje: wyczyszczenie aktualnie zdefiniowanego sortowania, oprócz domyślnego (6) oraz dodanie nowego na podstawie pól przekazanych do metody (7).

Zobaczmy co się dzieje w wywołanej metodzie Query.add_ordering(). Następuje tutaj weryfikacja przekazanych argumentów sortowania i jeśli parametry są prawidłowe, zostają zapisane w tupli self.order_by, która będzie później użyta przez kompilator (chodzi o SQLCompiler) do wygenerowania końcowego zapytania SQL. W tej metodzie znajduje się również błąd umożliwiający omawiany atak, ale o tym za chwilę.

Obiekt klasy Query reprezentuje pojedyncze zapytanie SQL, więc oprócz danych sortowania zawiera również inne informacje, niezbędne do utworzenia zapytania SQL wysyłanego później do bazy danych. A dzieje się to za sprawą kompilatora SQLCompiler oraz jego metody as_sql(), tłumaczącej powiązany obiekt Query na łańcuch znaków (string) z wynikowym zapytaniem SQL.

Gdzie leży problem?

Jak zostało już wcześniej wspomniane, błąd znajduje się w metodzie Query.add_ordering(). Kiedy zapoznamy się z treścią commita: [3.2.x] Fixed CVE-2021-35042 — Prevented SQL injection in QuerySet.order_by(), od razu dowiadujemy się, że jest to błąd regresji wynikający z wcześniejszego commita, który usunął weryfikację formatu kolumny.

for item in ordering:
    if isinstance(item, str):
        if '.' in item:
            warnings.warn(
                'Passing column raw column aliases to order_by() is '
                'deprecated. Wrap %r in a RawSQL expression before '
                'passing it to order_by().' % item,
                category=RemovedInDjango40Warning,
                stacklevel=3,
            )
            continue
        if item == '?':
            continue
        if item.startswith('-'):
            item = item[1:]
        if item in self.annotations:
            continue
        if self.extra and item in self.extra:
            continue
        # names_to_path() validates the lookup. A descriptive
        # FieldError will be raise if it's not.
        self.names_to_path(item.split(LOOKUP_SEP), self.model._meta)
    elif not hasattr(item, 'resolve_expression'):
        errors.append(item)
    if getattr(item, 'contains_aggregate', False):
        raise FieldError(
            'Using an aggregate in order_by() without also including '
            'it in annotate() is not allowed: %s' % item
        )

Jak wynika z powyższego fragmentu metody add_ordering(), następuje tutaj sprawdzenie czy przekazany argument jest łańcuchem znaków, a jeśli zawiera kropkę to znaczy, że użytkownik chce posortować wynik posługując się pełną nazwą kolumny, czyli np. wywołał metodę w taki sposób: Article.objects.order_by('-articles_article.publication_date'). Co prawda dostaniemy ostrzeżenie zachęcające do unikania takich konstrukcji, ale sortowanie zadziała jak należy.

Niestety, po usunięciu dodatkowego warunku ORDER_PATTERN.match(item), sprawdzającego czy podany string jest prawidłowy na podstawie wyrażenia regularnego: r'\?|[-+]?[.\w]+$', możemy wykonać również taką operację: Article.objects.order_by('articles_article.publication_date); -- '). To spowoduje wygenerowanie następującego zapytania SQL:

SELECT "articles_article"."id", "articles_article"."author", "articles_article"."publication_date", "articles_article"."title", "articles_article"."content" FROM "articles_article" ORDER BY ("articles_article".publication_date); -- ) ASC LIMIT 21

Mamy więc możliwość modyfikowania końcowego zapytania SQL poprzez manipulację wartością argumentu metody QuerySet.order_by().

Exploit

Skoro znamy już anatomię podatności, zobaczmy w jaki sposób może być wykorzystana, na przykładzie bardzo prostej aplikacji napisanej na potrzeby artykułu w Django 3.2.4. Kod przykładowej aplikacji wraz z exploitem można znaleźć tutaj.

Dołączona do repozytorium dokumentacja README opisuje dosyć dokładnie przykładową aplikację oraz zasadę działania exploita, więc tutaj przedstawię jedynie ogólny zarys. Aplikacja wyświetla na stronie głównej artykuły, zapisane w bazie danych, w formie tabelarycznej. Tabela ma bardzo prosty mechanizm sortowania, który został w całości zaimplementowany na potrzeby prezentacji (bez użycia bibliotek) i który jest podatny na omawianą podatność. Wartość parametru URL order jest przekazywana bezpośrednio do metody order_by():

def get_queryset(self) -> QuerySet[Article]:
    self.order_by = self.request.GET.get('order')
    if not self.order_by:
        self.order_by = '-publication_date'
    return Article.objects.order_by(self.order_by)

Dołączony exploit wykorzystuje ten fakt i za pomocą odpowiednio skonstruowanego zapytania SQL próbuje wyciągnąć nazwy użytkowników z tabeli auth_user (domyślna tabela utworzona automatycznie przez Django):

select * from articles_article
order by 
    (articles_article.id=0),
    (case 
        when exists(select 1 from auth_user u where u.username like 'USERNAME%') 
        then author 
        else title 
    end) asc;

Ogólnie rzecz biorąc, algorytm exploita próbuje odgadnąć nazwy użytkowników, znak po znaku, obserwując kierunek sortowania tabeli po wstrzyknięciu kodu SQL (innymi słowy: blind SQL injection).

Należy pamiętać, że przedstawiony tutaj exploit nie jest uniwersalny i jego kod jest dostosowany do tej konkretnej aplikacji. Niemniej jednak, pokazuje jak groźna jest luka tego typu, ponieważ przy odpowiednich warunkach i dużej determinacji atakującego może doprowadzić do wycieku bardzo wrażliwych danych.

Łatanie dziur

Całe szczęście omawiana podatność została zauważona już jakiś czas temu i można stosunkowo łatwo ją załatać. Pierwszym i najważniejszym krokiem powinna być aktualizacja frameworka Django przynajmniej do wersji 3.1.13 lub 3.2.5. Jeśli z jakichś powodów nie jesteśmy w stanie szybko wdrożyć takiego rozwiązania to należy się upewnić, że nigdzie nie przekazujemy nieprzetworzonych danych, otrzymanych bezpośrednio od użytkowników, do metody order_by(). W gruncie rzeczy to ZAWSZE powinniśmy walidować dane otrzymane od użytkowników, zanim przekażemy je do dalszego przetwarzania.

O autorze

Łukasz Mieczkowski

Programista, który zainteresował się cyberbezpieczeństwem.

Dodaj komentarz

Cyberbezpieczeństwo okiem programisty

Łukasz Mieczkowski

Programista, który zainteresował się cyberbezpieczeństwem.

Kontakt

Zapraszam do kontaktu za pośrednictwem mediów społecznościowych.