
Biblioteca que facilita o uso de syscalls indiretas. Bypass de AV/EDR bastante interessante como PoC.
Este projeto implementa uma maneira confortável e moderna de usar as funções NTAPI usando syscalls indiretos, aliado ao método FreshyCalls com um pequeno toque para recuperação dinâmica de números de syscall. Também usa uma técnica que eu não vi ser mencionada para contornar a varredura de memória do Windows Defender. O projeto implementa um injetor de processos PoC clássico usando a biblioteca. Funções convenientes para criptografia também estão disponíveis.

Para compilar o exemplo use -DNULLGATE_BUILD_SAMPLE=ON.
Se você compilou o nullgate diretamente, ele estará acessível em <build_dir>/sample.exe; se você o compilou como dependência, em <build_dir>/_deps/nullgate-build/sample.exe.
No Windows, como os destinos de build são estranhos, provavelmente estará nos mesmos diretórios base dos locais dos exemplos, mas provavelmente aninhado um pouco mais.
Ele recebe um PID que você deseja injetar shellcode como argumento.
[!WARNING] Se você estiver usando Linux, precisa ter o compilador cruzado mingw instalado. No Arch, por exemplo, você pode fazer
pacman -S mingw-w64-gcc. Em seguida, use a opção-DNULLGATE_CROSSCOMPILE=ONpara definir o mingw como compilador padrão.
[!TIP] Também é recomendado fazer strip do binário resultante para diminuir a possibilidade de detecção
git clone https://github.com/0xsch1zo/NullGate
cd NullGate
cmake . -B build -DNULLGATE_BUILD_SAMPLE=ON
cmake --build build/
Ele pode ser construído usando a flag -DNULLGATE_DEPRECATED_HASHER.
CMake FetchContent é suportado. Aqui está um exemplo de um CMakeLists.txt simples:
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
)
A linkagem é feita estaticamente, então você não precisa se preocupar com símbolos visíveis.
[!NOTE] Os exemplos a seguir usarão
namespace ng = nullgate
O uso é bastante direto. Aqui está um trecho demonstrando a funcionalidade 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);
Há segurança de tipos embutida. Você só precisa fornecer a definição da função nt que deseja chamar! Você pode obtê-la facilmente no ntdoc. Esta é a maneira recomendada de usar a lib.
Para pessoas que não gostam da magia negra de templates de C++ ou algo assim, a interface anterior ainda está disponível:
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 interface, você precisa converter os argumentos para o tipo correto; não fazer isso pode causar problemas.
constexpr uint64_t hash = ng::obfuscation::fnv1Const("NtAllocateVirtualMemory");
O método fnv1Const demonstrado anteriormente traz as alegrias do C++ moderno para o mundo do maldev. É uma função consteval, portanto é garantido que será avaliada em tempo de compilação, substituindo o nome legível da função por um hash fnv1.
Há também um equivalente em tempo de execução chamado fnv1Runtime, mas, é claro, ele não adiciona o benefício de ter nossos nomes de função ofuscados. Ele é usado pela implementação para verificar de qual função dentro da ntdll obter o número do syscall.
Há três rotinas para "criptografia" xor:
constexpr ng::obfuscation::ConstData xored = ng::obfuscation::xorConst("some string");
std::cout << xored.string();
Saída possível:
<Y:-E&X0
Esta rotina, de forma semelhante à função fnv1Const, é avaliada em tempo de compilação, portanto "some string" nunca aparecerá no binário, pois a string será armazenada em sua forma criptografada.
Mas ei, precisamos realmente usar esses dados! ConstData é um wrapper fino em torno de bytes brutos de tamanho constante.
Ele tem dois métodos: raw() e string().
raw() retorna um std::vector<unsigned char> e string, como o nome sugere, retorna um std::string.
[!NOTE] Se você precisar construir
ConstDatadiretamente com um literal de string, usestd::to_arraypara construir um array intermediário passado paraConstData. Observe, no entanto, que com essa abordagemConstDataarmazenará o caractere nulo adicional do literal.
É claro que precisamos de uma maneira de descriptografar isso e usá-lo, que é a próxima função que abordaremos.
xorRuntime é a segunda rotina disponível.
Como o nome sugere, é o equivalente em tempo de execução de xorConst.
Observe que ele não precisa ser usado apenas para descriptografia; funciona nos dois sentidos, embora usá-lo para criptografia não traga os benefícios de ofuscar a string em tempo de compilação.
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 tem os mesmos métodos que ConstData, com alguns construtores adicionados para interoperabilidade e, como o nome sugere, pode ter tamanho desconhecido em tempo de compilação.
xorRuntime aceita tanto DynamicData quanto ConstData.
Embora sempre pudéssemos descriptografar em tempo de execução chamando primeiro xorConst e passando o resultado para xorRuntime, isso é meio tedioso.
Aqui vem a solução para esse problema, a cereja do bolo, que é xorRuntimeDecrypted; é uma função prática para literais de string que não precisam ser operados enquanto estão no estado criptografado.
Ela criptografa o literal de string em tempo de compilação e depois o descriptografa em tempo de execução, tudo em uma única chamada. Não é legal!
ng::obfuscation::ConstData text = ng::obfuscation::xorRuntimeDecrypted<"some string">();
assert(data.string() == "some string");
Agora alguém pode dizer: tudo é ótimo, mas onde está a chave? No nullgate 1.2, a chave é gerada aleatoriamente a cada novo build feito! (ou seja, quando o comando cmake é executado) Isso reduz ainda mais a chance de ser detectado por assinaturas.
O cerne da questão é que, quando chamamos NtCreateRemoteThreadEx ou NtCreateProcess, uma varredura de memória é acionada e nosso payload msfvenom, todo cheio de assinaturas, é detectado.
Uma solução conhecida é, primeiro, ao chamar NtAllocateVirtualMemory, definir as permissões da página como PAGE_NOACCESS e, em seguida, criar a thread em estado suspenso.
Quando o Windows Defender verificar a memória do nosso processo, ele não conseguirá fazer isso.
Podemos então retomar a execução da nossa thread com NtResumeThread.
Isso funciona, mas e se uma solução de segurança mais competente estiver sendo usada? O que ela faria?
Ela, é claro, simplesmente usaria VirtualProtect para alterar as permissões da nossa página e detectar o msfvenom.
Para contornar isso, mudei um pouco a estratégia. Em vez de definir a página como PAGE_NOACCESS, durante nossa primeira escrita na memória do processo, podemos simplesmente colocar alguns dados lixo no processo (Sim, isso é necessário, ou eu sou burro demais para encontrar uma maneira de fazer funcionar sem isso).
Então criamos uma thread em estado suspenso.
Depois disso, escrevemos no processo o shellcode desejado e, finalmente, retomamos a thread usando NtResumeThread.
Com essa técnica, não precisamos nos preocupar com nossa memória sendo acessada após a chamada a NtCreateThreadEx, porque não há nada lá.
Somente depois disso o shellcode descriptografado é escrito e a execução é retomada.
Esta biblioteca foi feita apenas para fins acadêmicos. Os autores não são responsáveis pelo que é dado a esta biblioteca e, portanto, estamos isentos de qualquer responsabilidade decorrente do mau uso dela.