GUIDA AntiVirus: funzionamento 'hook' in user mode e bypass

DispatchCode

Moderatore
Staff Forum
Utente Èlite
2,207
1,844
CPU
Intel I9-10900KF 3.75GHz 10x 125W
Dissipatore
Gigabyte Aorus Waterforce X360 ARGB
Scheda Madre
Asus 1200 TUF Z590-Plus Gaming ATX DDR4
HDD
1TB NVMe PCI 3.0 x4, 1TB 7200rpm 64MB SATA3
RAM
DDR4 32GB 3600MHz CL18 ARGB
GPU
Nvidia RTX 3080 10GB DDR6
Audio
Integrata 7.1 HD audio
Monitor
LG 34GN850
PSU
Gigabyte P850PM
Case
Phanteks Enthoo Evolv X ARGB
Periferiche
MSI Vigor GK30, mouse Logitech
Net
FTTH Aruba, 1Gb (effettivi: ~950Mb / ~480Mb)
OS
Windows 10 64bit / OpenSUSE Tumbleweed

AntiVirus: Bypass Userland API Hooking​



0 - Premessa​

Se non vi siete mai interessati più di tanto agli AV, già il titolo dovrebbe incuriosirvi. Apro con queste immagini:

imported_dll_without_av.png imported_dll_with_av.png

Osservando il secondo screen, noterete una DLL, in questo caso di BitDefender. Di che si tratta? Che cos'è?

NOTA: magari è solo un dettaglio a cui non farete molto caso, comunque noterete risoluzioni differenti in base agli screen; è dovuto al fatto che l'AV è sulla VM, che era in finestra, per comodità (e gli screen sono stati fatti dall'host).​

1 - Monitoraggio user mode (userland hooking)​

Un antivirus monitora molteplici attività, sia in kernel mode, che in user mode. Per effettuare dei controlli sui programmi che vengono avviati dall'utente, l'AV inietta all'interno del processo una libreria DLL proprietaria. Questa DLL viene utilizzata per accertarsi che il software non compia azioni pericolose per l'utente (ovvero, che non sia un malware). Se un comportamento è sospetto, il programma viene interrotto, e probabilmente spostato subito in quarantena.

I due screen mostrati sopra identificano nel primo caso l'avvio di un programma su un OS senza protezioni in tempo reale, senza suite installate; nel secondo invece l'AV in questione è BitDefender, ma il discorso è analogo per tutti gli altri (in tempo reale). Gli antivirus monitorano le chiamate di sistema che vengono fatte dai programmi; non monitorano tutte le chiamate di sistema, dipende da AV ad un'altro. Alcune suite monitorano un gran numero di queste "API call", mentre altri molte meno (se non quasi nessuna).

Le chiamate di sistema monitorate quando viene eseguito un programma in user mode (nel territorio dell'utente, userland) sono ovviamente quelle più "delicate", quelle usate anche da chi scrive malware o software potenzialmente pericolosi. Per comprendere se una certa chiamata a una di queste API è fatta con lo scopo di procurare un qualche tipo di problema, viene "controllata" dall'AV.

In buona sostanza l'AV devia quello che sarebbe il normale flusso della chiamata all'API, e prima di eseguirla effettivamente, salta all'interno di una funzione nella DLL che lui stesso ha iniettato. Considerate i due screenshot seguenti:

without_av.png

with_av.png

Quello che vedete nelle due immagini sono le funzioni contenute in NTDLL.DLL, una delle librerie usate dai programmi in Windows (e una di quelle praticamente sempre usate); in pratica le funzioni che il programma chiama, puntano a queste procedure (altre DLL, come Kernel32.dll, richiamano le procedure contenute in NTDLL). Per esempio, TerminateProcess internamente richiama NtTerminateProcess (in NTDLL).

Noterete nella prima colonna degli screen gli indirizzi di quelle funzioni (dove si trovano in memoria) e il nome di quella funzione.
La seconda colonna che vedete nello screen è il codice macchina (un'istruzione per ogni singola riga, in pratica).
La terza colonna è la rappresentazione mnemonica di quel codice macchina, ovvero il codice Assembly (in tutti gli screen si tratta di x86-64).
La quarta colonna sono solo commenti, ignoratela.

Prendiamo come esempio una funzione, NtReadVirtualMemory:

Codice:
00007FFD229AD710 <ntdll.NtReadVirtualMemory>         | 4C:8BD1               | mov r10,rcx                                                               | rcx:sub_7FFD229AD3D0+14
00007FFD229AD713                                     | B8 3F000000           | mov eax,3F                                                                | 3F:'?'
00007FFD229AD718                                     | F60425 0803FE7F 01    | test byte ptr ds:[7FFE0308],1                                             |
00007FFD229AD720                                     | 75 03                 | jne ntdll.7FFD229AD725                                                    |
00007FFD229AD722                                     | 0F05                  | syscall                                                                   |
00007FFD229AD724                                     | C3                    | ret                                                                       |
00007FFD229AD725                                     | CD 2E                 | int 2E                                                                    |
00007FFD229AD727                                     | C3                    | ret                                                                       |

l'istruzione mov eax, 0x3F assegna al registro EAX il valore 0x3F; questo numero identifica la funzione da richiamare. Le istruzioni che seguono sono un test su una locazione di memoria (ovvero se in 0x7FFE0308 è presente il numero 1) che se da come esito "true", viene eseguita l'istruzione syscall; questa provoca la transizione in kernel mode. Se da come esito false, viene eseguito int 0x2E.

Ora, guardiamo invece il codice del secondo screen; riporto sempre la medesima funzione:

Codice:
00007FFBD5B2D540 <ntdll.NtReadVirtualMemory>         | E9 3B3E0080              | jmp 7FFB55B31380                        |
00007FFBD5B2D545                                     | 0000                     | add byte ptr ds:[rax],al                |
00007FFBD5B2D547                                     | 00F6                     | add dh,dh                               |
00007FFBD5B2D549                                     | 04 25                    | add al,25                               |
00007FFBD5B2D54B                                     | 0803                     | or byte ptr ds:[rbx],al                 |
00007FFBD5B2D54D                                     | FE                       | ???                                     |
00007FFBD5B2D54E                                     | 7F 01                    | jg ntdll.7FFBD5B2D551                   |
00007FFBD5B2D550                                     | 75 03                    | jne ntdll.7FFBD5B2D555                  |
00007FFBD5B2D552                                     | 0F05                     | syscall                                 |
00007FFBD5B2D554                                     | C3                       | ret                                     |
00007FFBD5B2D555                                     | CD 2E                    | int 2E                                  |
00007FFBD5B2D557                                     | C3                       | ret                                     |

In questo caso il flusso è notevolmente differente rispetto al primo; si tratta in entrambi i casi di Windows 10 x64. Nel secondo esempio però c'è installato BitDefender (in versione trial).
La prima istruzione che si vede è un JMP che è stato inserito dall'AV; in questo modo ha "deviato" il flusso della chiamata a quella funzione. Quanto visto prende il nome di hooking.

Il JMP in questione salta a questo codice (ci sono tante altre funzioni simili):

Codice:
00007FFC787B1380                                     | 48:B8 00108C24C6010000   | mov rax,1C6248C1000                     | rax:EntryPoint
00007FFC787B138A                                     | 50                       | push rax                                | rax:EntryPoint
00007FFC787B138B                                     | 48:B8 B0002725C6010000   | mov rax,1C6252700B0                     | rax:EntryPoint
00007FFC787B1395                                     | C3                       | ret                                     |

Il valore inserito in RAX, è l'indirizzo a un'altra funzione. Lo si evince dall'istruzione RET che si vede poco sotto: infatti questo indirizzo viene messo sullo stack e in seguito, con RET, avviene un salto a questo indirizzo.
Giunti in quella funzione, ci troviamo di fronte a quest'altro codice:

Codice:
000001C6248C1000                                     | 9C                       | pushfq                                  |
000001C6248C1001                                     | 48:81EC E0000000         | sub rsp,E0                              |
000001C6248C1008                                     | 48:894C24 28             | mov qword ptr ss:[rsp+28],rcx           |
000001C6248C100D                                     | 48:894424 20             | mov qword ptr ss:[rsp+20],rax           | rax:EntryPoint
000001C6248C1012                                     | 48:B9 00008D24C6010000   | mov rcx,1C6248D0000                     |
000001C6248C101C                                     | F048:FF01                | lock inc qword ptr ds:[rcx]             |
000001C6248C1020                                     | B8 01000000              | mov eax,1                               |
000001C6248C1025                                     | F0:0FB141 08             | lock cmpxchg dword ptr ds:[rcx+8],eax   |
000001C6248C102A                                     | 0F84 DC010000            | je 1C6248C120C                          |
000001C6248C1030                                     | 4C:894424 60             | mov qword ptr ss:[rsp+60],r8            |
000001C6248C1035                                     | 4C:894C24 68             | mov qword ptr ss:[rsp+68],r9            | r9:EntryPoint
000001C6248C103A                                     | 4C:895424 70             | mov qword ptr ss:[rsp+70],r10           |
000001C6248C103F                                     | 4C:895C24 78             | mov qword ptr ss:[rsp+78],r11           |
000001C6248C1044                                     | 4C:89A424 80000000       | mov qword ptr ss:[rsp+80],r12           |
000001C6248C104C                                     | 4C:89AC24 88000000       | mov qword ptr ss:[rsp+88],r13           |
000001C6248C1054                                     | 4C:89B424 90000000       | mov qword ptr ss:[rsp+90],r14           |
000001C6248C105C                                     | 4C:89BC24 98000000       | mov qword ptr ss:[rsp+98],r15           |
000001C6248C1064                                     | 48:895C24 38             | mov qword ptr ss:[rsp+38],rbx           |
000001C6248C1069                                     | 48:895424 30             | mov qword ptr ss:[rsp+30],rdx           | rdx:EntryPoint
000001C6248C106E                                     | 48:897424 50             | mov qword ptr ss:[rsp+50],rsi           |
000001C6248C1073                                     | 48:897C24 58             | mov qword ptr ss:[rsp+58],rdi           |
000001C6248C1078                                     | 48:896C24 48             | mov qword ptr ss:[rsp+48],rbp           |
000001C6248C107D                                     | F3:0F7F8424 A0000000     | movdqu xmmword ptr ss:[rsp+A0],xmm0     |
000001C6248C1086                                     | F3:0F7F8C24 B0000000     | movdqu xmmword ptr ss:[rsp+B0],xmm1     |
000001C6248C108F                                     | F3:0F7F9424 C0000000     | movdqu xmmword ptr ss:[rsp+C0],xmm2     |
000001C6248C1098                                     | F3:0F7F9C24 D0000000     | movdqu xmmword ptr ss:[rsp+D0],xmm3     |
000001C6248C10A1                                     | 48:8BCC                  | mov rcx,rsp                             |
000001C6248C10A4                                     | 48:81C1 E8000000         | add rcx,E8                              |
000001C6248C10AB                                     | 48:894C24 40             | mov qword ptr ss:[rsp+40],rcx           |
000001C6248C10B0                                     | 48:8B4C24 20             | mov rcx,qword ptr ss:[rsp+20]           |
000001C6248C10B5                                     | 48:8D5424 20             | lea rdx,qword ptr ss:[rsp+20]           | rdx:EntryPoint
000001C6248C10BA                                     | FC                       | cld                                     |
000001C6248C10BB                                     | 48:B8 108BCCCDFC7F0000   | mov rax,atcuf64.7FFCCDCC8B10            | rax:EntryPoint
000001C6248C10C5                                     | FFD0                     | call rax                                | rax:EntryPoint
000001C6248C10C7                                     | 51                       | push rcx                                |
000001C6248C10C8                                     | 48:B9 00008D24C6010000   | mov rcx,1C6248D0000                     |
000001C6248C10D2                                     | F048:FF09                | lock dec qword ptr ds:[rcx]             |
000001C6248C10D6                                     | 59                       | pop rcx                                 |
000001C6248C10D7                                     | 48:83F8 02               | cmp rax,2                               | rax:EntryPoint
000001C6248C10DB                                     | 0F84 A2000000            | je 1C6248C1183                          |
000001C6248C10E1                                     | 48:83F8 01               | cmp rax,1                               | rax:EntryPoint
000001C6248C10E5                                     | 74 18                    | je 1C6248C10FF                          |
000001C6248C10E7                                     | 4C:8B5424 70             | mov r10,qword ptr ss:[rsp+70]           |
000001C6248C10EC                                     | 4C:8B5C24 78             | mov r11,qword ptr ss:[rsp+78]           |
000001C6248C10F1                                     | 48:8B4424 20             | mov rax,qword ptr ss:[rsp+20]           | rax:EntryPoint
000001C6248C10F6                                     | 48:81C4 E0000000         | add rsp,E0                              |
000001C6248C10FD                                     | 9D                       | popfq                                   |
000001C6248C10FE                                     | C3                       | ret                                     |

avviene un setup, una preparazione, per richiamare il codice iniettato dall'antivirus; la chiamata infatti avviene all'indirizzo 000001C6248C10C5: la mov nell'istruzione sopra setta proprio l'indirizzo ad una funzione interna alla DLL dell'AV (atcuf64, quella vista nel primo screenshot).

Non ho cercato di capire cosa faccia esattamente (online probabilmente troverete info in merito), ma è da questo punto in avanti che viene presa la decisione se bloccare o meno il programma. Se l'esito è positivo, il programma rimane in esecuzione e non viene interrotto.

2 - Bypassare un Hook​

Qui inizia a farsi interessante. Come "evadere" ciò che viene messo in campo? Uno dei modi per farlo, è l'approccio inverso: l'unhooking, cioè togliere l'hook che è presente in quella funzione. Tuttavia rimane un problema: va tolto, ma cosa va al suo posto, visto che il codice di quella DLL (NTDLL, in questo caso) è stato modificato in memoria? Si può usare il codice di NTDLL presente sul disco.

Per farlo è necessario eseguire alcune operazioni; supponiamo di voler rimuovere l'hook da NtReadVirtualMemory, i passi da seguire sono:

1) parsare il PE header: bisogna individuare l'indirizzo che ha questa funzione in memoria, quindi bisogna cercarla all'interno della ExportTable (nel PE header);
2) calcolare l'offset fisico di quella funzione dul disco;
3) leggere da NTDLL.DLL presente sul disco i bytes che si trovano all'offset calcolato in precedenza
4) modificare la protezione sulla memoria virtuale e sostituire il codice presente in memoria con quello letto dal disco.

Superato l'ultimo step, sarà possibile richiamare NtReadVirtualMemory (o la funzione sulla quale stiamo facendo l'unhook) senza che l'AV si metta di mezzo.

3 - Eseguire l'unhooking, un pò di codice​

Non ho ancora reso pubblico il codice su GitHub, anche se è funzionante; volevo dare una sistemata al codice e poi valutare se pubblicare tutto.
In estrema sintesi, viene fatto il confronto tra il codice macchina letto dalla memoria virtuale, e quello presente sul disco. Se non coincidono, probabilmente è presente un hook.

Una volta individuata la funzione nella Export Directory, viene preso l'RVA (Relative Virtual Address). A questo punto si devono controllare le sezioni per capire in quale si trova e calcolare l'offset fisico (la posizione su disco):

C:
PINFO GetFileOffsetFunction(LPCBYTE pImageBase, PIMAGE_NT_HEADERS pNtHeader, DWORD dwRvaFunction)
{
    PINFO pInfo = calloc(1, sizeof(INFO));

    PIMAGE_SECTION_HEADER pFirstSection = IMAGE_FIRST_SECTION(pNtHeader);
    PBYTE pFunctions = (PBYTE)(pImageBase + dwRvaFunction);

    for (int j = 0; j < pNtHeader->FileHeader.NumberOfSections; j++)
    {
        LPCBYTE pSectionStart = pImageBase + pFirstSection[j].VirtualAddress;
        LPCBYTE pSectionEnd = pSectionStart + pFirstSection[j].Misc.VirtualSize;

        if (pSectionStart <= pFunctions && pFunctions < pSectionEnd)
        {
            pInfo->pInMemoryFunction = pFunctions;
            pInfo->dwOffset = pFirstSection[j].PointerToRawData + (dwRvaFunction - pFirstSection[j].VirtualAddress);
            pInfo->is32Bit = pNtHeader->FileHeader.Machine == IMAGE_FILE_MACHINE_I386;
            return pInfo;
        }
    }

    return NULL;
}

Una volta individuata, viene memorizzato l'indirizzo in pInMemoryFunction; l'altro valore salvato è l'offset fisico del file (quello su disco).
Con le informazioni raccolte si procede alla lettura del codice macchina:

C:
struct instruction* GetInMemoryBytes(PINFO pInfo, DWORD dwNumberInstructions)
{
    return MemoryDisassembly((char*)pInfo->pInMemoryFunction, dwNumberInstructions);
}

La lettura del codice dalla memoria è abbastanza semplice: il puntatore salvato prima punta già al primo byte della funzione. Ho scelto di leggere 8 istruzioni, ma è un parametro che si può variare; ho preferito non utilizzare la funzione realizzata tempo fa che determina la lunghezza di una funzione poichè... non si comporta proprio sempre bene e non era pensata per le DLL. ?

Ciò che viene restituito è un puntatore a "instruction", che come si capisce rappresenta un'istruzione. E' una struct che fa parte di un progetto di cui ho già parlato, chiamato Machine Code Analyzer (mi ha semplificato un pò questo lavoro, visto che effettua il disassembly delle istruzioni, identificandone la lunghezza). E' bene comunque notare che si può anche fare tutto basandosi direttamente su una lettura di N bytes, anche senza determinare la lunghezza delle singole istruzioni; potrebbe tornare utile in caso si voglia modificare qualcosa di specifico.

C:
struct instruction* GetFileOnDiskBytes(PINFO pInfo, DWORD dwNumberInstructions)
{

    LPCSTR path_ntdll32 = "C:\\Windows\\SysWOW64\\ntdll.dll";
    LPCSTR path_ntdll64 = "C:\\Windows\\System32\\ntdll.dll";

    LPCSTR ntdll_file_path = pInfo->is32Bit ? path_ntdll32 : path_ntdll64;

    FILE* hFile = fopen(ntdll_file_path, "rb");

    size_t file_size = GetSize(hFile);
    uint8_t* file_on_disk_bytes = calloc(file_size, sizeof(uint8_t));

    fread(file_on_disk_bytes, sizeof(uint8_t), file_size, hFile);
    fclose(hFile);

    struct instruction* instr = MemoryDisassembly((char*)(file_on_disk_bytes + pInfo->dwOffset), dwNumberInstructions);

    free(file_on_disk_bytes);

    return instr;
}

Questa funzione va a leggere NTDLL sul disco, differenziando tra 32 e 64bit (valore salvato in precedenza, e presente nel PE header). Il codice dovrebbe essere facilmente comprensibile: viene allocato spazio sufficiente a mantenere in memoria la DLL, e successivamente il puntatore alla base della memoria allocata viene sommato all'offset ricavato in precedenza: in questo modo il puntatore viene incrementato e punterà alla funzione che stiamo cercando.

La funzione che effettua il disassembly è la seguente:

C:
struct instruction* MemoryDisassembly(char* in_buffer, DWORD dwNumberInstructions)
{
    struct instruction* instr = calloc(dwNumberInstructions, sizeof(struct instruction));

    int offset = 0;
    for (int i = 0; i < dwNumberInstructions; i++)
    {
        mca_decode(instr + i, 2, in_buffer, offset);
        offset += instr[i].length;
    }

    return instr;
}

Successivamente viene fatto il confronto sui singoli bytes che compongono le istruzioni: quando viene trovato un byte diverso, viene scelto il colore rosso, e viene mostrato a schermo con quel colore. La funzione di comparazione restituisce anche un valore BOOL; se è TRUE, viene richiamato l'unhook:

C:
void UnhookFunction(PINFO pInfo, struct instruction* on_disk_bytes, int dwNumberInstructions)
{
    DWORD totalInstrLengths = 0;
    for (int i = 0; i < dwNumberInstructions; i++)
    {
        totalInstrLengths += on_disk_bytes[i].length;
    }

    DWORD dwOldProtect;
    VirtualProtect(pInfo->pInMemoryFunction, totalInstrLengths, PAGE_EXECUTE_READWRITE, &dwOldProtect);

    DWORD dwLenPreviousIntr = 0;
    for (int i = 0; i < dwNumberInstructions; i++)
    {
        memcpy((pInfo->pInMemoryFunction + dwLenPreviousIntr), on_disk_bytes[i].instr, on_disk_bytes[i].length);
        dwLenPreviousIntr += on_disk_bytes[i].length;
    }

    VirtualProtect(pInfo->pInMemoryFunction, totalInstrLengths, dwOldProtect, &dwOldProtect);
}

A questo punto tramite VirtualProtect si indica di voler scrivere su quelle pagine di memoria (che sono protette, senza questa operazione, si avrebbe un'eccezione); la scrittura avviene sui bytes raccolti nella fase di disassembly vista in precedenza. Al termine della copia (che potrebbe anche essere fatta usando il puntatore, giusto per evitare memcpy), viene settata nuovamente la protezione presente in origine.

Questo è il risultato dell'esecuzione del programma su un OS senza AV, e uno con AV:

output_clean_sysem.png

output_hooked_system.png

Ovviamente ricordiamoci che siamo in user mode: la modifica ha effetto solo sul programma che ha effettuato l'unhook, e non su tutti gli altri programmi. La DLL infatti viene mappata nello spazio degli indirizzi del processo in esecuzione.

Spero che l'argomento sia stato interessante. Questa volta sono stato breve.

Ps. ho riletto ora per formattare e aggiungere le immagini, se notate strafalcioni... scritevemelo qui sotto (be gentle ?).
 

ilfe98

Moderatore
Staff Forum
Utente Èlite
3,049
1,277
CPU
Intel i7 7700K
Dissipatore
Bequiet Dark rock pro 4
Scheda Madre
Msi pc mate z270
HDD
Seagate barracuda 1tb, silicon power NVME 500gb
RAM
Patriot viper steel 3733Mhz
GPU
Inno 3d gtx 1080 herculez design
Monitor
Asus mg279q
PSU
Corsair HX750
Case
Itek lunar 23
Net
Tiscali ftth
OS
windows 10,mint,debian,Arch linux
Un lavoro che non ha eguali, davvero complimenti!
 
  • Adoro
Reazioni: DispatchCode

pabloski

Utente Èlite
2,868
916
Ottimo articolo sull'unhooking.

Per chi volesse spulciare il codice di una libreria "professionale" per l'hooking e che implementa vari trucchi, può guardare Detour https://github.com/Microsoft/Detours

Per chi invece volesse avventurarsi nell'AV evasion, considerate che, sotto Windows, l'hooking è stato praticamente sostituto da ETW ( di cui il TI, threat intelligence, è implementato in kernel land ) .
 

DispatchCode

Moderatore
Staff Forum
Utente Èlite
2,207
1,844
CPU
Intel I9-10900KF 3.75GHz 10x 125W
Dissipatore
Gigabyte Aorus Waterforce X360 ARGB
Scheda Madre
Asus 1200 TUF Z590-Plus Gaming ATX DDR4
HDD
1TB NVMe PCI 3.0 x4, 1TB 7200rpm 64MB SATA3
RAM
DDR4 32GB 3600MHz CL18 ARGB
GPU
Nvidia RTX 3080 10GB DDR6
Audio
Integrata 7.1 HD audio
Monitor
LG 34GN850
PSU
Gigabyte P850PM
Case
Phanteks Enthoo Evolv X ARGB
Periferiche
MSI Vigor GK30, mouse Logitech
Net
FTTH Aruba, 1Gb (effettivi: ~950Mb / ~480Mb)
OS
Windows 10 64bit / OpenSUSE Tumbleweed
Ottimo articolo sull'unhooking.

Per chi volesse spulciare il codice di una libreria "professionale" per l'hooking e che implementa vari trucchi, può guardare Detour https://github.com/Microsoft/Detours

Per chi invece volesse avventurarsi nell'AV evasion, considerate che, sotto Windows, l'hooking è stato praticamente sostituto da ETW ( di cui il TI, threat intelligence, è implementato in kernel land ) .

Quella lib se non vado errato è anche usata per iniettare DLL in un processo. L'avevo guardata un po' di tempo fa.
 

Entra

oppure Accedi utilizzando
Discord Ufficiale Entra ora!