
Bibliothèque qui facilite l'utilisation des syscalls indirects. Contournement AV/EDR assez intéressant en tant que PoC.
Ce projet implémente une manière confortable et moderne d'utiliser les fonctions NTAPI via des appels système indirects, couplée à la méthode FreshyCalls avec une petite variante pour la récupération dynamique des numéros d'appels système. Il utilise également une technique que je n'ai pas vu être mentionnée pour contourner l'analyse mémoire de Windows Defender. Le projet implémente un injecteur de processus PoC classique utilisant la bibliothèque. Des fonctions pratiques pour le chiffrement sont également disponibles.

Pour compiler l'exemple, utilisez -DNULLGATE_BUILD_SAMPLE=ON.
Si vous avez compilé nullgate directement, il sera accessible à <build_dir>/sample.exe, si vous l'avez compilé en tant que dépendance à <build_dir>/_deps/nullgate-build/sample.exe.
Sous Windows, comme les destinations de compilation sont étranges, il sera probablement dans les mêmes répertoires de base que les emplacements des exemples, mais probablement avec beaucoup plus de niveaux d'imbrication.
Il prend en argument un PID dans lequel vous souhaitez injecter du shellcode.
[!WARNING] Si vous utilisez Linux, vous devez avoir le compilateur croisé mingw installé. Sur Arch par exemple, vous pouvez faire
pacman -S mingw-w64-gcc. Utilisez ensuite l'option-DNULLGATE_CROSSCOMPILE=ONpour définir mingw comme compilateur par défaut.
[!TIP] Il est également recommandé de stripper le binaire résultant afin de réduire les possibilités de détection
git clone https://github.com/0xsch1zo/NullGate
cd NullGate
cmake . -B build -DNULLGATE_BUILD_SAMPLE=ON
cmake --build build/
Il peut être compilé à l'aide de l'option -DNULLGATE_DEPRECATED_HASHER.
Le CMake FetchContent est pris en charge. Voici un exemple de CMakeLists.txt simple :
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
)
La liaison est effectuée statiquement, vous n'avez donc pas à vous soucier de la visibilité des symboles.
[!NOTE] Les exemples suivants utiliseront
namespace ng = nullgate
L'utilisation est assez simple, voici un extrait illustrant la fonctionnalité 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);
La sécurité de types est intégrée. Vous avez simplement besoin de fournir la définition de la fonction NT que vous souhaitez appeler ! Vous pouvez facilement l'obtenir depuis ntdoc. C'est la manière recommandée d'utiliser la bibliothèque.
Pour ceux qui n'aiment pas la magie noire du templating C++ ou quelque chose du genre, l'interface précédente est toujours disponible :
NTSTATUS status = syscalls.Call(ng::obfuscation::fnv1Const("NtAllocateVirtualMemory"),
processHandle, (PVOID)&buf, (ULONG_PTR)0, ®ionSize,
(ULONG)(MEM_RESERVE | MEM_COMMIT), (ULONG)PAGE_EXECUTE_READWRITE);
Avec cette interface, vous devez caster les arguments vers le bon type ; ne pas le faire peut causer des problèmes.
constexpr uint64_t hash = ng::obfuscation::fnv1Const("NtAllocateVirtualMemory");
La méthode fnv1Const démontrée précédemment apporte les joies du C++ moderne au monde du maldev. C'est une fonction consteval, donc il est garanti qu'elle sera évaluée au moment de la compilation, remplaçant le nom de fonction lisible par un hash fnv1.
Il existe également un équivalent à l'exécution appelé fnv1Runtime, mais bien sûr il n'apporte pas l'avantage d'obfusquer nos noms de fonctions. Il est utilisé par l'implémentation pour vérifier quelle fonction dans ntdll doit être utilisée pour obtenir le numéro d'appel système.
Il existe trois routines pour le « chiffrement » xor :
constexpr ng::obfuscation::ConstData xored = ng::obfuscation::xorConst("some string");
std::cout << xored.string();
Sortie possible :
<Y:-E&X0
Cette routine, comme la fonction fnv1Const, est évaluée au moment de la compilation, donc "some string" n'apparaîtra jamais dans le binaire, car la chaîne sera stockée sous sa forme chiffrée.
Mais bon, nous devons réellement utiliser ces données ! ConstData est une fine enveloppe autour d'octets bruts, de taille constante.
Elle a deux méthodes : raw() et string().
raw() retourne un std::vector<unsigned char> et string, comme son nom l'indique, retourne un std::string.
[!NOTE] Si vous devez construire
ConstDatadirectement avec une chaîne littérale, utilisezstd::to_arraypour construire un tableau intermédiaire passé àConstData. Veuillez toutefois noter qu'avec cette approche,ConstDatastockera le caractère nul supplémentaire de la chaîne littérale.
Bien sûr, nous avons besoin d'un moyen de déchiffrer ces données et de les utiliser, c'est ce que couvre la fonction suivante.
xorRuntime est la deuxième routine disponible.
Comme son nom l'indique, c'est l'équivalent à l'exécution de xorConst.
Veuillez noter qu'elle ne doit pas être utilisée uniquement pour le déchiffrement : elle fonctionne dans les deux sens, bien que l'utiliser pour le chiffrement n'apporte pas les avantages d'obfusquer la chaîne au moment de la compilation.
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 possède les mêmes méthodes que ConstData, avec quelques constructeurs supplémentaires pour l'interopérabilité, et comme son nom l'indique, elle peut avoir une taille non connue au moment de la compilation.
xorRuntime accepte à la fois DynamicData et ConstData.
Bien que nous puissions toujours déchiffrer à l'exécution en appelant d'abord xorConst puis en passant le résultat à xorRuntime, c'est un peu fastidieux.
Voici la solution à ce problème, la cerise sur le gâteau : xorRuntimeDecrypted. C'est une fonction pratique pour les chaînes littérales qui n'ont pas besoin d'être manipulées tant qu'elles sont dans l'état chiffré.
Elle chiffre la chaîne littérale au moment de la compilation puis la déchiffre à l'exécution en un seul appel. N'est-ce pas cool !.
ng::obfuscation::ConstData text = ng::obfuscation::xorRuntimeDecrypted<"some string">();
assert(data.string() == "some string");
Quelqu'un pourrait dire : tout est super, mais où est la clé ? Dans nullgate 1.2, la clé est générée aléatoirement à chaque nouvelle compilation ! (c'est-à-dire à chaque exécution de la commande cmake) Cela réduit encore davantage les risques d'être détecté par signature.
Le cœur du problème est que lorsque nous appelons NtCreateRemoteThreadEx ou NtCreateProcess, une analyse mémoire est déclenchée et notre payload msfvenom, signaturé à mort, est détecté.
Une solution connue consiste d'abord, lors de l'appel à NtAllocateVirtualMemory, à définir les permissions de la page comme PAGE_NOACCESS, puis à créer le thread dans un état suspendu.
Quand Windows Defender analysera la mémoire de notre processus, il échouera.
Nous pouvons ensuite reprendre l'exécution de notre thread avec NtResumeThread.
Cela fonctionne, mais que se passerait-il si une solution de sécurité plus compétente était utilisée ? Que ferait-elle ?
Elle utiliserait bien sûr simplement VirtualProtect pour modifier les permissions de notre page et détecter msfvenom.
Pour contourner cela, j'ai un peu changé la stratégie. Au lieu de définir la page comme PAGE_NOACCESS, lors de notre première écriture dans la mémoire du processus, nous pouvons simplement mettre des données factices dans le processus (oui, c'est nécessaire, ou alors je suis trop bête pour trouver un moyen de le faire fonctionner sans cela).
Ensuite, nous créons un thread dans un état suspendu.
Après cela, nous écrivons dans le processus notre shellcode souhaité et enfin nous reprenons le thread avec NtResumeThread.
Avec cette technique, nous n'avons pas à nous soucier de l'accès à notre mémoire après l'appel à NtCreateThreadEx, car il n'y a rien dedans.
Ce n'est qu'après coup que le shellcode déchiffré est écrit et que l'exécution est reprise.
Cette bibliothèque a été créée à des fins académiques uniquement. Les auteurs ne sont pas responsables de ce qui est confié à cette bibliothèque et sont donc exonérés de toute responsabilité découlant de son utilisation abusive.