
Librería que facilita el uso de syscalls indirectas. Bypass de AV/EDR bastante interesante como PoC.
Este proyecto implementa una forma cómoda y moderna de usar las funciones NTAPI mediante syscalls indirectos, combinado con el método FreshyCalls con un pequeño giro para la recuperación dinámica de números de syscall. También utiliza una técnica que no he visto mencionada para evadir el escaneo de memoria de Windows Defender. El proyecto implementa un inyector de procesos PoC clásico usando la librería. También hay funciones convenientes para cifrado.

Para compilar el ejemplo usa -DNULLGATE_BUILD_SAMPLE=ON.
Si compilaste nullgate directamente será accesible en <build_dir>/sample.exe, si lo compilaste como dependencia estará en <build_dir>/_deps/nullgate-build/sample.exe.
En Windows, debido a que los destinos de compilación son extraños, probablemente estará en los mismos directorios base de las ubicaciones de los ejemplos, pero probablemente anidado un poco más.
Toma como argumento un PID en el que quieras inyectar shellcode.
[!WARNING] Si estás usando Linux necesitas tener instalado el compilador cruzado mingw. En Arch, por ejemplo, puedes hacer
pacman -S mingw-w64-gcc. Luego usa la opción-DNULLGATE_CROSSCOMPILE=ONpara establecer mingw como compilador predeterminado.
[!TIP] También se recomienda hacer strip del binario resultante para disminuir la posibilidad de detección.
git clone https://github.com/0xsch1zo/NullGate
cd NullGate
cmake . -B build -DNULLGATE_BUILD_SAMPLE=ON
cmake --build build/
Se puede compilar usando el flag -DNULLGATE_DEPRECATED_HASHER.
CMake FetchContent está soportado. Aquí hay un ejemplo de un CMakeLists.txt sencillo:
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
)
El enlazado se realiza estáticamente, por lo que no tienes que preocuparte de que los símbolos sean visibles.
[!NOTE] Los siguientes ejemplos usarán
namespace ng = nullgate
El uso es bastante sencillo, aquí hay un fragmento que demuestra la funcionalidad principal:
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);
Hay seguridad de tipos integrada. Solo necesitas proporcionar la definición de la función NT que quieras llamar. Puedes obtenerla fácilmente desde ntdoc. Esta es la forma recomendada de usar la librería.
Para las personas a las que no les gusta la magia negra de las plantillas de C++ o algo así, la interfaz anterior sigue 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);
Usando esta interfaz necesitas hacer cast de los argumentos al tipo correcto; no hacerlo puede causar problemas.
constexpr uint64_t hash = ng::obfuscation::fnv1Const("NtAllocateVirtualMemory");
El método fnv1Const demostrado anteriormente trae las alegrías del C++ moderno al mundo del maldev. Es una función consteval, por lo que se garantiza que se evaluará en tiempo de compilación, reemplazando el nombre de función legible por un hash fnv1.
También existe un equivalente en tiempo de ejecución llamado fnv1Runtime, pero por supuesto no añade el beneficio de tener nuestros nombres de función ofuscados. Es usado por la implementación para comprobar qué función dentro de ntdll obtener el número de syscall.
Hay tres rutinas para el "cifrado" xor:
constexpr ng::obfuscation::ConstData xored = ng::obfuscation::xorConst("some string");
std::cout << xored.string();
Posible salida:
<Y:-E&X0
Esta rutina, de manera similar a la función fnv1Const, se evalúa en tiempo de compilación, por lo que "some string" nunca aparecerá en el binario, porque la cadena se almacenará en su forma cifrada.
Pero bueno, ¡necesitamos hacer uso real de esos datos! ConstData es un envoltorio delgado alrededor de bytes crudos que tiene un tamaño constante.
Tiene dos métodos: raw() y string().
raw() devuelve un std::vector<unsigned char> y string, como su nombre indica, devuelve un std::string.
[!NOTE] Si necesitas construir
ConstDatadirectamente con un literal de cadena, usastd::to_arraypara construir un array intermedio que se pasa aConstData. Ten en cuenta, sin embargo, que con este enfoqueConstDataalmacenará el carácter nulo adicional del literal.
Por supuesto, necesitamos una forma de descifrar esto y usarlo, que es la siguiente función que cubriremos.
xorRuntime es la segunda rutina disponible.
Como su nombre indica, es el equivalente en tiempo de ejecución de xorConst.
Ten en cuenta que no solo tiene que usarse para descifrar; funciona en ambos sentidos, aunque usarlo para cifrar no tiene los beneficios de ofuscar la cadena en tiempo de compilación.
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 tiene los mismos métodos que ConstData con algunos constructores añadidos para interoperabilidad y, como su nombre indica, puede tener un tamaño no conocido en tiempo de compilación.
xorRuntime acepta tanto DynamicData como ConstData.
Aunque siempre podríamos descifrar en tiempo de ejecución llamando primero a xorConst y pasando el resultado a xorRuntime, eso es algo tedioso.
Aquí viene la solución a este problema, la guinda del pastel: xorRuntimeDecrypted. Es una función práctica para literales de cadena que no necesitan ser manipulados mientras están en estado cifrado.
Cifra el literal de cadena en tiempo de compilación y luego lo descifra en tiempo de ejecución, todo en una sola llamada. ¿No es genial?
ng::obfuscation::ConstData text = ng::obfuscation::xorRuntimeDecrypted<"some string">();
assert(data.string() == "some string");
Ahora alguien podría decir que todo es genial, pero ¿dónde está la clave? En nullgate 1.2 la clave se genera aleatoriamente en cada compilación nueva (es decir, cada vez que se ejecuta el comando cmake). Esto reduce aún más la probabilidad de ser detectado por firmas.
El núcleo del problema es que cuando llamamos a NtCreateRemoteThreadEx o NtCreateProcess, se activa un escaneo de memoria y nuestro payload de msfvenom, lleno de firmas hasta decir basta, es detectado.
Una solución conocida es, primero, al llamar a NtAllocateVirtualMemory, establecer los permisos de la página como PAGE_NOACCESS y luego crear el hilo en estado suspendido.
Cuando Windows Defender escanee la memoria de nuestro proceso, fallará al hacerlo.
Entonces podemos reanudar la ejecución de nuestro hilo con NtResumeThread.
Esto funciona, pero ¿qué pasa si se está usando una solución de seguridad más competente? ¿Qué haría?
Por supuesto, usaría VirtualProtect para cambiar los permisos de nuestra página y detectar msfvenom.
Para evadir eso cambié un poco la estrategia. En lugar de establecer la página como PAGE_NOACCESS, durante nuestra primera escritura en la memoria del proceso podemos simplemente poner algunos datos basura en el proceso (Sí, es necesario, o soy demasiado tonto para encontrar una forma de hacerlo funcionar sin esto).
Luego creamos un hilo en estado suspendido.
Después de eso, escribimos nuestro shellcode deseado en el proceso y finalmente reanudamos el hilo usando NtResumeThread.
Con esta técnica no tenemos que preocuparnos de que nuestra memoria sea accedida después de la llamada a NtCreateThreadEx porque no hay nada ahí.
Solo después de eso se escribe el shellcode descifrado y se reanuda la ejecución.
Esta librería fue creada únicamente con fines académicos. Los autores no son responsables de lo que se haga con esta librería y, por lo tanto, quedamos exentos de cualquier responsabilidad derivada de su mal uso.