Scarlet Tactics

悪用厳禁

VanHelsing v1.0 Decrypter 技術解析

1. 本文書について

本文書は、VanHelsingランサムウェアのリークされたソースコードのうち、Decrypterコンポーネント(暗号化ファイルの復号モジュール)の技術解析である。

1.1 解析対象の位置づけ

# コンポーネント 役割 本文書の対象
1 Locker ファイル暗号化・ネットワーク拡散 VanHelsing v1.0 Locker 技術解析
2 Loader ペイロードの復号・メモリ内実行 VanHelsing v1.0 Loader 技術解析
3 Decrypter 暗号化ファイルの復号 本文書
4 Brewriter ブートレコード書き換え VanHelsing v1.0 Brewriter 技術解析
5 Builder ビルド・パッケージング・C2連携 VanHelsing v1.0 Builder 技術解析

1.2 Decrypterの役割と運用上の位置づけ

Decrypterは、身代金支払い後に被害者へ提供される復号ツールである。Lockerが暗号化したファイル(.vanhelsing 拡張子)を元の状態に復元する。

RaaSの運用フローにおいて:

[被害者] 身代金支払い(Bitcoin)
    ↓
[オペレーター] 支払い確認 → Curve25519秘密鍵をアフィリエイトに提供
    ↓
[アフィリエイト] Decrypterバイナリ + 秘密鍵 を被害者に提供
    ↓
[被害者] decrypter.exe --Key <秘密鍵HEX> を実行 → ファイル復号

Decrypterの正常動作は、RaaSの「信頼性」を維持するために不可欠である。復号ツールが正しく動作しなければ、被害者は身代金を支払っても復旧できず、RaaSの評判が低下してアフィリエイトが離反する。このため、Decrypterの実装品質はRaaSの経済的持続性に直結する。

Lockerとの対称性: DecrypterはLockerの暗号化処理を正確に逆転する必要がある。Lockerが crypto_secretstream_xchacha20poly1305_push() で暗号化したデータを、Decrypterは crypto_secretstream_xchacha20poly1305_pull() で復号する。Lockerが crypto_box_seal() で封印したセッション鍵を、Decrypterは crypto_box_seal_open() で開封する。両者の暗号化パラメータ(チャンクサイズ、部分暗号化の閾値と割合)が一致していなければ、復号は失敗する。

1.3 ソースファイル構成

ファイル 行数 役割
main.cpp 102行 エントリポイント、復号対象の選定(全ドライブ/ディレクトリ/ファイル)
common.h 46行 グローバル変数宣言、X25519_PUBLIC_KEY 定義
common.cpp 247行 コマンドライン解析(--Key, --Directory, --File等)
decryption.h 22行 decryptionクラス宣言
decryption.cpp 369行 復号エンジン本体(メタデータ解析、セッション鍵復号、ストリーム復号)
diskmanagment.h 22行 diskmanagmentクラス宣言
diskmanagment.cpp 367行 ドライブ列挙、再帰的ディレクトリ探索(.vanhelsing ファイルのみ対象)

1.4 Locker暗号化フローとの対比

処理ステップ Locker(暗号化) Decrypter(復号)
鍵操作 crypto_box_seal() でセッション鍵を公開鍵で封印 crypto_box_seal_open() で秘密鍵を使って開封
メタデータ ---key---HEX---endkey---\n をファイル先頭に書き込み ファイル先頭から ---key---...---endkey--- を解析して抽出
ストリーム初期化 init_push() で暗号化ストリームを初期化 init_pull() で復号ストリームを初期化
チャンク処理 push() で1MBチャンクを暗号化 pull() で1MB+17バイトのチャンクを復号
部分暗号化(>1GB) 先頭20%を暗号化、残り80%は平文 先頭20%を復号、残り80%はそのまま出力
ファイル名 元ファイル名 + .vanhelsing .vanhelsing を除去して元ファイル名を復元
元ファイル DeleteFileW() で削除 削除しない(暗号化ファイルは残存)

参考文献

[1] ソースコードディレクトリ: windows/builder/Release/VanHelsing/2-decrypter/


2. 実行フロー全体像

2.1 WinMain() — エントリポイント (main.cpp:5-102)

INT WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
                    PWSTR pCmdLine, int nCmdShow)
{
    // (1) libsodium初期化
    if (sodium_init() == -1) {
        return EXIT_FAILURE;
    }

    // (2) コマンドライン解析
    if (ParseArgs() == FALSE) {
        return EXIT_FAILURE;
    }

    // (3) 秘密鍵の提供確認
    if (Key == NULL) {
        wsprintfW(debug, L"[*]\tSorry key not provided \n");
        return EXIT_FAILURE;
    }

    // (4) 公開鍵と秘密鍵をHEXからバイナリに変換
    unsigned char PUBLIC_KEY[crypto_box_PUBLICKEYBYTES];   // 32バイト
    unsigned char PRIVATE_KEY[crypto_box_SECRETKEYBYTES];  // 32バイト

    sodium_hex2bin(PUBLIC_KEY, (sizeof(X25519_PUBLIC_KEY) / 2),
                   X25519_PUBLIC_KEY, sizeof(X25519_PUBLIC_KEY),
                   nullptr, nullptr, nullptr);
    sodium_hex2bin(PRIVATE_KEY, (strlen(Key) / 2),
                   Key, strlen(Key),
                   nullptr, nullptr, nullptr);

    // (5) 復号対象の選定と実行
    diskmanagment* _diskmanagment = new diskmanagment();

    if (isTargetDirectory == TRUE) {
        // 指定ディレクトリのみ復号
        _diskmanagment->DirectorySearch(pTarget_directory, PUBLIC_KEY, PRIVATE_KEY);
    }
    else if (isTargetFile == TRUE) {
        // 単一ファイルの復号
        decryption dec;
        dec.decryptFile(target_file.c_str(), PUBLIC_KEY, PRIVATE_KEY);
    }
    else {
        // 全ドライブを復号
        drivers* drivers_list = _diskmanagment->EnumDrivers();
        for (INT dcounter = 0; dcounter < drivers_list->drivers_count; dcounter++) {
            _diskmanagment->DirectorySearch(
                drivers_list->drivers_list[dcounter], PUBLIC_KEY, PRIVATE_KEY);
        }
    }

    wprintf(L"[*] decrypting Finished \n");
    getchar();
    return EXIT_SUCCESS;
}

Lockerとの構造的類似性: main.cppの全体構造はLockerと酷似しており、同一の開発者が同一のコードベースから派生させたことが明白である。libsodium初期化→引数解析→対象選定(ドライブ/ディレクトリ/ファイル)のフローが共通している。

Lockerとの重要な差異:

  • 秘密鍵が必要: Lockerは公開鍵のみで動作するが、Decrypterは --Key で秘密鍵を受け取る。公開鍵はバイナリ内の X25519_PUBLIC_KEY マクロから取得し、秘密鍵はコマンドラインから取得する
  • ドライブ列挙でDRIVE_REMOTEが除外: ネットワークドライブの復号処理がコメントアウトされている(diskmanagment.cpp:119-129)。ローカルドライブ(DRIVE_FIXED)のみが対象。Lockerがネットワーク共有上のファイルも暗号化している場合、被害者は --Directory \\server\share で手動指定する必要がある

2.2 鍵の役割 — 公開鍵と秘密鍵の両方が必要な理由

Decrypterは公開鍵(X25519_PUBLIC_KEY、ビルダーが埋め込み)と秘密鍵(--Key、コマンドラインで指定)の両方を使用する:

// main.cpp:34-38
unsigned char PUBLIC_KEY[crypto_box_PUBLICKEYBYTES];
unsigned char PRIVATE_KEY[crypto_box_SECRETKEYBYTES];

sodium_hex2bin(PUBLIC_KEY, ..., X25519_PUBLIC_KEY, ...);   // 公開鍵: バイナリ内
sodium_hex2bin(PRIVATE_KEY, ..., Key, ...);                // 秘密鍵: コマンドライン

なぜ復号に公開鍵が必要なのか。crypto_box_seal_open() の仕様上、封印解除には受信者の公開鍵と秘密鍵のペアが必要である[2]。内部的に:

  1. 暗号文の先頭32バイト(ephemeral公開鍵)と受信者の秘密鍵からX25519共有秘密を計算
  2. 共有秘密と受信者の公開鍵からHSalsa20で暗号化鍵を導出
  3. 導出された鍵でXSalsa20-Poly1305復号

ステップ2で受信者の公開鍵が使用されるため、復号には公開鍵と秘密鍵の両方が不可欠。

参考文献

[2] libsodium公式ドキュメント: Sealed boxes — crypto_box_seal_open
[3] main.cpp:5-102


3. コマンドライン引数

3.1 ParseArgs() (common.cpp:92-168)

BOOL ParseArgs()
{
    int argc;
    LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);

    // ヘルプ
    if (IsArgExists(argc, argv, L"-h") == TRUE) {
        // ヘルプ表示後 getchar() でブロック → return TRUE
        // 注意: return TRUEだがKeyがNULLのまま → main.cppで "key not provided" エラー
    }

    // デバッグモード
    if (IsArgExists(argc, argv, L"-v") == TRUE) {
        AllocConsole();
        Debug = TRUE;
    }

    // 復号対象指定
    if (IsArgExists(argc, argv, L"--Driver") == TRUE) {
        target_driver = GetArgsValue(argc, argv, L"--Driver");
        isTargetDriver = TRUE;
    }

    if (IsArgExists(argc, argv, L"--Directory") == TRUE) {
        target_directory = GetArgsValue(argc, argv, L"--Directory");
        isTargetDirectory = TRUE;
    }

    if (IsArgExists(argc, argv, L"--File") == TRUE) {
        target_file = GetArgsValue(argc, argv, L"--File");
        isTargetFile = TRUE;
    }

    // Curve25519秘密鍵(復号に必須)
    if (IsArgExists(argc, argv, L"--Key") == TRUE) {
        std::wstring tempKey = GetArgsValue(argc, argv, L"--Key");
        Key = (char*)malloc(tempKey.length());   // メモリ確保
        Key = WideCharToChar(tempKey.c_str());   // WCHAR→char変換
        printf("[*] Key  :  %s  \n", Key);
    }

    return TRUE;
}

全引数一覧:

引数 種別 必須 機能
-h 単独 ヘルプ表示
-v 単独 デバッグ出力有効化
--Key <HEX> 値付き 必須 Curve25519秘密鍵(64文字HEX = 32バイト)
--Driver <パス> 値付き 復号対象ドライブ指定
--Directory <パス> 値付き 復号対象ディレクトリ指定
--File <パス> 値付き 単一ファイル復号

実行例:

# 全ドライブの暗号化ファイルを復号
decrypter.exe --Key <64文字HEX秘密鍵>

# 特定ディレクトリのみ復号
decrypter.exe --Key <秘密鍵> --Directory C:\Users\victim\Documents

# 単一ファイルの復号
decrypter.exe --Key <秘密鍵> --File C:\Users\victim\report.docx.vanhelsing

# デバッグ出力付き
decrypter.exe -v --Key <秘密鍵>

3.2 バグ: --Keyのメモリリーク (common.cpp:156-164)

if (IsArgExists(argc, argv, L"--Key") == TRUE) {
    std::wstring tempKey = GetArgsValue(argc, argv, L"--Key");
    Key = (char*)malloc(tempKey.length());  // (a) mallocで確保
    Key = WideCharToChar(tempKey.c_str());  // (b) 新たにnew[]で確保し、(a)のポインタを上書き
}

行160で malloc したメモリのポインタが、行161で WideCharToChar() の戻り値(new char[]で確保された別のメモリ)によって即座に上書きされる。(a)で確保したメモリは解放されずリークする。正しくは malloc 行を削除し、Key = WideCharToChar(tempKey.c_str()) のみにすべきである。

3.3 バグ: --Driverが未使用 (main.cpp)

--Driver フラグはParseArgs()で isTargetDriver = TRUE に設定されるが、main.cppの分岐処理に isTargetDriver を評価する条件がない:

// main.cpp:43-97
if (isTargetDirectory == TRUE) {
    // ディレクトリ復号
}
else if (isTargetFile == TRUE) {
    // ファイル復号
}
else {
    // 全ドライブ復号(isTargetDriverを見ていない)
}

--Driver を指定してもフォールスルーして全ドライブが復号対象になる。Lockerでは isTargetDriver による分岐があったが、Decrypterではその分岐が実装されていない。

3.4 ヘルプ文字列の問題 (common.cpp:94-95)

const WCHAR* Usage = L"VanHelsing Ransomeware usage\n"
    L"-h for help\n-v for verbose\n"
    L"-sftpPassword for spread over sftp\n"
    L"-smbPassword for spread over smb\n"
    L"-bypassAdmin for locking the target without admin privileges\n"
    L"-noLogs for stop logging\n-nopriority for stop CPU and IO priority";

Decrypterのヘルプ文字列がLockerのヘルプ文字列をそのままコピーしている。-sftpPassword, -smbPassword, -bypassAdmin はDecrypterには無関係な機能であり、--Key の説明がない。Lockerからのコードコピーが機械的に行われ、Decrypter固有の修正が漏れている。

参考文献

[4] common.cpp:92-168
[5] common.cpp:156-164


4. 復号エンジン — decryptFile()

4.1 全体フロー (decryption.cpp:47-254)

復号処理は以下の6ステップで構成される:

[ステップ1] ファイル名から元のファイル名を復元
            "document.docx.vanhelsing" → "document.docx"

[ステップ2] 暗号化ファイルをオープンし、メタデータ(封印済みセッション鍵)を抽出
            "---key---<HEX>---endkey---\n" を解析

[ステップ3] 封印済みセッション鍵を秘密鍵で開封
            crypto_box_seal_open(key, sealedKey, publicKey, privateKey)

[ステップ4] ストリームヘッダー(24バイト)を読み取り、復号ストリームを初期化
            crypto_secretstream_xchacha20poly1305_init_pull(&state, header, key)

[ステップ5] チャンク単位で復号
            ≤1GB: 全チャンクを復号
            >1GB: 先頭20%を復号、残り80%はそのまま出力

[ステップ6] 復号データを元ファイル名で出力

4.2 ファイル名の復元 — GetRealFileName() (decryption.cpp:17-45)

BOOL GetRealFileName(WCHAR* FileName, WCHAR* RealFileName)
{
    WCHAR tempFileNameExtention[150];
    int x = 0;  // ドット('.')の出現回数カウンタ
    int i = 0;

    while (x < 2)  // 2番目のドットが見つかるまでループ
    {
        if (FileName[i] == '.') {
            x += 1;
        }
        if (x == 2) break;  // 2番目のドットで停止

        tempFileNameExtention[i] = FileName[i];
        i += 1;
    }
    tempFileNameExtention[i] = '\0';

    wsprintfW(RealFileName, L"%s", tempFileNameExtention);
    return TRUE;
}

この関数は "document.docx.vanhelsing" から "document.docx" を抽出する。2番目のドット(.vanhelsing の先頭ドット)の位置でファイル名を切り詰める。

バグ: ドットなしファイル名、1ドットファイル名での無限ループ: ファイル名にドットが1つ以下の場合(例: "README.vanhelsing""Makefile.vanhelsing")、2番目のドットが見つからないまま文字列の終端を超えてバッファオーバーリードが発生する。while (x < 2) の終了条件にファイル名の長さチェックがなく、NULLターミネータを超えて読み続ける。

バグ: 複数ドットを含む元ファイル名の切り詰め: 元のファイル名に複数のドットが含まれる場合(例: "backup.2024.01.tar.gz.vanhelsing")、2番目のドットで切り詰められるため "backup.2024" になってしまう。正しくは末尾の .vanhelsing のみを除去すべきだが、この実装ではファイル名が破損する。

Lockerのencrypt側では StringCchPrintf(path, L"%s.vanhelsing", originalPath) で単純に追加しているだけであるため、復号側は wcslen(path) - wcslen(L".vanhelsing") で末尾を除去するのが正しい実装である。

4.3 メタデータの抽出 (decryption.cpp:57-95)

// 暗号化ファイルをオープン
std::ifstream encrypted_file(file_path, std::ios::binary);

encrypted_file.seekg(0, std::ios::end);
long long int fileSize = encrypted_file.tellg();
encrypted_file.seekg(0, std::ios::beg);

// メタデータ行を読み取り
std::string line, signature;
while (std::getline(encrypted_file, line)) {
    signature += line + "\n";
    if (line.find("---endkey---") != std::string::npos)
        break;  // "---endkey---" が見つかったら読み取り終了
}

// デリミタ間のHEX文字列を抽出
std::string start_marker = "---key---";
std::string end_marker = "---endkey---";
auto start = signature.find(start_marker);
auto end   = signature.find(end_marker);

if (start == std::string::npos || end == std::string::npos || end <= start) {
    printf("[!] Metadata key markers not found.\n");
    return;
}

int meta_tag_size = signature.length();

// "---key---" の後から "---endkey---" の前までを抽出
std::string hex_key = signature.substr(
    start + start_marker.size(),
    end - (start + start_marker.size()));
printf("[*] Extracted sealed key: %s\n", hex_key.c_str());

Lockerが書き込んだ ---key---<160文字HEX>---endkey---\n パターンを std::getline() で1行ずつ読み取り、デリミタ間の文字列を抽出する。このメタデータのサイズ(meta_tag_size)は後の復号処理でオフセット計算に使用される(が、現在の実装ではコメントアウトされている — decryption.cpp:133)。

OPSEC考察 — 秘密鍵のコンソール出力: 行95で抽出された封印済みセッション鍵がコンソールに出力される。デバッグ用途だが、Debug フラグに依存しない printf であるため、-v 未指定でもGUIアプリケーションのコンソール状態によっては出力される。フォレンジック観点では、プロセスの標準出力のキャプチャやコンソールバッファの回収で鍵情報が得られる可能性がある。

4.4 セッション鍵の開封 (decryption.cpp:97-107)

// HEX文字列をバイナリに変換
unsigned char sealedKey[crypto_secretstream_xchacha20poly1305_KEYBYTES
                       + crypto_box_SEALBYTES];  // 32 + 48 = 80バイト
sodium_hex2bin(sealedKey, sizeof(sealedKey),
               hex_key.c_str(), hex_key.length(),
               nullptr, nullptr, nullptr);

// 秘密鍵で封印を解除
unsigned char key[crypto_secretstream_xchacha20poly1305_KEYBYTES];  // 32バイト
if (crypto_box_seal_open(key, sealedKey, sizeof(sealedKey),
                         publicKey, privateKey) != 0)
{
    printf("[!] Failed to decrypt the stream key.\n");
    return;
}

crypto_box_seal_open() はLockerの crypto_box_seal() の逆操作である[6]:

  1. 封印データの先頭32バイトからephemeral公開鍵を取得
  2. ephemeral公開鍵と受信者の秘密鍵からX25519共有秘密を計算
  3. 共有秘密と受信者の公開鍵からXSalsa20-Poly1305鍵を導出
  4. 残りの48バイト(暗号文+MAC)を復号

秘密鍵が正しくない場合、MAC検証が失敗し -1 が返される。この場合、"Failed to decrypt the stream key" が出力されて処理が中断される。

4.5 ストリーム復号の初期化 (decryption.cpp:109-123)

// ストリームヘッダー(24バイト)を読み取り
unsigned char header[crypto_secretstream_xchacha20poly1305_HEADERBYTES];
encrypted_file.read(reinterpret_cast<char*>(header), sizeof(header));
if (encrypted_file.gcount() != sizeof(header)) {
    printf("[!] Failed to read stream header.\n");
    return;
}

// 復号ストリームを初期化
crypto_secretstream_xchacha20poly1305_state stream_state;
if (crypto_secretstream_xchacha20poly1305_init_pull(&stream_state, header, key) != 0)
{
    printf("[!] Failed to initialize stream pull.\n");
    return;
}

init_pull() はLockerの init_push() の逆操作で、ヘッダーからnonceを復元し復号状態を初期化する。

復号者の観点 — init_pull()が失敗するケース: init_pull() はヘッダー内のnonceとセッション鍵の整合性を検証する。以下の場合に失敗する:

  • セッション鍵がcrypto_box_seal_openで正しく復号されなかった(秘密鍵の不一致)
  • ヘッダーが破損している(暗号化ファイルの部分的な上書きや切り詰め)
  • Lockerとは異なるlibsodiumバージョンがヘッダー形式を変更した場合(極めて稀)

init_pull() の成功はストリーム復号が正しく開始される前提条件であり、ここで失敗した場合は「秘密鍵が正しいか」「暗号化ファイルが破損していないか」の2点を確認する必要がある。

4.6 チャンク単位の復号 (decryption.cpp:125-247)

// 出力ファイルを元のファイル名で作成
std::ofstream outFile(temp_new_file_path, std::ios::binary);

// チャンクサイズ: 1MB + 認証タグ(17バイト)
const size_t CHUNK_SIZE = 1 * 1024 * 1024
                        + crypto_secretstream_xchacha20poly1305_ABYTES;
auto* encryptedData = (unsigned char*)malloc(CHUNK_SIZE);
auto* decryptedData = (unsigned char*)malloc(CHUNK_SIZE);

if (fileSize <= 1048576000)  // ≤ 1GB: 全チャンク復号
{
    long long int remainingSize = fileSize;
    while (remainingSize > 0) {
        encrypted_file.read(reinterpret_cast<char*>(encryptedData), CHUNK_SIZE);
        size_t rlen = encrypted_file.gcount();
        if (rlen == 0) break;

        unsigned long long out_len;
        unsigned char tag;

        if (crypto_secretstream_xchacha20poly1305_pull(
                &stream_state, decryptedData, &out_len, &tag,
                encryptedData, rlen, nullptr, 0) == 0)
        {
            outFile.write(reinterpret_cast<char*>(decryptedData), out_len);
        }
        else {
            printf("[!] Decryption failed .\n");
            break;
        }

        if (tag == crypto_secretstream_xchacha20poly1305_TAG_FINAL) {
            printf("[*] Final chunk received.\n");
            break;
        }
        remainingSize -= chunkSize;
    }
}
else  // > 1GB: 先頭20%を復号、残り80%はそのまま出力
{
    long long int TotalSize     = fileSize;
    long long int remainingSize = ((fileSize / 100) * 20);  // 先頭20%

    while (TotalSize > 0) {
        encrypted_file.read(reinterpret_cast<char*>(encryptedData), CHUNK_SIZE);
        size_t rlen = encrypted_file.gcount();
        if (rlen == 0) break;

        unsigned long long out_len;
        unsigned char tag;

        if (remainingSize > 0) {
            // 先頭20%: 復号
            if (crypto_secretstream_xchacha20poly1305_pull(
                    &stream_state, decryptedData, &out_len, &tag,
                    encryptedData, rlen, nullptr, 0) == 0)
            {
                outFile.write(reinterpret_cast<char*>(decryptedData), out_len);
            }
            else {
                printf("[!] Decryption failed .\n");
                break;
            }
        }
        else {
            // 残り80%: そのまま出力
            outFile.write(reinterpret_cast<char*>(encryptedData), rlen);
        }

        remainingSize -= chunkSize;
        TotalSize     -= chunkSize;
    }
}

free(encryptedData);
free(decryptedData);
encrypted_file.close();
outFile.close();

Lockerとの対称性の検証:

パラメータ Locker (encryption.cpp) Decrypter (decryption.cpp) 一致
チャンクサイズ 1MB 1MB + ABYTES(17) 正しい(暗号文はタグ分大きい)
部分暗号化閾値 1,048,576,000 (≈1GB) 1,048,576,000 一致
部分暗号化割合 fileSize * 20 / 100 (fileSize / 100) * 20 注意が必要(後述)
TAG_FINAL処理 push時に設定 pull時に検出して終了 正しい

バグ: 部分暗号化割合の計算順序の差異:

Lockerでは fileSize * 20 / 100 だが、Decrypterでは (fileSize / 100) * 20 である。整数除算のため、この2つは異なる結果を返す場合がある:

例: fileSize = 1,500,000,001 (約1.5GB)
  Locker:    1500000001 * 20 / 100 = 30000000020 / 100 = 300000000 (正確)
  Decrypter: 1500000001 / 100 * 20 = 15000000 * 20     = 300000000 (同一)

例: fileSize = 1,500,000,099
  Locker:    1500000099 * 20 / 100 = 30000001980 / 100 = 300000019
  Decrypter: 1500000099 / 100 * 20 = 15000000 * 20     = 300000000
  → 19バイトの差異

long long int 型であるため、Locker側では fileSize * 20 がオーバーフローするリスクは低い(最大9.2EB)。しかし、Decrypter側の fileSize / 100 は端数を切り捨てるため、Lockerが暗号化したバイト数とDecrypterが復号を試みるバイト数に最大99バイトの差異が生じる。この差異がチャンク境界に影響する場合、最終チャンクの復号が失敗するか、平文部分が暗号化データとして復号され破損する可能性がある。

バグ: fileSizeがメタデータを含んだサイズ: fileSize は暗号化ファイル全体のサイズ(メタデータ + ヘッダー + 暗号化データ)だが、部分暗号化の判定(fileSize <= 1048576000)ではメタデータサイズが含まれている。Locker側では元ファイルのサイズで判定するため、メタデータ分だけ閾値がずれる。ただし、メタデータサイズ(約180バイト)は1GBに対して無視できるほど小さいため、実用上の影響はほぼない。

暗号化ファイルが残存: Lockerは暗号化後に元ファイルを DeleteFileW() で削除するが、Decrypterは暗号化ファイル(.vanhelsing)を削除しない。復号後に元ファイルと暗号化ファイルが共存する。被害者が復号結果を確認してから手動で暗号化ファイルを削除する運用が想定される。

4.7 復号処理の完全性検証 — TAG_FINALとAEAD認証

XChaCha20-Poly1305はAEAD(認証付き暗号)であり、各チャンクの復号時にPoly1305認証タグが検証される。crypto_secretstream_xchacha20poly1305_pull() は認証タグの検証に失敗した場合に非ゼロを返す。これが発生するケース:

  1. 秘密鍵の不一致: 正しくないCurve25519秘密鍵を指定した場合、crypto_box_seal_openでセッション鍵の復号が失敗するか、復号されたセッション鍵が誤っているためにpull()で認証エラーとなる
  2. 暗号化ファイルの部分的な破損: ディスクエラーやファイルコピーの不完全性により暗号文の一部が変化した場合、該当チャンクの認証タグ検証が失敗する。ただし、破損していないチャンクは正常に復号される
  3. 暗号化ファイルの切り詰め: ファイルの末尾が切り詰められた場合、TAG_FINAL を含む最終チャンクが失われるため、復号は途中で終了する

TAG_FINAL はストリームの終端を示すフラグであり、Lockerの push() で最終チャンクに設定される。Decrypterの pull() でこのフラグを検出すると復号ループを終了する:

if (tag == crypto_secretstream_xchacha20poly1305_TAG_FINAL) {
    printf("[*] Final chunk received.\n");
    break;
}

TAG_FINALの意義は復号の完全性を保証することにある。TAG_FINALが検出されずに復号が終了した場合(ファイル末尾到達でrlen==0となった場合)、ファイルが不完全に暗号化されたか、切り詰められた可能性がある。しかし、現在の実装では TAG_FINAL の未検出を明示的にエラーとして扱っておらず、rlen == 0 で静かにループを抜ける。

4.8 復号が失敗する実運用上のシナリオ

インシデントレスポンスの観点から、Decrypterによる復号が失敗する主要なシナリオを整理する:

シナリオ 原因 症状 対処
秘密鍵の不一致 異なるアフィリエイトの秘密鍵を使用 "Failed to decrypt the stream key" 正しい秘密鍵(build_publickey に対応する秘密鍵)を取得
部分暗号化の境界ずれ Locker/Decrypterの計算順序差異(fileSize * 20 / 100 vs (fileSize / 100) * 20 大容量ファイルの末尾付近でチャンク復号失敗 手動でバイトオフセットを調整して復号
ファイル名の破損 GetRealFileName()のドット位置バグ 復号は成功するが出力ファイル名が不正 復号後にファイル名を手動修正
ネットワーク共有のファイル EnumDrivers()でDRIVE_REMOTEが除外 ネットワーク共有上のファイルが復号されない --Directory \\server\share で手動指定
Silentモードのファイル Lockerが暗号化せずリネームのみ .vanhelsing 拡張子があるが暗号化されていない "Metadata key markers not found" エラー。リネームのみのため拡張子を手動削除で復旧可能

最後の「Silentモードのファイル」は特に注意が必要である。Lockerの --Silent フラグで処理されたファイルは暗号化されておらず、ファイル名に .vanhelsing が付与されただけの平文ファイルである。Decrypterは ---key--- マーカーを検索するが、平文ファイルにはこのマーカーが存在しないため "Metadata key markers not found" エラーとなる。この場合、被害者は .vanhelsing 拡張子を手動で削除するだけで復旧できるが、Decrypterはこのケースを自動判定する機能を持たず、エラーメッセージも原因を示唆しない。

参考文献

[6] libsodium公式ドキュメント: crypto_box_seal_open [7] decryption.cpp:47-254


5. ディレクトリ探索 — .vanhelsingファイルのフィルタリング

5.1 DirectorySearch() — Decrypter版 (diskmanagment.cpp:141-208)

VOID diskmanagment::DirectorySearch(WCHAR* entry,
    unsigned char* PUBLIC_KEY, unsigned char* PRIVATE_KEY)
{
    WCHAR searchPath[1500 * 2];
    swprintf_s(searchPath, L"%s\\*", entry);

    WIN32_FIND_DATAW data;
    HANDLE hFile = FindFirstFileW(searchPath, &data);

    do {
        if (lstrcmpW(data.cFileName, L".") == 0 ||
            lstrcmpW(data.cFileName, L"..") == 0 ||
            data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)
            continue;

        swprintf_s(fullPath, L"%s\\%s", entry, data.cFileName);

        if (data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
            SetFileAttributesW(fullPath, FILE_ATTRIBUTE_NORMAL);
            if (CanAccessDirectory(fullPath) && !IsDirectoryEmpty(fullPath)) {
                this->DirectorySearch(fullPath, PUBLIC_KEY, PRIVATE_KEY);
            }
        }
        else {
            // Lockerのブラックリストとは対照的に、
            // ".vanhelsing" を含むファイルのみを対象とする(ホワイトリスト方式)
            if (StrStrW(data.cFileName, L".vanhelsing")) {
                WCHAR FilePath[1560 * 4];
                swprintf_s(FilePath, L"%s\\%s", entry, data.cFileName);

                decryption dec;
                dec.decryptFile(FilePath, PUBLIC_KEY, PRIVATE_KEY);
            }
        }
    } while (FindNextFileW(hFile, &data) != 0);

    FindClose(hFile);
}

Lockerとの差異:

項目 Locker (DirectorySearch) Decrypter (DirectorySearch)
ファイルフィルタ ブラックリスト方式(71拡張子を除外) ホワイトリスト方式(.vanhelsing のみ対象)
ディレクトリフィルタ ブラックリスト(18ディレクトリを除外) なし(全ディレクトリを走査)
README.txt配置 Vanhelsing_DropReadMe() を呼び出し 呼び出しなし
ネットワークドライブ isNoMounted で制御 コメントアウト(ローカルのみ)

OPSEC考察 — ディレクトリブラックリストの不在: Decrypterにはディレクトリブラックリストがない。Lockerでは Windows, Boot, System Volume Information 等を除外していたが、Decrypterはこれらのディレクトリも走査する。もっとも、Lockerがこれらのディレクトリを暗号化していないため、.vanhelsing ファイルは存在せず、実質的な影響はない。ただし、走査自体は行われるため、大量のシステムファイルをスキャンする無駄な処理が発生する。

バグ: StrStrW()による部分一致: StrStrW(data.cFileName, L".vanhelsing") は大文字小文字を区別する部分一致検索である。ファイル名の途中に .vanhelsing が含まれるファイル(例: ".vanhelsing_backup.txt", "my.vanhelsing.notes")も対象となってしまう。Lockerが L"%s.vanhelsing" で末尾に追加する仕様であるため、末尾マッチ(StrStrW + 位置検証)が正しい実装。

5.2 ネットワークドライブの除外 (diskmanagment.cpp:119-129)

// Decrypter版 EnumDrivers() — DRIVE_REMOTEがコメントアウト
if (DriverType == DRIVE_FIXED) {
    // 固定ドライブ: 対象
}
//else if (DriverType == DRIVE_REMOTE)
//{
//    if (isNoMounted == FALSE) {
//        // ネットワークドライブ: コメントアウトで除外
//    }
//}

Lockerではネットワークドライブも暗号化対象に含まれるが、Decrypterではコメントアウトされている。これは:

  • 被害者がDecrypterを実行する環境がネットワーク接続されていない場合を想定
  • ネットワーク共有上のファイル復号は権限問題が発生しやすい
  • アフィリエイトが被害者に「ローカルで実行してください」と指示する運用

ただし、Lockerがネットワーク共有上のファイルも暗号化している場合、Decrypterのデフォルト設定ではそれらを復号できない。被害者は --Directory \\server\share を指定して手動で復号する必要がある。

参考文献

[8] diskmanagment.cpp:141-208
[9] diskmanagment.cpp:60-139


6. 旧バージョンとの差分分析

6.1 コメントアウトされたコード

decryption.cppとdiskmanagment.cppの両方に、コメントアウトされた旧バージョンが存在する。

decryption.cpp旧バージョン (行259-368): 現行バージョンとの主な差異: - 部分暗号化(>1GB)の処理がない — 全ファイルを全体復号する - fileSize を取得していない — 復号はストリームの TAG_FINAL のみで終了を判定

diskmanagment.cpp旧バージョン (行212-367): - IsLockedExtention() 関数が存在し、.vanlocker 拡張子をチェックしている(行357: lstrcmpW(tempFileNameExtention, L".vanlocker") == 0) - 現行バージョンでは StrStrW(data.cFileName, L".vanhelsing") に変更されている

旧バージョンが .vanlocker を検索し、現行バージョンが .vanhelsing を検索していることは、Locker側の拡張子バグ(.vanlocker でアイコン登録しつつ .vanhelsing で暗号化)の変遷と一致する。旧Lockerが .vanlocker 拡張子を使用していた時期があり、その後 .vanhelsing に変更されたことがDecrypterの変更からも裏付けられる。

6.2 コメントアウトされた旧ParseArgs() (common.cpp:172-245)

旧バージョンのParseArgs()では GetArgsValue() の戻り値が WCHAR* 型であり、現行バージョンの std::wstring 型と異なる。これはLockerのParseArgs()と同じ変更が適用されていることを示し、コードベースが共有されていることを裏付ける。

参考文献

[10] decryption.cpp:259-368, diskmanagment.cpp:212-367


7. バグ・実装上の問題 — 全リスト

# 問題 箇所 影響度
1 GetRealFileName()の無限ループ/バッファオーバーリード decryption.cpp:17-45 高 — ドット1つ以下のファイル名でクラッシュ
2 GetRealFileName()の複数ドットファイル名切り詰め decryption.cpp:17-45 高 — 復号後ファイル名が破損
3 部分暗号化割合の計算順序差異 decryption.cpp:192 中 — 最大99バイトの復号範囲ずれ
4 --Key のメモリリーク common.cpp:160-161 低 — malloc→即上書き
5 --Driver が未使用 main.cpp:43-97 低 — 全ドライブにフォールスルー
6 GetArgsValue()の境界外アクセス common.cpp:78-90 中 — Lockerと同一のバグ
7 ヘルプ文字列がLocker用のまま common.cpp:94-95 低 — 機能に影響なし
8 StrStrW()の部分一致 diskmanagment.cpp:191 低 — 誤検出の可能性
9 fileSizeにメタデータサイズが含まれる decryption.cpp:140 低 — 閾値への影響は無視可能
10 ネットワーク共有のファイルがデフォルトで復号されない diskmanagment.cpp:119-129 中 — 手動指定が必要

GetRealFileName()のバグ(#1, #2)は実用上の重大な問題である。ドットを1つしか含まないファイル名("README.txt.vanhelsing" 等)はDecrypterで正しく復号できない可能性があり、RaaSの「信頼性」を損なう。

参考文献

[11] decryption.cpp:17-45, common.cpp:78-90, 94-95, 160-161


8. 検知・ハンティングポイント

8.1 MITRE ATT&CK マッピング

Decrypter自体は攻撃ツールではなく復旧ツールであるが、以下の観点でインシデントレスポンスに関連する:

観点 説明
フォレンジック Decrypterバイナリから X25519_PUBLIC_KEY を抽出し、同一アフィリエイトの他の攻撃と関連付け可能
インシデント対応 Decrypterのコマンドライン引数から秘密鍵を回収し、バックアップなしの環境でファイル復旧に使用可能
脅威インテリジェンス Decrypterの X25519_PUBLIC_KEY とLockerの X25519_PUBLIC_KEY を照合し、同一ビルドであることを確認

8.2 プロセス実行パターン

# Decrypterの典型的なコマンドライン
decrypter.exe --Key <64文字HEX秘密鍵>
decrypter.exe --Key <秘密鍵> --Directory C:\Users\victim
decrypter.exe --Key <秘密鍵> --File C:\path\to\file.vanhelsing

フォレンジック上の重要性: --Key パラメータの値(Curve25519秘密鍵)がプロセス作成イベント(Windows Security 4688, Sysmon 1)に記録されている場合、その秘密鍵を使用して他の暗号化ファイルの復号が可能になる。インシデントレスポンスにおいて、イベントログからの秘密鍵回収は最優先の調査項目となる。

参考文献

[12] main.cpp:5-102


9. Lockerとの暗号化パラメータ対応表

Decrypterが正しく動作するためには、Lockerの暗号化パラメータと完全に一致する必要がある。以下に両者のパラメータを対比する:

パラメータ Locker (encryption.cpp) Decrypter (decryption.cpp) 一致状態
対称暗号アルゴリズム XChaCha20-Poly1305 XChaCha20-Poly1305 一致
対称暗号鍵長 32バイト 32バイト 一致
非対称暗号アルゴリズム Curve25519 crypto_box_seal Curve25519 crypto_box_seal_open 正しい逆操作
メタデータ形式 ---key---HEX---endkey---\n ---key---...---endkey--- を解析 一致
ストリームヘッダー 24バイト(init_push生成) 24バイト(init_pull読み取り) 一致
チャンクサイズ(暗号化) 1MB 1MB + 17バイト(ABYTES) 正しい(暗号文サイズ)
全体暗号化閾値 1,048,576,000 1,048,576,000 一致
部分暗号化割合 fileSize * 20 / 100 (fileSize / 100) * 20 微小差異あり
TAG_FINAL push時に設定 pull時に検出 正しい
暗号化ファイル拡張子 .vanhelsing .vanhelsing を検索 一致
元ファイル削除 DeleteFileW() で削除 暗号化ファイルは残存 設計通り

参考文献

[13] encryption.cpp:11-181 (Locker), decryption.cpp:47-254 (Decrypter)