
Libreria che facilita l'uso di syscall indirette. Un bypass AV/EDR piuttosto interessante come PoC.
Questo progetto implementa un modo comodo e moderno per usare le funzioni NTAPI tramite syscall indirette, accoppiato al metodo FreshyCalls con una piccola variazione per il recupero dinamico dei numeri di syscall. Utilizza inoltre una tecnica che non ho visto menzionata per bypassare la scansione della memoria di Windows Defender. Il progetto implementa un classico PoC di injection di processi usando la libreria. Sono disponibili anche funzioni comode per la crittografia.

Per compilare l'esempio usa -DNULLGATE_BUILD_SAMPLE=ON.
Se hai compilato nullgate direttamente, sarà accessibile in <build_dir>/sample.exe; se lo hai compilato come dipendenza, in <build_dir>/_deps/nullgate-build/sample.exe.
Su Windows, poiché le destinazioni di build sono strane, probabilmente si troverà nelle stesse directory di base delle posizioni degli esempi, ma probabilmente annidato un po' più in profondità.
Prende come argomento un PID nel quale vuoi iniettare lo shellcode.
[!WARNING] Se stai usando Linux devi avere installato il cross-compilatore mingw. Su Arch, per esempio, puoi fare
pacman -S mingw-w64-gcc. Poi usa l'opzione-DNULLGATE_CROSSCOMPILE=ONper impostare mingw come compilatore predefinito.
[!TIP] È anche consigliato eseguire lo strip del binario risultante per ridurre la possibilità di rilevamento.
git clone https://github.com/0xsch1zo/NullGate
cd NullGate
cmake . -B build -DNULLGATE_BUILD_SAMPLE=ON
cmake --build build/
Può essere compilato usando il flag -DNULLGATE_DEPRECATED_HASHER.
CMake FetchContent è supportato. Ecco un esempio di un semplice CMakeLists.txt:
cmake_minimum_required(VERSION 3.25)
include(FetchContent)
FetchContent_Declare(nullgate
GIT_REPOSITORY https://github.com/0xsch1zo/NullGate
GIT_TAG 1.2.0
)
FetchContent_MakeAvailable(nullgate)
project(test)
add_executable(test
main.cpp
)
target_link_libraries(test
PRIVATE nullgate
)
Il linking è fatto staticamente, quindi non devi preoccuparti che i simboli siano visibili.
[!NOTE] Gli esempi seguenti useranno
namespace ng = nullgate
L'uso è abbastanza semplice, ecco uno snippet che dimostra la funzionalità principale:
ng::syscalls syscalls;
typedef NTSTATUS NTAPI NtAllocateVirtualMemory(
_In_ HANDLE ProcessHandle,
_Inout_ _At_(*BaseAddress,
_Readable_bytes_(*RegionSize) _Writable_bytes_(*RegionSize)
_Post_readable_byte_size_(*RegionSize)) PVOID *BaseAddress,
_In_ ULONG_PTR ZeroBits, _Inout_ PSIZE_T RegionSize,
_In_ ULONG AllocationType, _In_ ULONG PageProtection);
NTSTATUS status = syscalls.SCall<NtAllocateVirtualMemory>(
ng::obfuscation::fnv1Const("NtAllocateVirtualMemory"), processHandle,
&buf, 0, ®ionSize, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
C'è type-safety integrata. Devi solo fornire la definizione della funzione NT che vuoi chiamare! Puoi ottenerla facilmente da ntdoc. Questo è il metodo consigliato per usare la libreria.
Per chi non ama la magia nera dei template C++ o simili, l'interfaccia precedente è ancora disponibile:
NTSTATUS status = syscalls.Call(ng::obfuscation::fnv1Const("NtAllocateVirtualMemory"),
processHandle, (PVOID)&buf, (ULONG_PTR)0, ®ionSize,
(ULONG)(MEM_RESERVE | MEM_COMMIT), (ULONG)PAGE_EXECUTE_READWRITE);
Usando questa interfaccia devi fare il cast degli argomenti al tipo giusto; non farlo potrebbe causare problemi.
constexpr uint64_t hash = ng::obfuscation::fnv1Const("NtAllocateVirtualMemory");
Il metodo fnv1Const dimostrato in precedenza porta le gioie del C++ moderno nel mondo del maldev. È una funzione consteval, quindi è garantito che venga valutata in fase di compilazione, sostituendo il nome di funzione leggibile con un hash fnv1.
Esiste anche un equivalente a runtime chiamato fnv1Runtime, ma ovviamente non aggiunge il vantaggio di offuscare i nomi delle nostre funzioni. Viene usato dall'implementazione per verificare quale funzione all'interno di ntdll ottenere il numero di syscall.
Ci sono tre routine per la "crittografia" xor:
constexpr ng::obfuscation::ConstData xored = ng::obfuscation::xorConst("some string");
std::cout << xored.string();
Possibile output:
<Y:-E&X0
Questa routine, analogamente alla funzione fnv1Const, viene valutata in fase di compilazione, quindi "some string" non apparirà mai nel binario, perché la stringa verrà memorizzata nella sua forma crittografata.
Ma ehi, dobbiamo effettivamente usare quei dati! ConstData è un sottile wrapper attorno a byte grezzi di dimensione costante.
Ha due metodi: raw() e string().
raw() restituisce un std::vector<unsigned char> e string, come suggerisce il nome, restituisce un std::string.
[!NOTE] Se devi costruire
ConstDatadirettamente con un literal stringa, usastd::to_arrayper costruire un array intermedio passato aConstData. Nota comunque che con questo approccioConstDatamemorizzerà anche il carattere null aggiuntivo del literal.
Ovviamente abbiamo bisogno di un modo per decrittare e usare questi dati, che è la prossima funzione che tratteremo.
xorRuntime è la seconda routine disponibile.
Come suggerisce il nome, è l'equivalente a runtime di xorConst.
Nota che non deve essere usata solo per la decrittazione: funziona in entrambe le direzioni, anche se usarla per la crittografia non offre i vantaggi di offuscare la stringa in fase di compilazione.
ng::obfuscation::ConstData xored = ng::obfuscation::xorConst("some string");
ng::obfuscation::ConstData data = ng::obfuscation::xorRuntime(xored);
assert(data.string() == "some string");
std::string dynamicString = "some data of dynamic size";
auto dynamicData = ng::obfuscation::DynamicData(dynamicString);
auto xoredDynamic = ng::obfuscation::xorRuntime(dynamicData);
auto unxoredDynamic = ng::obfuscation::xorRuntime(xoredDynamic);
assert(unxoredDynamic.string() == dynamicString);
DynamicData ha gli stessi metodi di ConstData con alcuni costruttori aggiuntivi per l'interoperabilità e, come suggerisce il nome, può avere una dimensione non nota in fase di compilazione.
xorRuntime accetta sia DynamicData che ConstData.
Mentre potremmo sempre decrittare a runtime chiamando prima xorConst e passando il risultato a xorRuntime, è un po' noioso.
Ecco la soluzione a questo problema, la ciliegina sulla torta: xorRuntimeDecrypted è una funzione comoda per i literal di stringa su cui non serve operare mentre sono nello stato crittografato.
Crittografa il literal di stringa in fase di compilazione e poi lo decritta a runtime in un'unica chiamata. Non è fantastico!.
ng::obfuscation::ConstData text = ng::obfuscation::xorRuntimeDecrypted<"some string">();
assert(data.string() == "some string");
Qualcuno potrebbe dire: tutto è fantastico, ma dov'è la chiave? In nullgate 1.2 la chiave viene generata casualmente a ogni nuova build effettuata! (cioè quando viene eseguito il comando cmake) Questo riduce ulteriormente la possibilità di essere rilevati tramite firme.
Il cuore del problema è che quando chiamiamo NtCreateRemoteThreadEx o NtCreateProcess, viene attivata una scansione della memoria e il nostro payload msfvenom, decisamente ricco di firme, viene rilevato.
Una soluzione nota è: prima, quando si chiama NtAllocateVirtualMemory, impostare i permessi della pagina come PAGE_NOACCESS, poi creare il thread in stato sospeso.
Quando Windows Defender scansionerà la memoria del nostro processo, non ci riuscirà.
Possiamo quindi riprendere l'esecuzione del thread con NtResumeThread.
Questo funziona, ma cosa succederebbe se venisse usata una soluzione di sicurezza più competente? Cosa farebbe?
Userebbe ovviamente VirtualProtect per cambiare i permessi della nostra pagina e rilevare msfvenom.
Per bypassare anche questo ho cambiato un po' la strategia. Invece di impostare la pagina come PAGE_NOACCESS, durante la nostra prima scrittura nella memoria del processo possiamo semplicemente inserire dei dati spazzatura nel processo (Sì, è necessario, o sono solo troppo stupido per trovare un modo di farlo funzionare senza).
Poi creiamo un thread in stato sospeso.
Dopo di che scriviamo nel processo il nostro shellcode desiderato e infine riprendiamo il thread usando NtResumeThread.
Con questa tecnica non dobbiamo preoccuparci che la nostra memoria venga acceduta dopo la chiamata a NtCreateThreadEx, perché lì non c'è nulla.
Solo dopo, lo shellcode decrittato viene scritto e l'esecuzione viene ripresa.
Questa libreria è stata creata solo per scopi accademici. Gli autori non sono responsabili di ciò che viene dato a questa libreria e pertanto siamo esenti da qualsiasi responsabilità derivante da un suo uso improprio.