.NET 8/9 WPF・コンソールでの VERSION.dll DLLハイジャック対策【Windows 11 / ClickOnce】

.NET 8/9 の WPF やコンソール アプリを Windows 11 上で動かしたとき、アプリ フォルダーに置かれた偽の VERSION.dll が System32 の本物より先に読み込まれてしまう──いわゆる DLL ハイジャックは、条件がそろうと誰でも再現できる現実的なリスクです。本記事では、その原因を Windows の DLL 検索順序から整理し、ClickOnce 配布時の注意点、MSIX や OS ミティゲーションを用いた実務的な対策まで、開発者・情シス担当者の双方の視点で詳しく解説します。

目次

.NET 8/9 で発生する VERSION.dll ハイジャック問題の概要

まずは、今回の事象を簡単に整理します。ここで扱うケースは次のようなものです。

  • .NET 9 の WPF アプリ(ClickOnce 配布)
  • .NET 8/9 の単純なコンソール アプリでも同様に再現
  • OS は Windows 11 23H2 相当(ビルド 22631.5189)
  • アプリのインストール先フォルダーに悪意ある VERSION.dll を置く
  • アプリを起動すると、System32 の正規 version.dll ではなく偽 DLL が読み込まれる
  • 偽 DLL が電卓を起動したり、アプリをクラッシュさせたりできる

ポイントは、これは アプリの Main に到達する前に起こる という点です。そのため、C# 側のコードで DLL 検索パスを変更しても「間に合わない」状況が発生します。

事象と環境の整理

項目内容
OSWindows 11 23H2 相当(ビルド 22631.5189)
.NET ランタイム.NET 8 / .NET 9
アプリ種別WPF(ClickOnce 配布)、コンソール アプリ
攻撃条件アプリ実行フォルダー配下に偽 VERSION.dll を配置可能
結果System32\version.dll よりも偽 DLL が優先ロードされる
影響範囲アプリ起動前に任意コード実行/アプリ起動失敗など

では、なぜこのようなことが起こるのでしょうか。

DLL ハイジャックとは何か

DLL ハイジャック(DLL Hijacking) とは、アプリケーションが DLL をロードする際、その検索順序を悪用して、攻撃者が用意した「同名の DLL」を先に読み込ませる攻撃手法です。

  • アプリが version.dll をロードしたい
  • Windows は決められた検索順序に従って DLL を探す
  • 先に見つかった DLL を読み込む(署名チェックなどは基本的に行われない)
  • その「先に見つかった場所」が攻撃者が書き込めるフォルダーだった場合、偽 DLL を差し込める

今回のケースでは、ユーザー プロファイル配下に置かれた ClickOnce アプリのフォルダー が「ユーザーによって書き込み可能」であり、そこに VERSION.dll を置けてしまうことが問題の入口になっています。

Windows の DLL 検索順序と KnownDLL の仕組み

この問題を理解するには、Windows が DLL を読み込む際の 検索順序 を押さえておく必要があります。

SafeDllSearchMode 有効時の一般的な検索順序

SafeDllSearchMode(最近の Windows では既定で有効)が有効な場合、ざっくり次のような順序で DLL が検索されます。

優先度検索場所補足
1アプリケーションの実行フォルダーここが今回の攻撃ポイント
2System32(%SystemRoot%\System32)正規のシステム DLL の多くが存在
316 ビット システムフォルダー互換性用(通常あまり意識しない)
4Windows フォルダー%SystemRoot%
5現在の作業ディレクトリ(CWD)オプションや設定により除外可能
6PATH 環境変数に登録されたフォルダー順番に探索

ここから分かる通り、アプリケーションの実行フォルダーが最優先 であるため、そこに偽 DLL が置けてしまうと、System32 の正規 DLL よりも先に読み込まれます。

KnownDLL と VERSION.dll の立ち位置

Windows には KnownDLL と呼ばれる仕組みがあります。これは、kernel32.dll や user32.dll など、ほぼすべてのプロセスが使う中核 DLL 群を OS が特別扱いするもので、以下の特徴があります。

  • 特定の DLL 名は「KnownDLL リスト」に登録されている
  • これらは System32 の DLL が共有セクションとしてマップされる
  • 結果として、アプリ フォルダーに置いても 横取りできない

ところが、今回問題になっている version.dll は、代表的な KnownDLL には含まれていません。そのため、通常の DLL と同じ検索順序 が適用され、アプリ実行フォルダーが最優先されてしまいます。

種類例アプリ フォルダーからのハイジャック
KnownDLLkernel32.dll / user32.dll など基本的に不可(System32 固定)
非 KnownDLL のシステム DLLversion.dll / winhttp.dll など可能(アプリ フォルダーから横取りされ得る)

つまり、「システム DLL だから安全」とは限らない ということです。

.NET 8/9 の WPF / コンソールでなぜ発生するのか

.NET アプリケーションは、実際にはネイティブのホスト(dotnet.exe や apphost)から起動され、そこから hostfxr.dll や coreclr.dll がロードされる流れになっています。この過程で、さまざまなネイティブ DLL が静的または動的にロードされます。

重要なのは、これらのネイティブ依存 DLL の中に version.dll を参照するものがある という点です。静的リンクされている場合、プロセス起動直後に Windows が DLL 解決を行い、上記の検索順序に従って version.dll を探します。

  • .NET ランタイム自体、あるいは依存ライブラリが version.dll に依存
  • プロセス開始時点で Windows が version.dll を解決
  • この時点ではまだ C# の Main は実行されていない
  • 結果として、アプリ フォルダーの偽 DLL が優先される

つまり、問題の発火ポイントは .NET アプリの Main より前 にあります。C# コードからの対策(SetDllDirectory など)だけでは防ぎきれないのはこのためです。

効かなかった対策がなぜ効かないのか

Main 内で SetDllDirectory を呼ぶだけでは間に合わない

よく試されるのが、C# の Main 冒頭で次のようなコードを書く方法です。


using System;
using System.Runtime.InteropServices;

class Program
{
    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool SetDllDirectory(string lpPathName);

    static void Main()
    {
        // DLL 検索パスを空にしてみる
        SetDllDirectory(string.Empty);

        // ここから先でネイティブ DLL を使う処理...
    }
}

しかし、今回の VERSION.dll 問題では、SetDllDirectory を呼び出す前に DLL がロードされている ため、既に手遅れです。また、SetDllDirectory はあくまで「現在の作業ディレクトリ(CWD)」との組み合わせで意味を持つ関数であり、アプリ実行フォルダー優先というルール自体を変えてくれるわけではありません。

ネイティブ ブートストラッパーでの対策が子プロセスに届かないことも

もう一つありがちな案が、C/C++ でブートストラッパー EXE を作り、その中で SetDefaultDllDirectories や AddDllDirectory を駆使して DLL 検索パスを制御し、その後に .NET アプリを起動するというものです。

ところが、以下の落とし穴があります。

  • ブートストラッパーと .NET アプリを別プロセスとして起動している場合、DLL 検索パスの設定はプロセスをまたいで継承されない
  • .NET アプリ側のプロセスが起動した瞬間に、再び通常の検索順序で DLL 解決が行われる
  • version.dll のように静的依存となっている場合、その瞬間にハイジャックが発生する

したがって、この手の「前座プロセスで調整する」アプローチも、本質的な解決にはなりません。

根本原因:「アプリ フォルダーに同名 DLL を置ける」環境

ここまでの整理から、問題の本質は次の一文に集約できます。

アプリ実行フォルダーに同名 DLL(VERSION.dll)を置けてしまう状態がそもそも危険である。

つまり、アプリ側だけで完全に防ごうとするのは限界がある ということです。ユーザー プロファイル配下に配置される ClickOnce のように、「アプリ実体がユーザー書き込み可能な場所」にある場合、同じユーザー権限で動作するマルウェアやスクリプト により DLL が置き換えられ得ます。

この前提を変えない限り、どこかのルートから DLL ハイジャックが入り込む余地は残ってしまいます。

優先すべき対策:インストール先を「書き込み不可」にする

最も効果的で、かつ原理的な対策は次の通りです。

アプリのインストール先フォルダーを、ユーザー(標準ユーザー)が書き込めない場所にする。

Program Files 配下へのインストール(MSI 等)

従来の Windows アプリの王道は、C:\Program Files または C:\Program Files (x86) 配下へのインストールです。ここは標準ユーザーからは基本的に書き込み不可であり、DLL を勝手にドロップして差し替えることはできません。

  • MSI やセットアップ ツールで Program Files 配下に配置
  • アプリ フォルダーは管理者のみ書き込み可能
  • ユーザー権限のマルウェアからは DLL を置き換えられない

ただし、ClickOnce のようなユーザー単位の自動更新メカニズムとは異なり、更新のたびに管理者権限が必要になる といった運用上の違いも出てくるため、環境に応じたトレードオフが必要です。

MSIX パッケージ化(最も強力な対策)

Windows 10 以降で推奨される配布形態が MSIX です。MSIX ではアプリ実体は C:\Program Files\WindowsApps 配下に格納され、ここは実質的に読み取り専用として扱われます。

  • パッケージは OS により厳格に管理される
  • アプリ フォルダーにユーザーがファイルを追加・削除できない
  • DLL ハイジャック対策として非常に強い
  • アンインストールや更新もクリーンに行える

ClickOnce からの移行コストはありますが、「アプリ フォルダーに偽 DLL を置けないようにする」という観点では、現行の Windows で取り得る最強クラスの対策 と言えます。

ClickOnce を使い続ける場合の注意点

どうしても ClickOnce を継続したい場合、以下のような観点でリスクを抑えることができます。

  • ClickOnce アプリの依存 DLL は極力同梱せず、システム側の DLL への依存を最小化する
  • ClickOnce の公開元サーバーを保護し、不正ファイルが混入しないしくみを整える
  • 組織として WDAC / AppLocker 等で「ユーザー プロファイル配下からの DLL 実行」を厳しく制限する

ただし、インストール先がユーザー書き込み可能である限り、構造的なリスクは残る という点は変わりません。中長期的には MSIX や Program Files への移行を検討すべきです。

OS ミティゲーション:System32 優先(PreferSystem32Images)の活用

環境側で DLL 検索の挙動を「より安全側」に寄せる方法として、Windows の プロセス ミティゲーション を利用する手があります。その中でも VERSION.dll 問題に直接効くのが PreferSystem32Images です。

PreferSystem32Images とは

PreferSystem32Images を有効にしたプロセスでは、DLL ロード時に System32 内の DLL を優先的に選ぶ ようになります。これにより、アプリ フォルダーに同名 DLL があっても、System32 の正規版が選ばれる可能性が高くなります。

この設定は、次のような方法で有効化できます。

  • Windows セキュリティ(Exploit Protection)の GUI から EXE ごとに設定
  • PowerShell の Set-ProcessMitigation コマンドレットで設定
  • グループ ポリシー(GPO)を使い、組織内の対象アプリに一括適用

PowerShell による設定例

管理者権限の PowerShell から、特定の EXE に PreferSystem32Images を有効化する例を示します。


# 例: MyApp.exe に PreferSystem32Images を付与
Set-ProcessMitigation -Name "MyApp.exe" -Enable PreferSystem32Images

この設定は プロセス開始時点 から適用されるため、Main 前の DLL ロードにも効く 点が重要です。つまり、今回のような「静的依存 DLL のロード」に対しても、ある程度の抑止力を持ちます。

CWDIllegalInDllSearch との違い

似たような設定として、レジストリで CWDIllegalInDllSearch を設定し、「現在の作業ディレクトリ(Current Working Directory)を DLL 検索から除外する」という対策もあります。

しかし、今回問題になっているのは アプリ実行フォルダー であり、CWD とは必ずしも一致しません。CWDIllegalInDllSearch は あくまで CWD に対する対策 であり、アプリ フォルダー優先というルールには直接の影響を与えません。

設定対象VERSION.dll 問題への効果
CWDIllegalInDllSearch現在の作業ディレクトリ(CWD)限定的(CWD が問題の原因であれば有効)
PreferSystem32ImagesDLL 解決全般(System32 を優先)VERSION.dll のような非 KnownDLL に有効

したがって、本記事の文脈では PreferSystem32Images の方が本質的な対策に近い と言えます。

WDAC / AppLocker によるコード実行制御

さらに一段高いレイヤーでの対策が、Windows の コードインテグリティ系機能(WDAC / AppLocker)を用いる方法です。

WDAC(Windows Defender Application Control)

WDAC は、「どのバイナリが実行できるか」をホワイトリスト方式で制御する仕組みです。これを DLL にまで適用すると、次のようなポリシーが取れます。

  • 署名された DLL のみロードを許可する
  • System32 や Program Files など、特定ディレクトリの DLL だけを許可する
  • ユーザー プロファイル配下の DLL ロードは原則禁止

こうしたポリシーを適用すれば、たとえアプリ フォルダーに偽 VERSION.dll が置かれても、そもそも OS がロードを許さない という状態を作れます。

AppLocker

AppLocker は、WDAC よりもややライトなアプローチで、実行ファイルやスクリプト、DLL の実行・読み込みを制御します。グループ ポリシーと連携しやすく、次のような運用に向きます。

  • 社内アプリの EXE / DLL のみ許可
  • 特定フォルダー配下からの DLL 読み込みを禁止
  • 監査モードでログを取りつつ、ポリシーを段階的に強化

WDAC / AppLocker を用いた コード実行制御は、組織全体での恒久対策 として非常に有効です。ただし、適切なルール設計を行わないと業務アプリが動かなくなるリスクもあるため、事前の検証環境でのテストが必須です。

アプリ側でできる限定的な防御策

ここまでの対策は「環境側で守る」アプローチでした。一方、アプリ開発者の立場から「自分のアプリが呼ぶネイティブ DLL だけでも安全にしたい」というニーズもあります。その場合に有効なのが、.NET 5 以降で利用できる DefaultDllImportSearchPaths と DllImportResolver です。

DefaultDllImportSearchPaths 属性で既定の検索パスを制御

.NET 5 以降では、アセンブリに対して DefaultDllImportSearchPaths 属性を付与することで、P/Invoke の DLL 検索パスを制御できます。


using System.Runtime.InteropServices;

[assembly: DefaultDllImportSearchPaths(
DllImportSearchPath.System32 |
DllImportSearchPath.AssemblyDirectory)]

この例では、P/Invoke で DLL を探す際に、

  • System32
  • アセンブリが置かれているディレクトリ

を優先するように設定しています。少なくとも、現在の作業ディレクトリや PATH だけに頼るよりは遥かに安全 です。

DllImportResolver で特定 DLL を System32 に固定

さらに踏み込んで、「この DLL 名が来たら必ず System32 からロードする」と強制することもできます。それが NativeLibrary.SetDllImportResolver です。


using System;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;

public static class NativeLoader
{
    public static void Init()
    {
        NativeLibrary.SetDllImportResolver(
            Assembly.GetExecutingAssembly(),
            (name, asm, paths) =>
            {
                if (name.Equals("version.dll",
                    StringComparison.OrdinalIgnoreCase))
                {
                    // System32 の正規版を明示的に指定
                    var sysPath = Path.Combine(
                        Environment.SystemDirectory,
                        "version.dll");
                    return NativeLibrary.Load(sysPath);
                }

                // それ以外は既定の解決ロジックに任せる
                return IntPtr.Zero;
            });
    }
}

この NativeLoader.Init() をアプリ起動直後(可能な限り早い段階)で呼び出せば、少なくとも自分の P/Invoke が version.dll をハイジャックされることは防げます。

ただし、繰り返しになりますが、これはあくまで「自分が直接呼ぶ P/Invoke」に対しての保護であり、

  • .NET ランタイム自身が起動時にロードする DLL
  • ネイティブ DLL 間の静的リンクでロードされる DLL

には直接効きません。環境側の対策を補完する位置づけ として利用するのが現実的です。

正規 VERSION.dll の先行読み込みというアイデア

もう一つの案として、「アプリ最序盤で System32 の VERSION.dll を明示的に読み込んでしまう」という手があります。


using System;
using System.IO;
using System.Runtime.InteropServices;

public static class VersionDllPreloader
{
    public static void Load()
    {
        var path = Path.Combine(
            Environment.SystemDirectory,
            "version.dll");
        NativeLibrary.Load(path);
    }
}

これにより、プロセスに正規の version.dll が既にマップされている状態 にできます。ただし、

  • 本当に最初期(静的依存 DLL ロード前)に実行できないと意味がない
  • 一度偽 DLL がロードされてしまっている場合には手遅れ

という制約があるため、万能策ではありません。あくまで「一部シナリオでは多少マシになる補助的な対策」と考えるべきです。

パッケージ形態別のリスク比較

ここまでの内容を踏まえ、代表的な配布形態ごとの DLL ハイジャックリスクを簡単に比較してみます。

配布形態インストール場所の例ユーザーからの書き込みDLL ハイジャックリスク備考
ClickOnce%LocalAppData%\Apps\2.0 など高い(同一ユーザー権限で可能)高い利便性と引き換えに構造上のリスク
MSI(Program Files)C:\Program Files\MyApp標準ユーザーは不可中〜低管理者権限でのインストールが前提
MSIXC:\Program Files\WindowsAppsユーザーからは実質不可低いパッケージ管理とセキュリティが強固

この表からも分かる通り、VERSION.dll ハイジャックのような問題は、ClickOnce のようなユーザー プロファイル配下配布と相性が悪い ことが見て取れます。

実務での対応フロー例

現場でこの問題に直面した場合、次のようなステップで対応を検討するのが現実的です。

  1. 現状調査
    • 対象アプリの配布形態(ClickOnce / MSI / MSIX / その他)を洗い出す
    • Process Explorer や Process Monitor で、起動時にどの VERSION.dll がロードされているかを確認する
    • ユーザー プロファイル配下にアプリ実体が置かれていないかチェック
  2. 配布形態の見直し
    • ClickOnce のままで良いのか、MSI / MSIX へ移行可能かを検討
    • 新規案件は原則として MSIX を第一候補にするガイドラインを作る
  3. OS ミティゲーションの適用
    • 重要アプリに PreferSystem32Images を付与する
    • GPO で全社的に設定するか、アプリ単位で設定するか方針を決める
  4. コード実行制御の強化
    • WDAC / AppLocker の導入計画を立てる
    • まずは監査モードでログを収集し、影響範囲を把握する
  5. アプリ側の補助対策
    • DefaultDllImportSearchPaths / DllImportResolver の導入
    • 可能な範囲で正規 DLL の先行読み込みを検討
    • 全バイナリへのコード署名(Authenticode)を徹底

監査とテスト:本当に VERSION.dll は System32 から読まれているか

対策を施したら、本当に目的どおり動いているかの検証が欠かせません。具体的には、次のようなツールが役立ちます。

  • Process Explorer
    • 対象プロセスを選択し、「DLL」タブからロード済み DLL の一覧を見る
    • version.dll のパスが C:\Windows\System32\version.dll になっているかを確認
  • Process Monitor(ProcMon)
    • プロセス起動前からキャプチャを開始
    • Path に version.dll を含むイベントをフィルタ
    • どのパスを順番に探しに行ったか、最終的にどの DLL を開いたかを確認

特に ProcMon で DLL ロードのトレースを見ると、「アプリ フォルダー → System32 → PATH」といった具合に、実際の検索順序が可視化 されるため、対策の有効性を検証するうえで非常に有用です。

まとめ:環境と配布形態の見直しが最優先

VERSION.dll ハイジャック問題は、一見すると「.NET のバグ」のようにも見えますが、実際には Windows の DLL 検索順序とアプリの配布形態 が重なり合って発生する問題です。

  • Windows は既定で「アプリ実行フォルダー」を DLL 検索の最優先にしている
  • version.dll は KnownDLL ではないため、アプリ フォルダーから横取り可能
  • ClickOnce のようにユーザー プロファイル配下にアプリが置かれると、偽 DLL をドロップしやすい
  • .NET 8/9 の WPF / コンソールでは、Main 到達前のネイティブ依存解決でハイジャックが発生し得る

この構造を踏まえると、もっとも堅実で再現性の高い解決策は次の組み合わせになります。

  • アプリをユーザー書き込み不可の場所に置く
    • MSIX への再パッケージ
    • Program Files への MSI インストール
  • OS のミティゲーションとコード実行制御
    • PreferSystem32Images の適用
    • WDAC / AppLocker による DLL ロード制御
  • アプリ側での補助的な防御
    • DefaultDllImportSearchPaths / DllImportResolver での安全な DLL 解決
    • 正規 DLL の先行読み込み
    • 全バイナリへの署名とログ監査

受け身のパッチワーク的な対処だけでは限界があります。特に企業環境では、配布形態(ClickOnce の見直し) と OS 側ミティゲーションの標準化 をセットで進めることが、VERSION.dll のような DLL ハイジャックを根本的に抑え込むうえで重要です。

実務チェックリスト

最後に、本記事の内容をもとにしたチェックリストを示します。自社アプリや運用環境に照らして、一つずつ確認してみてください。

  • 配布形態を見直したか
    • ClickOnce から MSIX または Program Files への MSI に移行できるか検討した
  • インストール先の権限を確認したか
    • アプリ フォルダーが標準ユーザーから書き込み不可になっている
  • OS ミティゲーションを適用したか
    • 対象 EXE に PreferSystem32Images を有効化した
    • 必要に応じて CWDIllegalInDllSearch も設定した
  • コード実行制御を検討したか
    • WDAC / AppLocker の導入を検討し、少なくとも監査モードでログを取得している
  • アプリ側の設定を見直したか
    • DefaultDllImportSearchPaths 属性を利用している
    • 必要な DLL について DllImportResolver で System32 を明示している
  • 署名と監査を強化したか
    • EXE / DLL に対して Authenticode 署名を行っている
    • Process Explorer / Process Monitor で VERSION.dll の実際のロードパスを確認した

これらを一つずつ潰していくことで、VERSION.dll に限らず、同種の DLL ハイジャック全般に対しても防御力を高めることができます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次