
間接システムコールの利用を容易にするライブラリ。PoCとしてのAV/EDRバイパスとしてかなり興味深い。
このプロジェクトは、間接システムコールを使用してNTAPI関数を快適かつ現代的に利用する方法を実装しており、動的なシステムコール番号の取得には FreshyCalls メソッドを少しひねった形で組み合わせています。 また、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内のどの関数からシステムコール番号を取得するかを確認するために実装で使用されます。
xor「暗号化」には3つのルーチンがあります:
constexpr ng::obfuscation::ConstData xored = ng::obfuscation::xorConst("some string");
std::cout << xored.string();
出力例:
<Y:-E&X0
このルーチンは fnv1Const 関数と同様にコンパイル時に評価されるため、"some string" は暗号化された形式で保存され、バイナリ内に現れることはありません。
しかし、そのデータを実際に使わなければなりません!ConstData は、一定のサイズを持つ生のバイト列の薄いラッパーです。
raw() と string() の2つのメソッドがあります。
raw() は std::vector<unsigned char> を返し、string は名前が示すとおり std::string を返します。
[!NOTE]
ConstDataを文字列リテラルで直接構築する必要がある場合は、std::to_arrayを使って中間配列を構築し、それをConstDataに渡してください。 ただし、この方法ではConstDataにリテラルの余分なヌル文字も格納されることに注意してください。
もちろん、これを復号化して使用する方法が必要です。次に説明する関数がそれです。
xorRuntime は利用可能な2番目のルーチンです。
名前が示すとおり、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 です。これは、暗号化された状態で操作する必要のない文字列リテラルのための便利な関数です。
コンパイル時に文字列リテラルを暗号化し、実行時に復号化する処理を1回の呼び出しで行います。すごいと思いませんか!
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 の呼び出し後にメモリにアクセスされることを心配する必要はありません。そこには何もないからです。
実際に復号化されたシェルコードが書き込まれ、実行が再開されるのは、その後のことです。
このライブラリは学術目的のみで作成されました。著者はこのライブラリに与えられたものについて責任を負いません。したがって、誤用から生じるいかなる責任も負いません。