
Bibliothek, die die Verwendung indirekter Syscalls erleichtert. Ziemlich interessanter AV/EDR-Bypass als PoC.
Dieses Projekt implementiert eine komfortable und moderne Möglichkeit, die NTAPI-Funktionen mithilfe indirekter Syscalls zu verwenden, gekoppelt mit der FreshyCalls-Methode mit einer kleinen Abwandlung zur dynamischen Syscall-Nummernabrufung. Es verwendet außerdem eine Technik, von der ich nicht gesehen habe, dass sie erwähnt wird, um den Speicherscan von Windows Defender zu umgehen. Das Projekt implementiert einen klassischen PoC-Prozess-Injektor, der die Bibliothek verwendet. Praktische Funktionen zur Verschlüsselung sind ebenfalls verfügbar.

Zum Erstellen des Beispiels verwende -DNULLGATE_BUILD_SAMPLE=ON.
Wenn du nullgate direkt gebaut hast, ist es unter <build_dir>/sample.exe verfügbar; wenn du es als Abhängigkeit gebaut hast, unter <build_dir>/_deps/nullgate-build/sample.exe.
Unter Windows sind die Build-Ziele seltsam, daher wird es sich wahrscheinlich in denselben Basisverzeichnissen wie die Speicherorte der Beispiele befinden, aber wahrscheinlich noch ein paar Ebenen tiefer verschachtelt.
Es benötigt als Argument eine PID, in die du Shellcode injizieren möchtest.
[!WARNING] Wenn du Linux verwendest, musst du den MinGW-Cross-Compiler installiert haben. Auf Arch kannst du zum Beispiel
pacman -S mingw-w64-gccausführen. Verwende dann die Option-DNULLGATE_CROSSCOMPILE=ON, um mingw als Standard-Compiler festzulegen.
[!TIP] Es wird außerdem empfohlen, die resultierende Binärdatei zu strippen, um die Erkennungswahrscheinlichkeit zu verringern.
git clone https://github.com/0xsch1zo/NullGate
cd NullGate
cmake . -B build -DNULLGATE_BUILD_SAMPLE=ON
cmake --build build/
Es kann mit dem Flag -DNULLGATE_DEPRECATED_HASHER gebaut werden.
CMake FetchContent wird unterstützt. Hier ist ein Beispiel für eine einfache 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
)
Die Verknüpfung erfolgt statisch, sodass du dir keine Sorgen um sichtbare Symbole machen musst.
[!NOTE] Die folgenden Beispiele verwenden
namespace ng = nullgate
Die Verwendung ist ziemlich einfach. Hier ist ein Codeausschnitt, der die Hauptfunktionalität demonstriert:
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);
Es gibt eine eingebaute Typsicherheit. Du musst nur die Definition der NT-Funktion bereitstellen, die du aufrufen möchtest! Du kannst sie leicht von ntdoc bekommen. Dies ist der empfohlene Weg, die Bibliothek zu verwenden.
Für Leute, die die C++-Template-Schwarze Magie oder so etwas nicht mögen, ist die bisherige Schnittstelle weiterhin verfügbar:
NTSTATUS status = syscalls.Call(ng::obfuscation::fnv1Const("NtAllocateVirtualMemory"),
processHandle, (PVOID)&buf, (ULONG_PTR)0, ®ionSize,
(ULONG)(MEM_RESERVE | MEM_COMMIT), (ULONG)PAGE_EXECUTE_READWRITE);
Bei dieser Schnittstelle musst du die Argumente in den richtigen Typ casten. Wenn du das nicht tust, kann das zu Problemen führen.
constexpr uint64_t hash = ng::obfuscation::fnv1Const("NtAllocateVirtualMemory");
Die zuvor gezeigte fnv1Const-Methode bringt die Freuden des modernen C++ in die Maldev-Welt. Sie ist eine consteval-Funktion, sodass garantiert ist, dass sie zur Kompilierzeit ausgewertet wird und den lesbaren Funktionsnamen durch einen fnv1-Hash ersetzt.
Es gibt auch ein Laufzeit-Äquivalent namens fnv1Runtime, aber natürlich bietet es nicht den Vorteil, dass unsere Funktionsnamen verschleiert werden. Es wird von der Implementierung verwendet, um zu prüfen, welche Funktion innerhalb von ntdll die Syscall-Nummer liefert.
Es gibt drei Routinen für die xor-„Verschlüsselung“:
constexpr ng::obfuscation::ConstData xored = ng::obfuscation::xorConst("some string");
std::cout << xored.string();
Mögliche Ausgabe:
<Y:-E&X0
Diese Routine wird ähnlich wie die fnv1Const-Funktion zur Kompilierzeit ausgewertet, sodass "some string" niemals in der Binärdatei erscheint, weil der String in seiner verschlüsselten Form gespeichert wird.
Aber hey, wir müssen diese Daten tatsächlich nutzen! ConstData ist ein dünner Wrapper um rohe Bytes mit konstanter Größe.
Es hat zwei Methoden: raw() und string().
raw() gibt ein std::vector<unsigned char> zurück und string gibt, wie der Name schon sagt, ein std::string zurück.
[!NOTE] Falls du
ConstDatadirekt mit einem String-Literal konstruieren musst, verwendestd::to_array, um ein Zwischenarray zu erstellen, das anConstDataübergeben wird. Beachte jedoch, dassConstDatabei diesem Ansatz das zusätzliche Nullzeichen des Literals speichert.
Natürlich brauchen wir eine Möglichkeit, dies zu entschlüsseln und zu verwenden, was die nächste Funktion ist, die wir behandeln werden.
xorRuntime ist die zweite verfügbare Routine.
Wie der Name schon sagt, ist es das Laufzeit-Äquivalent von xorConst.
Bitte beachte, dass es nicht nur zur Entschlüsselung verwendet werden muss; es funktioniert in beide Richtungen, obwohl die Verwendung zur Verschlüsselung nicht den Vorteil bietet, den String zur Kompilierzeit zu verschleiern.
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 hat dieselben Methoden wie ConstData, mit einigen zusätzlichen Konstruktoren für die Interoperabilität, und kann, wie der Name schon sagt, eine Größe haben, die zur Kompilierzeit nicht bekannt ist.
xorRuntime akzeptiert sowohl DynamicData als auch ConstData.
Obwohl wir immer zur Laufzeit entschlüsseln könnten, indem wir zuerst xorConst aufrufen und das Ergebnis an xorRuntime übergeben, ist das ziemlich mühsam.
Hier kommt die Lösung für dieses Problem, das i-Tüpfelchen, nämlich xorRuntimeDecrypted. Es ist eine praktische Funktion für String-Literale, die nicht bearbeitet werden müssen, solange sie sich im verschlüsselten Zustand befinden.
Es verschlüsselt das String-Literal zur Kompilierzeit und entschlüsselt es dann zur Laufzeit – alles in einem Aufruf. Ist das nicht cool!
ng::obfuscation::ConstData text = ng::obfuscation::xorRuntimeDecrypted<"some string">();
assert(data.string() == "some string");
Jetzt könnte jemand sagen: Alles ist großartig, aber wo ist der Schlüssel? In nullgate 1.2 wird der Schlüssel bei jedem frischen Build zufällig generiert! (d. h. wenn der cmake-Befehl ausgeführt wird) Das verringert die Chance, per Signatur erkannt zu werden, noch weiter.
Der Kern des Problems ist, dass beim Aufruf von NtCreateRemoteThreadEx oder NtCreateProcess ein Speicherscan ausgelöst wird und unser bis zum Anschlag signiertes msfvenom-Payload erkannt wird.
Eine bekannte Lösung besteht darin, beim Aufruf von NtAllocateVirtualMemory zunächst die Seitenberechtigungen auf PAGE_NOACCESS zu setzen und den Thread dann in einem angehaltenen Zustand zu erstellen.
Wenn Windows Defender den Speicher unseres Prozesses scannt, wird ihm das nicht gelingen.
Wir können dann die Ausführung unseres Threads mit NtResumeThread fortsetzen.
Das funktioniert, aber was, wenn eine kompetentere Sicherheitslösung verwendet wird? Was würde sie tun?
Sie würde natürlich einfach VirtualProtect verwenden, um die Berechtigungen unserer Seite zu ändern und msfvenom zu erkennen.
Um das zu umgehen, habe ich die Strategie etwas geändert. Anstatt die Seite auf PAGE_NOACCESS zu setzen, können wir beim ersten Schreiben in den Speicher des Prozesses einfach ein paar Junk-Daten in den Prozess schreiben (Ja, das ist erforderlich, oder ich bin einfach zu dumm, einen Weg zu finden, es ohne das zum Laufen zu bringen).
Dann erstellen wir einen Thread im angehaltenen Zustand.
Danach schreiben wir unseren gewünschten Shellcode in den Prozess und setzen schließlich den Thread mit NtResumeThread fort.
Mit dieser Technik müssen wir uns keine Sorgen machen, dass nach dem Aufruf von NtCreateThreadEx auf unseren Speicher zugegriffen wird, weil dort nichts drin ist.
Erst danach wird der entschlüsselte Shellcode geschrieben und die Ausführung fortgesetzt.
Diese Bibliothek wurde ausschließlich für akademische Zwecke erstellt. Die Autoren sind nicht verantwortlich für das, was mit dieser Bibliothek gemacht wird, und daher sind wir von jeglicher Haftung befreit, die aus dem Missbrauch entsteht.