
간접 syscall 사용을 용이하게 하는 라이브러리. PoC로서 상당히 흥미로운 AV/EDR 우회 기법.
이 프로젝트는 동적 syscall 번호 검색을 위한 약간의 변형을 가미한 FreshyCalls 방식과 함께 간접 syscall을 사용하여 NTAPI 함수를 편리하고 현대적인 방식으로 사용할 수 있게 해줍니다. 또한 Windows Defender의 메모리 검색을 우회하기 위해 언급된 적 없는 기술을 사용합니다. 이 프로젝트는 라이브러리를 사용하는 고전적인 PoC 프로세스 인젝터를 구현합니다. 암호화를 위한 편리한 함수도 제공됩니다.

샘플을 빌드하려면 -DNULLGATE_BUILD_SAMPLE=ON을 사용하세요. nullgate를 직접 빌드했다면 <build_dir>/sample.exe에서, 의존성으로 빌드했다면 <build_dir>/_deps/nullgate-build/sample.exe에서 접근할 수 있습니다. Windows에서는 빌드 대상 위치가 이상하기 때문에, 샘플 위치와 동일한 기본 디렉토리에 있겠지만 아마도 훨씬 더 중첩되어 있을 것입니다. 셸코드를 주입하려는 PID를 인자로 받습니다.
[!WARNING] Linux를 사용하고 있다면 mingw 크로스 컴파일러가 설치되어 있어야 합니다. 예를 들어 Arch에서는
pacman -S mingw-w64-gcc를 실행하면 됩니다. 그런 다음-DNULLGATE_CROSSCOMPILE=ON옵션을 사용하여 mingw를 기본 컴파일러로 설정하세요.
[!TIP] 또한 탐지 가능성을 줄이기 위해 결과 바이너리를 스트립하는 것이 좋습니다.
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 안의 어떤 함수에서 syscall 번호를 가져올지 확인하는 데 사용합니다.
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 명령이 실행될 때마다)마다 무작위로 생성됩니다! 이로 인해 시그니처가 탐지될 가능성이 훨씬 더 줄어듭니다.
핵심 문제는 NtCreateRemoteThreadEx 또는 NtCreateProcess를 호출할 때 메모리 검사가 트리거되고, 그로 인해 시그니처가 엄청나게 박힌 msfvenom 페이로드가 탐지된다는 것입니다.
알려진 해결책은 먼저 NtAllocateVirtualMemory를 호출할 때 페이지 권한을 PAGE_NOACCESS로 설정한 다음, 스레드를 일시 중단된 상태로 생성하는 것입니다. Windows Defender가 우리 프로세스의 메모리를 검사하려고 할 때 그렇게 할 수 없게 됩니다. 그런 다음 NtResumeThread로 스레드 실행을 재개할 수 있습니다. 이 방법은 작동하지만, 더 유능한 보안 솔루션이 사용되고 있다면 어떻게 될까요? 물론 VirtualProtect를 사용하여 페이지 권한을 변경한 다음 msfvenom을 탐지할 것입니다. 이를 우회하기 위해 전략을 약간 바꿨습니다. 페이지를 PAGE_NOACCESS로 설정하는 대신, 프로세스 메모리에 첫 번째 쓰기를 할 때 그냥 정크 데이터를 넣을 수 있습니다(네, 필수입니다. 아니면 제가 이것 없이 작동하게 만들 방법을 찾지 못한 것일 수도 있습니다). 그런 다음 스레드를 일시 중단 상태로 만듭니다. 그 후 프로세스에 원하는 셸코드를 쓰고 마지막으로 NtResumeThread를 사용하여 스레드를 재개합니다. 이 기법을 사용하면 NtCreateThreadEx 호출 이후 메모리에 아무것도 없기 때문에 메모리가 접근될 걱정을 할 필요가 없습니다. 그 후에야 복호화된 셸코드가 쓰이고 실행이 재개됩니다.
이 라이브러리는 학술 목적으로만 제작되었습니다. 저자는 이 라이브러리에 제공되는 내용에 대해 책임을 지지 않으며, 따라서 이 라이브러리의 오용으로 인해 발생하는 모든 책임에서 면제됩니다.