.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 検索パスを変更しても「間に合わない」状況が発生します。
事象と環境の整理
| 項目 | 内容 |
|---|---|
| OS | Windows 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 | アプリケーションの実行フォルダー | ここが今回の攻撃ポイント |
| 2 | System32(%SystemRoot%\System32) | 正規のシステム DLL の多くが存在 |
| 3 | 16 ビット システムフォルダー | 互換性用(通常あまり意識しない) |
| 4 | Windows フォルダー | %SystemRoot% |
| 5 | 現在の作業ディレクトリ(CWD) | オプションや設定により除外可能 |
| 6 | PATH 環境変数に登録されたフォルダー | 順番に探索 |
ここから分かる通り、アプリケーションの実行フォルダーが最優先 であるため、そこに偽 DLL が置けてしまうと、System32 の正規 DLL よりも先に読み込まれます。
KnownDLL と VERSION.dll の立ち位置
Windows には KnownDLL と呼ばれる仕組みがあります。これは、kernel32.dll や user32.dll など、ほぼすべてのプロセスが使う中核 DLL 群を OS が特別扱いするもので、以下の特徴があります。
- 特定の DLL 名は「KnownDLL リスト」に登録されている
- これらは System32 の DLL が共有セクションとしてマップされる
- 結果として、アプリ フォルダーに置いても 横取りできない
ところが、今回問題になっている version.dll は、代表的な KnownDLL には含まれていません。そのため、通常の DLL と同じ検索順序 が適用され、アプリ実行フォルダーが最優先されてしまいます。
| 種類 | 例 | アプリ フォルダーからのハイジャック |
|---|---|---|
| KnownDLL | kernel32.dll / user32.dll など | 基本的に不可(System32 固定) |
| 非 KnownDLL のシステム DLL | version.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 が問題の原因であれば有効) |
| PreferSystem32Images | DLL 解決全般(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 | 標準ユーザーは不可 | 中〜低 | 管理者権限でのインストールが前提 |
| MSIX | C:\Program Files\WindowsApps | ユーザーからは実質不可 | 低い | パッケージ管理とセキュリティが強固 |
この表からも分かる通り、VERSION.dll ハイジャックのような問題は、ClickOnce のようなユーザー プロファイル配下配布と相性が悪い ことが見て取れます。
実務での対応フロー例
現場でこの問題に直面した場合、次のようなステップで対応を検討するのが現実的です。
- 現状調査
- 対象アプリの配布形態(ClickOnce / MSI / MSIX / その他)を洗い出す
- Process Explorer や Process Monitor で、起動時にどの VERSION.dll がロードされているかを確認する
- ユーザー プロファイル配下にアプリ実体が置かれていないかチェック
- 配布形態の見直し
- ClickOnce のままで良いのか、MSI / MSIX へ移行可能かを検討
- 新規案件は原則として MSIX を第一候補にするガイドラインを作る
- OS ミティゲーションの適用
- 重要アプリに PreferSystem32Images を付与する
- GPO で全社的に設定するか、アプリ単位で設定するか方針を決める
- コード実行制御の強化
- WDAC / AppLocker の導入計画を立てる
- まずは監査モードでログを収集し、影響範囲を把握する
- アプリ側の補助対策
- 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 ハイジャック全般に対しても防御力を高めることができます。

コメント