本项目实现了一种便捷且现代的方式来使用 NTAPI 函数,通过间接系统调用(indirect syscalls),并结合了 FreshyCalls 方法,在动态获取系统调用编号方面做了一点改动。 它还使用了一种我尚未见他人提及的技术来绕过 Windows Defender 的内存扫描。 该项目使用该库实现了一个经典的 PoC 进程注入器。 同时提供了一些便捷的加密函数。

要构建示例,请使用 -DNULLGATE_BUILD_SAMPLE=ON。
如果你直接构建 nullgate,产物会位于 <build_dir>/sample.exe;如果是作为依赖构建,则在 <build_dir>/_deps/nullgate-build/sample.exe。
在 Windows 上,由于构建输出路径比较奇怪,它大概会位于样例所在的基础目录下,但嵌套层级可能会更深。
它接受一个你想注入 shellcode 的进程 PID 作为参数。
[!WARNING] 如果你使用的是 Linux,则需要安装 mingw 交叉编译器。例如在 Arch 上可以执行
pacman -S mingw-w64-gcc。然后使用-DNULLGATE_CROSSCOMPILE=ON选项将 mingw 设为默认编译器。
[!TIP] 还建议对生成的二进制文件进行 strip,以降低被检测的可能性。
git clone https://github.com/0xsch1zo/NullGate
cd NullGate
cmake . -B build -DNULLGATE_BUILD_SAMPLE=ON
cmake --build build/
它可以通过 -DNULLGATE_DEPRECATED_HASHER 标志进行构建。
支持 CMake FetchContent。以下是一个简单 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
)
链接是静态完成的,因此你无需担心符号可见性问题。
[!NOTE] 以下示例将使用
namespace ng = nullgate
用法非常直接,下面是一段演示主要功能的代码片段:
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);
内置了类型安全。 你只需提供想要调用的 NT 函数的定义即可!你可以轻松地从 ntdoc 获取这些定义。 这是使用该库的推荐方式。
对于不喜欢 C++ 模板黑魔法之类东西的人,之前的接口仍然可用:
NTSTATUS status = syscalls.Call(ng::obfuscation::fnv1Const("NtAllocateVirtualMemory"),
processHandle, (PVOID)&buf, (ULONG_PTR)0, ®ionSize,
(ULONG)(MEM_RESERVE | MEM_COMMIT), (ULONG)PAGE_EXECUTE_READWRITE);
使用这个接口时,你必须将参数转换为正确的类型,否则可能会导致问题。
constexpr uint64_t hash = ng::obfuscation::fnv1Const("NtAllocateVirtualMemory");
前面演示的 fnv1Const 方法将现代 C++ 的乐趣带入了 maldev 世界。它是一个 consteval 函数,因此保证会在编译期求值,将可读的函数名替换为 fnv1 哈希。
还有一个运行时等价版本 fnv1Runtime,但当然它没有混淆函数名的好处。实现内部使用它来确定要获取 ntdll 中哪个函数的系统调用编号。
有三个用于 XOR“加密”的例程:
constexpr ng::obfuscation::ConstData xored = ng::obfuscation::xorConst("some string");
std::cout << xored.string();
可能的输出:
<Y:-E&X0
与 fnv1Const 函数类似,该例程在编译期求值,因此 "some string" 永远不会出现在二进制中,因为该字符串会以加密形式存储。
但嘿,我们得真正用上这些数据!ConstData 是一个围绕原始字节的轻量封装,其大小是恒定的。
它有两个方法:raw() 和 string()。
raw() 返回 std::vector<unsigned char>,而 string 顾名思义返回 std::string。
[!NOTE] 如果你需要用字符串字面量直接构造
ConstData,请使用std::to_array构造一个中间数组传给ConstData。 但请注意,这种方式下ConstData会额外存储字面量的结尾空字符。
当然,我们需要一种解密并使用它的方法,这就是接下来要介绍的函数。
xorRuntime 是可用的第二个例程。
顾名思义,它是 xorConst 的运行时等价版本。
请注意,它不仅可用于解密,而是双向可用;不过用它加密并不能获得在编译期混淆字符串的好处。
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 拥有与 ConstData 相同的方法,并额外增加了一些用于互操作的构造函数;顾名思义,它的大小可以在编译期未知。
xorRuntime 同时接受 DynamicData 和 ConstData。
虽然我们总是可以先调用 xorConst,再把结果传给 xorRuntime 在运行时解密,但那有点繁琐。
这个问题的解决方案来了,锦上添花的 xorRuntimeDecrypted 就是一个方便的函数,适用于那些不需要在加密状态下操作的字符串字面量。
它在编译期加密字符串字面量,然后在运行时解密,一次调用全部搞定。是不是很酷!。
ng::obfuscation::ConstData text = ng::obfuscation::xorRuntimeDecrypted<"some string">();
assert(data.string() == "some string");
现在可能有人会说,一切都很好,但密钥在哪里?在 nullgate 1.2 中,每次全新构建(即每次运行 cmake 命令)时都会随机生成密钥! 这进一步降低了被特征化检测(signatured)的几率。
问题的核心在于,当我们调用 NtCreateRemoteThreadEx 或 NtCreateProcess 时,会触发内存扫描,然后我们那个特征明显到爆的 msfvenom 载荷就会被检测出来。
一个已知的解决方案是:在调用 NtAllocateVirtualMemory 时先将页面权限设置为 PAGE_NOACCESS,然后以挂起状态创建线程。
当 Windows Defender 扫描我们进程的内存时,它将无法扫描成功。
然后我们可以用 NtResumeThread 恢复线程执行。
这种方法有效,但如果遇到更强大的安全解决方案呢?它会怎么做?
它当然会直接用 VirtualProtect 修改我们页面的权限,然后检测到 msfvenom。
为了绕过这一点,我稍微改变了策略。我们不是将页面设置为 PAGE_NOACCESS,而是在第一次向进程内存写入时,仅将一些垃圾数据放入进程(是的,这是必需的,或者只是我太笨,找不到不用这一步就能让它工作的方法)。
然后我们创建一个挂起状态的线程。
之后,我们向进程写入所需的 shellcode,最后使用 NtResumeThread 恢复线程。
使用这一技术,我们不必担心调用 NtCreateThreadEx 之后内存被访问,因为那里什么也没有。
只有在事后,解密后的 shellcode 被写入,执行才恢复。
此库仅用于学术目的。作者不对用于此库的任何内容负责,因此我们免于承担因滥用此库而产生的任何责任。