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ę, iż jeżeli 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, iż 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żeli 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 zwykle 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, iż 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 choćby 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, iż 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:
- SQL Injection – darmowe szkolenie dla początkujących (Wideo)
- What Is SQL Injection? Types, Examples, Prevention (Updated)
- (OWASP) SQL Injection
- (OWASP) Blind SQL Injection
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, iż mamy zdefiniowany model, opisujący artykuł jakiegoś portalu, o nazwie Article:
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):
Zobaczmy więc co się stanie jeżeli zechcemy wyciągnąć z bazy wszystkie artykuły, posortowane po dacie publikacji (publication_date), począwszy od najnowszego:
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:
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żeli 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ę, iż jest to błąd regresji wynikający z wcześniejszego commita, który usunął weryfikację formatu kolumny.
Jak wynika z powyższego fragmentu metody add_ordering(), następuje tutaj sprawdzenie czy przekazany argument jest łańcuchem znaków, a jeżeli zawiera kropkę to znaczy, iż 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:
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():
Dołączony exploit wykorzystuje ten fakt i dzięki odpowiednio skonstruowanego zapytania SQL próbuje wyciągnąć nazwy użytkowników z tabeli auth_user (domyślna tabela utworzona automatycznie przez Django):
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ć, iż 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żeli z jakichś powodów nie jesteśmy w stanie gwałtownie wdrożyć takiego rozwiązania to należy się upewnić, iż 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.