執筆者: ThreatLocker サイバーセキュリティエキスパート、Farid Mustafayev
名前付きパイプは、同一Windowsコンピュータ上で動作するアプリケーション間の通信によく使われる手段です。処理が高速で、OSに標準機能として組み込まれており、Windowsサービス、デスクトップアプリケーション、トレイプロセス、コマンドラインユーティリティ、バックグラウンドエージェント間の通信にも適しています。
典型的な設計では、権限を持つWindowsサービスが名前付きパイプのサーバーとして動作し、ユーザー向けアプリケーションがクライアントとして接続します。両方のプロセスが同一コンピュータ上で動作しているため、開発者はこの通信を内部的なもの、つまり信頼できるものとして扱いがちです。
しかし実際には、そのパイプは、異なるユーザー、セッション、セキュリティコンテキストの下で動作する多数の無関係なプロセスが存在し得る環境からアクセス可能です。
ローカルだからといって信頼できるわけではない
名前付きパイプは、同一コンピュータ上のアプリケーション間通信に使われるため、プライベートなものとして扱われがちです。しかし、この前提は安全ではありません。
Windowsワークステーションでは、LocalSystem、管理者、標準ユーザー、サービスアカウント、さらには別個のインタラクティブセッションやリモートセッションの下でプロセスが動作している可能性があります。また、サードパーティ製ソフトウェア、スクリプト、診断ツール、さらには侵害されたアカウントの下で動作するマルウェアが存在する場合もあります。
パイプ名を知っていて十分なアクセス権を持つプロセスであれば、誰でも接続を試みることができます。Windowsは、開発者がどの実行ファイルにそのパイプを使わせるつもりだったのかを本質的には把握していません。
そのため、名前付きパイプは外部に露出したローカルインターフェースとして扱う必要があります。アプリケーションはリクエストを処理する前に、誰が接続してきたのか、その識別情報に何が許可されているのか、そして渡されたデータが安全かどうかを判断しなければなりません。
識別情報、アクセス制御、権限境界
リスクが最も大きくなるのは、権限を持つWindowsサービスが、権限の低いデスクトップアプリケーションと通信する場合です。
LocalSystemとして動作するサービスは、保護されたファイルやレジストリキーの変更、プロセスの起動、システム設定の変更、他ユーザーのデータへのアクセス、カーネルドライバとの通信などが可能な場合があります。これらの操作が名前付きパイプを通じて公開されると、そのパイプは権限機能へのAPIになってしまいます。
接続が成功したという事実は、クライアントがパイプを開くことを許可されていたことを証明するにすぎません。以下のことは何も証明しません。
- クライアントが期待されたアプリケーションであること
- 接続したユーザーが認可されていること
- 要求された操作が許可されていること
- 渡されたコマンドが安全であること
したがって、パイプの権限は明示的に定義し、必要最小限の識別情報の集合に制限すべきです。Everyone、Authenticated Users、あるいはすべてのインタラクティブユーザーに広範な権限を与えると、無関係なプロセスがパイプに到達できてしまう可能性があります。
認証と認可も明確に分離しておく必要があります。あるユーザーがサービスの状態を照会できても、サービスを停止したり、保護された設定を変更したり、プロセスを起動したり、任意のファイルにアクセスしたりすることまで許可すべきではありません。機微なコマンドは個別に認可すべきです。
なりすまし(インパーソネーション)は、クライアントのセキュリティコンテキスト下で操作を実行する手段として役立ちますが、慎重に扱う必要があります。サーバーは、なりすましが成功したことを確認し、なりすまし状態で実行する作業を最小限にとどめ、必ず元の識別情報に戻さなければなりません。
信頼できないサーバー、コマンド、データ
サーバーがクライアントを検証するのと同様に、クライアントもサーバーを検証しなければなりません。
予測可能なパイプ名は単なる識別子にすぎません。秘密情報ではなく、どのプロセスがそのパイプを作成したかを証明するものでもありません。攻撃者は、正規のサーバーが起動する前に、想定されたパイプ名を使ってパイプを作成し、クライアントを攻撃者制御下のプロセスに接続させることが可能です。
「最初のパイプインスタンス」オプションを使えば、その名前がすでに取得されていることを検知するのに役立ちますが、適切なアクセス制御やサーバーの識別情報検証の代わりにはなりません。
パイプ経由で受信したメッセージも、信頼できない入力として扱う必要があります。認証済みのクライアントであっても、以下のようなものを送信する可能性があります。
- 不正な形式または過大なペイロード
- 無効なファイルパスやレジストリパス
- サポートされていないコマンドの組み合わせ
- 破損したシリアライズオブジェクト
- エラー状態を引き起こすように仕組まれた値
そのような入力をそのままファイル、レジストリ、プロセス、またはコマンドライン操作に変換する権限を持つサービスは、「混乱した代理人(confused deputy)」になりかねません。攻撃者が命令を与え、サービスが権限を提供する形になってしまうのです。
リクエストには、厳格なメッセージフレーミング、サイズの上限、コマンドの許可リスト、スキーマ検証、パスの正規化、操作ごとの認可、そして安全なエラー処理を用いるべきです。
可用性とリモート露出
名前付きパイプのセキュリティは、権限昇格や不正なコマンドの問題だけにとどまりません。
悪意のある、あるいは誤動作するプロセスが、繰り返し接続したり、接続を保持し続けたり、不完全なメッセージを送信したり、過剰なCPU・メモリ・カーネルリソースを消費するリクエストを送信したりする可能性があります。
サーバーは、接続数の制限、タイムアウト、キャンセル処理、メッセージサイズの上限、制御された並行処理、そして必要に応じてレート制限を利用すべきです。
また、すべての名前付きパイプがローカルコンピュータからしかアクセスできないと仮定するのも安全ではありません。Windowsの名前付きパイプは、構成によってはリモートアクセスをサポートすることがあります。
ローカルなプロセス間通信専用のパイプは、NT AUTHORITY\NETWORKのようなネットワーク識別情報を明示的にブロックするか、ローカル限定通信を保証する仕組みを利用すべきです。
正しい脅威モデルはシンプルです。クライアントまたはサーバーの識別情報、権限、要求された操作、メッセージの内容がすべて検証されるまで、あらゆる名前付きパイプの接続は潜在的に悪意あるものとみなすべきです。
名前付きパイプがセキュリティ境界になる場合
名前付きパイプがセキュリティ境界になるのは、その両端のプロセスが異なる権限で動作している、あるいは異なる信頼レベルの下で動作している場合です。
よくある例は、LocalSystemとして動作するWindowsサービスと、標準ユーザーアカウントの下で動作するデスクトップアプリケーションの組み合わせです。サービスは、保護されたファイルやレジストリキーの変更、プロセスの起動、システム全体の設定変更、他ユーザーのデータへのアクセス、カーネルドライバとの通信などが可能な場合があります。デスクトップアプリケーションは通常、それらの操作を直接実行することはできません。
サービスが名前付きパイプ経由でコマンドを受け付けるようになると、そのパイプはこれらの権限機能へのインターフェースになります。パイプの権限設定、識別情報チェック、コマンド検証、認可ロジックにわずかでも弱点があれば、信頼できないローカルプロセスがサービスの権限を悪用できてしまう可能性があります。
接続が成功したという事実は、クライアントが想定されたアプリケーションであることを証明するものではありません。それが証明するのは、接続してきたプロセスがパイプを開くのに十分な権限を持っていたということだけです。同一ユーザーアカウントの下で動作する別のプロセスも、まったく同じアクセス権を持っている可能性があります。したがってサーバーは、プロセス名や実行ファイルのパス、パイプ名の秘匿性に頼るのではなく、接続の背後にあるセキュリティ識別情報を検証しなければなりません。
サーバーはまた、各操作を個別に認可する必要があります。サービスの状態を照会することが許可されているクライアントが、自動的にサービスを停止したり、保護された設定を変更したり、プロセスを起動したり、任意のファイルへのアクセスを要求したりできるようになってはいけません。
認証は「誰が接続したか」を判断するものであり、認可は「その識別情報が何を行えるか」を判断するものです。
この区別は、サーバーがクライアント制御下のパス、コマンドライン引数、レジストリの場所、実行ファイル名、シリアライズされたコマンドを処理する場合に特に重要になります。厳格な検証がなければ、サービスは「混乱した代理人」になり得ます。クライアントが操作内容を選び、権限を持つサービスがそれを実行してしまうのです。
例えば、一見無害に見える次のようなリクエストがあるとします。
Read file: C:\ProgramData\Product\status.json
これは、クライアントがパスを次のように置き換えられる場合、危険なものになり得ます。
Read file: C:\Windows\System32\config\SAM
同じ問題は、プロセスを起動したり、ファイルを削除したり、レジストリ値を更新したり、コンポーネントをインストールしたり、ドライバと通信したりするリクエストにも当てはまります。サービスは、コマンドの構文が正しいかどうかを検証するだけでは不十分です。接続している識別情報が、その特定のリソースに対してまさにその操作を行うことを許可されているかどうかを検証しなければなりません。
したがって、安全な名前付きパイプサーバーは、権限を要するリクエストを実行する前に複数のチェックを適用する必要があります。
- 接続してきたクライアントのWindows識別情報を検証する
- 明示的なパイプセキュリティ記述子を通じてアクセスを制限する
- 各コマンドを個別に認可する
- すべてのパス、引数、識別子、ペイロードサイズを検証する
- サポート外の操作や曖昧な操作を拒否する
- 汎用的な権限機能を公開しないようにする
最後の項目は特に重要です。「任意のレジストリキーにこの値を書き込む」といったコマンドは、「この特定のアプリケーション設定を更新する」という狭く定義されたコマンドに比べ、はるかに大きな攻撃対象領域を生み出します。パイプのプロトコルが汎用的になればなるほど、それはローカルの権限付きAPIに近づいていき、より慎重な保護が必要になります。
正しい設計原則はシンプルです。パイプサーバーは、接続中のクライアントから要求されたという理由だけで操作を実行してはいけません。誰がその要求を行ったのか、その識別情報が認可されているかどうか、そしてそのリクエストが狭く定義されたセキュリティ境界の範囲内に収まっているかどうかを確認した上で初めて、その操作を実行すべきです。
アクセス制御とクライアント認可
名前付きパイプサーバーは、メッセージの処理を開始する前に、誰が接続を許可されるのかを決定しておくべきです。これは、特定のユーザーSID、サービスアカウント、管理者グループ、ログオンセッションといった必要なWindows識別情報にのみアクセスを許可する、明示的なセキュリティ記述子から始まります。
パイプのDACL(裁量アクセス制御リスト)は、名前付きパイプの両端へのアクセスを制御します。クライアントが接続を試みると、Windowsはクライアントのアクセストークンおよびリクエストされたアクセス権をこのDACLと照合します。デフォルトの記述子に頼るのは危険です。その権限がアプリケーションの必要とする範囲より広い可能性があるためです。
パイプへのアクセスが許可されたからといって、利用可能なすべてのコマンドが自動的に認可されるわけではありません。あるクライアントには状態情報の取得は許可されていても、設定の変更、プロセスの起動、保護されたファイルへのアクセスは拒否される場合があります。したがって認可は、接続確立時に一度だけ行うのではなく、機微な操作ごとに行うべきです。
ローカルなアプリケーション間通信の場合、アプリケーションはパイプの反対側にあるプロセスを調べることもできます。
- サーバー側は
GetNamedPipeClientProcessIdを呼び出せる - クライアント側は
GetNamedPipeServerProcessIdを呼び出せる
これらのWindows APIは、接続されたクライアントまたはサーバーに関連付けられたプロセス識別子を返します。これらは、パイプ接続が確立された後にのみ呼び出すべきです。
以下のC#ヘルパーは、ネイティブWindows APIを使って通信相手のPIDを取得します。
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetNamedPipeClientProcessId(SafePipeHandle pipe, out uint clientProcessId);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint serverProcessId);
また、kernel32.dll の別の関数であるQueryFullProcessImageNameを呼び出すことで、PROCESS_QUERY_INFORMATION または PROCESS_QUERY_LIMITED_INFORMATION で開いたプロセスハンドルから実行ファイルのパスを取得することもできます。取得したパスは、期待される実行ファイルの場所と照合し、追加の検証ステップとして利用できます。
サーバー側では、接続を受け入れた直後、コマンドの読み取りや実行を行う前に検証を行うべきです。
期待される実行ファイルは、標準ユーザーが変更できないディレクトリに置いておく必要があります。そうしないと、攻撃者が期待されるパスを保持したままファイルを差し替えることができてしまいます。
より強固な検証を行うには、アプリケーションが実行ファイルのAuthenticode署名を検証したり、承認済みの暗号学的ハッシュと比較したりすることもできます。Windowsは、署名済み実行ファイルを検証するためのWinVerifyTrustを提供しています。
ただし、PIDと実行ファイルパスのチェックは、あくまで補助的な制御にとどめるべきで、主たる認可メカニズムにしてはいけません。セキュリティ研究では、名前付きパイプのクライアントとして報告されるPIDを偽装する方法や、接続済みのパイプハンドルを別のプロセスに移す方法が示されています。返されるPIDは、その接続を開いたプロセスを識別するにすぎず、現在すべてのメッセージを送信しているプロセスがそれであることまで証明するものではありません。
したがって、安全な実装は複数の制御を組み合わせるべきです。
- 明示的かつ厳格なパイプDACL
- クライアントのWindows識別情報またはSIDの検証
- 権限を要する各コマンドの認可
- メッセージ内容の厳格な検証
- 多層防御としてのPID、実行ファイルパス、署名、ハッシュの検証(任意)
識別情報の検証に失敗した場合、あるいは検証を完了できない場合は、接続を拒否すべきです。権限を持つサービスは、パイプの接続自体が成功しただけでリクエストの受け入れにフォールバックしてはいけません。
なりすましと権限を要する操作
名前付きパイプサーバーは、接続してくるクライアントよりも多くの権限を持って動作することがよくあります。例えば、WindowsサービスはLocalSystemとして動作する一方、クライアントアプリケーションは標準ユーザーアカウントの下で動作している場合があります。サービスがすべての要求された操作を自身の識別情報の下で実行してしまうと、クライアントは本来直接アクセスできないファイル、レジストリキー、プロセス、システムリソースに間接的にアクセスできてしまう可能性があります。
名前付きパイプのなりすましを使うと、サーバーは接続してきたクライアントのセキュリティコンテキストの下で一時的にコードを実行できます。その際、Windowsはサービスアカウントのトークンではなく、クライアントのトークンを使ってリソースアクセスを評価します。
.NETでは、NamedPipeServerStream.RunAsClient が、接続してきたクライアントになりすますための制御された手段を提供しています。
server.WaitForConnection();
server.RunAsClient(() =>
{
string path = @"C:\ProgramData\MyApplication\settings.json";
// Access is checked using the connected client's identity.
string content = File.ReadAllText(path);
ProcessClientData(content);
});
このアプローチが有効なのは、クライアント自身のWindowsアカウントがすでに権限を持っている場合にのみ、その操作を実行できるようにしたいケースです。例えば、ユーザー所有のファイルの読み取り、ユーザー固有のレジストリキーへのアクセス、クライアントが保護されたリソースにアクセス権を持つかどうかの検証などで、なりすましが利用できます。
ただし、なりすましは認可の代わりにはなりません。サーバーは、クライアントがその操作を要求することを許可されているかどうかを、それでも検証すべきです。なりすましは、Windowsがアクセスチェックを行う際のセキュリティコンテキストを変えるだけであり、そのコマンド自体が適切かどうかを判断するものではありません。
権限を持つサービスはまた、クライアントの識別情報とサービスの識別情報とを不必要に切り替えることも避けるべきです。例えば、サービスにファイルを読み込ませ、その内容を設定としてインストールさせるリクエストを考えてみましょう。
そのファイルはクライアントになりすました状態で読み込まれるかもしれませんが、インストールはその後LocalSystemの下で行われるかもしれません。この場合、リクエストの一部がなりすまし状態で処理されたとしても、クライアントは依然として権限を要する操作に影響を与えることができてしまいます。
より安全な設計は、操作を明確に定義された段階に分離することです。
- クライアントを認証・認可する
- クライアント制御下のすべてのパス、引数、データを検証する
- クライアントの権限を使うべき操作についてのみなりすましを行う
- 狭く定義された権限を要する作業を行う前にサービスの識別情報に戻る
- なりすまし段階から権限段階へ渡るデータを再検証する
なりすましの範囲はできる限り小さくすべきです。長時間実行される処理、コールバック、非同期操作、無関係なサービスロジックは、クライアントの識別情報の下で実行すべきではありません。
ネイティブWindows APIを使う場合も、同じパターンが当てはまります。
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ImpersonateNamedPipeClient(SafePipeHandle pipe);
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool RevertToSelf();
サーバーはImpersonateNamedPipeClient が成功したかどうかを確認し、必ずfinally ブロックの中でRevertToSelf を呼び出さなければなりません。
if (!ImpersonateNamedPipeClient(server.SafePipeHandle))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
try
{
// Runs under the connected client's security context.
PerformClientScopedOperation();
}
finally
{
if (!RevertToSelf())
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
失敗時の処理は非常に重要です。なりすましが失敗したにもかかわらずサービスが処理を続行してしまうと、その操作はサービス本来の権限を持つ識別情報の下で実行される可能性があります。したがって、なりすましの試行が失敗した場合は、サービスアカウントへ静かにフォールバックするのではなく、そのリクエストを拒否しなければなりません。
同じ原則は、なりすましの後にも当てはまります。アプリケーションは、次のクライアントを処理したり無関係な作業を行ったりする前に、確実に元の識別情報を復元しなければなりません。そうしなければ、後続の操作が誤って前のクライアントのコンテキストの下で実行されてしまう可能性があります。
権限を要するパイプコマンドは、狭く目的特化したものにすべきです。例えば、次のようなコマンドは、
Write any value to any registry key
次のようなコマンドに比べ、はるかに大きな攻撃対象領域を生み出します。
Update the application's approved policy setting
サービスは、単にそれらの操作を実行できるからという理由だけで、汎用的なファイルアクセス、レジストリ変更、プロセス作成、コマンド実行を公開すべきではありません。権限を要する各コマンドは、どのリソースにアクセスできるか、どの値が受け入れられるか、どのクライアント識別情報がそれを呼び出せるかを、正確に定義すべきです。
なりすましは、より広範なセキュリティ設計の中の一つの層として使われたときに、最も効果を発揮します。サーバーは、制限の厳しいパイプ権限の適用、接続してきたクライアントの検証、各コマンドの認可、すべてのリクエストの検証、そして権限を要する操作の範囲を狭く保つことを、それでも継続すべきです。
パイプメッセージを信頼できない入力として扱う
名前付きパイプに接続しているプロセスを検証しても、そのメッセージが安全になるわけではありません。正規のアプリケーションが侵害されていたり、脆弱性を抱えていたり、ユーザー制御下のデータをパイプに渡してしまったりすることもあります。悪意のあるプロセスが、有効なパイプハンドルを取得したり継承したりすることも考えられます。
このため、名前付きパイプを通じて受信したすべてのメッセージは、信頼できない入力として扱うべきです。サーバーは、権限を要する操作を実行する前に、メッセージの構造とそれが要求する操作の両方を検証する必要があります。
危険な実装では、リクエストをデシリアライズしてそのまま実行してしまうことがあります。
PipeRequest request = Deserialize(data);
File.WriteAllText(request.Path, request.Content);
request が期待どおりの構造を持っていたとしても、Path やContent のような値は依然としてクライアントの制御下にあります。したがって、権限を持つサービスが、アプリケーションディレクトリ外のファイルを上書きしたり、保護された設定を変更したり、過剰なディスク容量を消費させられたりする恐れがあります。
より安全なアプローチは、狭く定義されたコマンドを公開し、すべてのフィールドを検証することです。
private static void ProcessRequest(PipeRequest request)
{
if (request == null)
throw new InvalidDataException("The request is missing.");
switch (request.Command)
{
case PipeCommand.UpdateConfiguration:
ValidateConfiguration(request.Configuration);
UpdateApprovedConfiguration(request.Configuration);
break;
case PipeCommand.GetStatus:
ReturnApplicationStatus();
break;
default:
throw new InvalidDataException("Unsupported command.");
}
}
プロトコルでは、以下のような汎用的な操作は避けるべきです。
WriteFile(path, content)
StartProcess(path, arguments)
SetRegistryValue(key, name, value)
ExecuteCommand(command)
これらのコマンドは、クライアントが権限を要する操作とその対象の両方を選択できてしまいます。代わりに、許可される挙動をサーバー側が制御する、アプリケーション固有のリクエストを優先すべきです。
UpdateApplicationConfiguration(configuration)
RequestApplicationRepair()
InstallApprovedUpdate(updateId)
GetServiceStatus()
メッセージの構造とサイズを検証する
アプリケーションが意図的にメッセージ送信モードを使わない限り、名前付きパイプの接続はバイトストリームです。1回のRead 呼び出しがアプリケーションメッセージ全体を返すことは保証されておらず、サーバーは読み取り境界がリクエスト境界と一致すると仮定すべきではありません。
プロトコルでは、固定サイズのヘッダーに続けて長さ接頭辞付きのペイロードを配置するなど、明示的なメッセージフレーミングを定義すべきです。
[Version][Command][Payload Length][Payload]
宣言された長さは、メモリを割り当てたりペイロードを読み込んだりする前に検証しなければなりません。
private const int MaxMessageSize = 1024 * 1024;
private static async Task<byte[]> ReadPayloadAsync(
Stream pipe,
int payloadLength,
CancellationToken cancellationToken)
{
if (payloadLength < 0 || payloadLength > MaxMessageSize)
throw new InvalidDataException("Invalid payload length.");
byte[] payload = new byte[payloadLength];
int offset = 0;
while (offset < payload.Length)
{
int read = await pipe.ReadAsync(
payload,
offset,
payload.Length - offset,
cancellationToken);
if (read == 0)
throw new EndOfStreamException(
"The pipe was closed before the message was complete.");
offset += read;
}
return payload;
}
最大サイズを設定していないと、攻撃者が非常に大きなペイロードを宣言し、サービスに過剰なメモリを確保させることができてしまいます。アプリケーションはまた、コレクションのサイズ、文字列の長さ、ネストの深さ、デシリアライザが受け入れるオブジェクトの数も制限すべきです。
型だけでなく値も検証する
デシリアライズが成功したという事実は、ペイロードが期待される型のオブジェクトに変換できたことを証明するにすぎません。それらの値が許容できるものであることまでは証明しません。
例えば、ファイルパスは正規化した上で、承認済みディレクトリと照合すべきです。
private static string ValidatePath(
string suppliedPath,
string allowedDirectory)
{
string fullPath = Path.GetFullPath(suppliedPath);
string fullDirectory = Path.GetFullPath(allowedDirectory)
.TrimEnd(Path.DirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
if (!fullPath.StartsWith(
fullDirectory,
StringComparison.OrdinalIgnoreCase))
{
throw new UnauthorizedAccessException(
"The requested path is outside the allowed directory.");
}
return fullPath;
}
同じ原則は、レジストリパス、プロセス引数、URL、識別子、更新パッケージ、設定値にも当てはまります。サーバーは、既知の危険な値をブロックしようとするのではなく、許可リストや狭く定義された範囲に照らして各値を検証すべきです。
パスのチェックでは、シンボリックリンク、ジャンクション、リパースポイント、そしてTOCTOU(検査時と使用時の間の競合状態)にも注意が必要です。機微なファイル操作については、文字列としてのパス検証だけでは不十分な場合があります。
不正なリクエストを安全に拒否する
不正な形式や認可されていないメッセージは、部分的な処理を続行することなく拒否すべきです。サーバーは、スタックトレース、内部パス、セキュリティトークン、詳細な例外情報をクライアントに返すことは避けるべきです。
パイプ経由で送信するエラーには、小規模で制御されたレスポンスコードの集合を使うべきです。
public enum PipeResult
{
Success,
InvalidRequest,
Unauthorized,
UnsupportedCommand,
InternalError
}
詳細な診断情報は保護されたサービスログに記録し、クライアントには失敗を処理するために必要な情報のみを返すようにします。
したがって、各リクエストは以下のような予測可能な一連の流れをたどるべきです。
- 制限された範囲内でメッセージを読み取る
- プロトコルバージョンとメッセージ構造を検証する
- 接続中のクライアントを認証・認可する
- クライアント制御下のすべての値を検証する
- 狭く定義された操作のみを実行する
- 制御されたレスポンスを返す
名前付きパイプは、あくまで伝送手段にすぎません。それ自体は、データを信頼できるものにするわけでも、メッセージフレーミングの正しさを保証するわけでも、接続してきたプロセスが悪意あるリクエストを送信することを防ぐわけでもありません。プロトコルを強制し、そこを通じて公開されるすべての操作を保護する責任は、受信側のアプリケーションにあります。
サービス拒否とリモートアクセスのリスク
名前付きパイプのエンドポイントが、不正なコマンドに対しては保護されていても、サービス拒否攻撃には依然として脆弱である場合があります。攻撃者は、必ずしも権限を要する操作を実行するための許可が必要なわけではありません。正規のアプリケーションがサービスと通信できないようにするだけで、製品を機能不全に陥らせるのに十分なことがあります。
悪意のある、あるいは誤動作するプロセスが、パイプに繰り返し接続し、利用可能なすべてのインスタンスを占有し、メッセージを完全に送信しないまま接続を保持し続け、切断されるたびに再接続を繰り返す可能性があります。すべてのサーバーインスタンスが占有されてしまうと、正規のクライアントが接続を確立できなくなる恐れがあります。
同じリスクは、接続が受け入れられた後にも存在します。クライアントは、極端にゆっくりとデータを送信したり、過大なペイロードを宣言したり、メッセージの途中で送信を止めたり、正当ではあるものの処理コストの高いリクエストでサーバーを溢れさせたりする可能性があります。制限がなければ、こうした挙動はスレッド、タスク、メモリ、CPU時間、ハンドル、内部リクエストキューを消費してしまいます。
名前付きパイプのバッファは、カーネルの非ページプールも消費します。したがって、パイプインスタンスの数やバッファリングされるデータ量は、システムリソースによって制限されます。無制限にインスタンスを作成したり、不必要に大きなバッファを選択したりすると、リソース枯渇の一因になり得ます。
防御的なサーバーは、以下について明確な上限を設定すべきです。
- 同時接続数とパイプインスタンス数
- メッセージおよびフィールドのサイズ
- リクエストの確立と完了までに許容される時間
- クライアントごとの保留中リクエスト数
- 同時に実行できる処理コストの高い操作数
- リクエストの頻度
- 内部キューの容量
ブロッキング操作はキャンセルに対応させ、クライアントがさらにデータを送信してくるのを無期限に待つべきではありません。クライアントが時間、サイズ、またはリクエスト数の制限を超えた場合、サーバーはその接続を速やかに終了し、リソースを解放すべきです。
制限は、コストの高い処理が始まる前に適用すべきです。例えば、サーバーは、対応するバッファを割り当てる前に、過大な宣言済みペイロードサイズを拒否すべきです。同様に、認可と基本的なリクエスト検証は、ディスクアクセス、プロセス作成、暗号処理、データベースクエリ、カーネルドライバとの通信よりも前に行うべきです。
アプリケーションはまた、接続ごとに無制限のワーカースレッドを1つずつ作成することも避けるべきです。制限された並行処理モデルであれば、大量のクライアント接続がプロセスのスレッドプールを枯渇させたり、制御不能なバックログを生じさせたりするのを防げます。レート制限は、アプリケーションのアーキテクチャに応じて、接続ごと、プロセスごと、ユーザー識別情報ごと、あるいはログオンセッションごとに適用できます。
ただし、可用性に関する制御は、クライアントのPIDのみに頼るべきではありません。プロセスは何度でも再起動できますし、複数のプロセスを使ったり、同一ユーザーアカウントの下で接続を確立したりすることも可能です。複数のシグナルを組み合わせて検討する必要がある場合もあり、クライアントごとの制御があってもサーバーはグローバルな上限を維持しなければなりません。
もう一つ見落とされがちなリスクが、リモートアクセス可能性です。Windowsの名前付きパイプは、必ずしもローカルコンピュータ内の通信に限定されるわけではありません。ネットワーク越しのコンピュータ間通信にも対応可能であり、Microsoftは、Windows Serverサービスが稼働している場合、名前付きパイプはリモートからアクセス可能になり得ると述べています。
つまり、ローカル用のパイプ名を使っているというだけでは、それ自体でローカル限定の通信が保証されるわけではありません。ローカルサービスとローカルデスクトップアプリケーションの間の通信を意図したパイプは、その要件を明示的に強制すべきです。
ネイティブのパイプサーバーでは、PIPE_REJECT_REMOTE_CLIENTS を指定でき、これによりWindowsはリモート接続を自動的に拒否します。このオプションを指定しない場合、リモートクライアントが受け入れられ、パイプのセキュリティ記述子に照らして評価される可能性があります。
パイプのアクセス制御リストでは、NT AUTHORITY\NETWORK 識別情報へのアクセスを拒否することもできます。アクセスを1つのインタラクティブセッションに限定する必要がある場合は、ローカルユーザーとリモートユーザーが共有する広範なグループではなく、適切なログオンSIDにアクセスを許可すべきです。
これらの保護策は、代替手段として扱うのではなく、組み合わせて使うべきです。
- APIがサポートしている場合は、パイプ作成時にリモートクライアントを拒否する
- パイプのセキュリティ記述子でネットワーク識別情報を拒否する
- 必要なユーザーまたはログオンセッションにのみアクセスを許可する
- 接続してきたプロセスの識別情報を検証する
- 接続数、タイムアウト、サイズ、並行処理の各制限を適用する
サービス拒否対策とリモートアクセス制限は、パイプのセキュリティモデルの一部です。名前付きパイプサーバーは、不正なコマンドを拒否しているというだけでは安全とは言えません。正規のクライアントに対して利用可能な状態を維持し、接続がローカルコンピュータの外部から発生することを許可するかどうかも強制する必要があります。
安全な名前付きパイプアーキテクチャの設計
安全な名前付きパイプの設計は、公開する操作の数と、クライアント制御下のデータを直接処理する権限コードの量を、両方とも最小限に抑えるべきです。パイプは、OSへの汎用インターフェースとしてではなく、狭い通信境界として機能すべきです。
実用的なアーキテクチャでは、接続処理、検証、認可、権限を要する実行を分離します。

クライアントは、汎用の権限機能と直接通信すべきではありません。代わりに、狭く定義されたリクエストをパイプのゲートウェイに送信すべきです。ゲートウェイはメッセージ形式を検証し、構造化されたリクエストのみを認可レイヤーに渡します。権限を要する作業は、すべてのセキュリティチェックに合格した後にのみ開始されます。
パイプのプロトコルを狭く保つ
パイプのプロトコルは、OSのプリミティブではなく、ビジネス上の操作を公開すべきです。
例えば、あるアプリケーションが、ポリシーの再読み込みを要求したり、承認済みの更新をインストールしたり、サービスの状態を取得したり、特定の設定値を更新したりする必要が正当にある場合があります。通常、任意のファイルへの書き込み、任意のレジストリキーの変更、任意の実行ファイルの起動、コマンドライン命令の実行といった、無制限のコマンドを必要とすることはありません。
操作を狭くしておけば、認可と検証が実用的になります。サーバーは、各コマンドがどのリソースにアクセスできるか、どのフィールドが期待されているか、どのクライアント識別情報がそれを呼び出せるかを把握できます。
- 明示的なプロトコルバージョン
- 固定されたリクエスト種別の集合
- 一意のリクエスト識別子
- 制限されたペイロードサイズ
- 予測可能なレスポンスおよびエラー形式
- サポート外または不正な形式のメッセージに関する明確な規則
サーバーは、未知のバージョン、コマンド、フィールド、状態を、寛容に解釈しようとするのではなく、拒否すべきです。
接続へのアクセスとコマンドの権限を分離する
パイプに接続する権限があるからといって、それを通じて公開されるすべての機能を利用できる権限があるとみなすべきではありません。
パイプのセキュリティ記述子は、どのWindows識別情報が接続を確立できるかを制限すべきです。接続後、サーバーはクライアントを識別し、各コマンドを個別に認可すべきです。
こうすることで、同一のサービスを通じて異なる信頼レベルをサポートすることが可能になります。例えば、一般ユーザーには状態の照会を許可する一方、保護された設定の変更は管理者または信頼された管理プロセスのみに許可する、といった設計です。
特に機微な操作については、別々の名前付きパイプを使う方が望ましい場合があります。
Product.Status Read-only information
Product.UserActions Limited user operations
Product.Admin Administrative operations
Product.Internal Trusted component communication
それぞれのパイプに独自のアクセス制御ルール、メッセージ制限、対応コマンドセットを持たせることができます。これは通常、すべての操作を一つの巨大なプロトコルの背後にまとめ、内部のコマンドチェックだけに全面的に頼るよりも安全です。
ただし、パイプを追加すること自体が自動的にセキュリティを向上させるわけではありません。新しいエンドポイントを一つ増やすごとに攻撃対象領域が広がり、それぞれを独立して保護する必要があります。パイプの分離は、それらが実質的に異なる信頼境界を表す場合にのみ行うべきです。
複数層の識別情報検証を使う
単一の識別情報チェックを、決定的なものとして扱うべきではありません。
アーキテクチャでは、以下を組み合わせることができます。
- 制限の厳しいパイプDACL
- 接続したユーザーのSID
- クライアントのログオンセッション
- 通信相手のプロセスID
- 実行ファイルのパス
- 実行ファイルのデジタル署名
- アプリケーションレベルのチャレンジ・レスポンス
- 操作ごとの認可
プロセスIDと実行ファイルパスのチェックは、想定外のアプリケーションを検知するのに役立ちますが、あくまで多層防御の一環にとどめるべきです。プロセスは変化し得ますし、ハンドルは継承されたり移されたりし得ますし、信頼されたプロセス自体が侵害されている可能性もあります。
最も強固な判断は、実行ファイル名の見た目だけでなく、Windowsのセキュリティ識別情報と狭く定義された権限に基づいて行うべきです。
権限を要する実行を分離する
パイプメッセージの読み取りを担当するコンポーネントは、可能な限り権限を要する処理を行わないようにすべきです。
接続処理、デシリアライズ、フレーミング、基本的な検証は、攻撃者が制御可能な入力にさらされています。このロジックを権限を要する操作から分離しておくことで、パーサーやプロトコルの脆弱性がもたらす影響を軽減できます。
権限を要する操作を行うレイヤーは、検証済みで強く型付けされた命令のみを受け取るべきです。生のメッセージバッファ、任意のパス、コマンドライン、シリアライズされたオブジェクトを、クライアントから直接受け取るべきではありません。
特に機微なアプリケーションでは、パイプのゲートウェイと権限を要するワーカーを別々のプロセスに分離することで、さらに設計を進めることができます。ゲートウェイは低い権限で動作させ、受信リクエストを検証した上で、承認された操作のみを2つ目の制限されたチャネルを通じて、より小規模な権限を持つコンポーネントへ転送できます。
この追加のプロセス境界は複雑さを増しますが、LocalSystem や他の強力なアカウントとして動作する、攻撃に晒されるコードの量を大幅に減らすことができます。
すべての接続のライフタイムを制御する
受け入れた各接続には、明確で範囲の定まったライフサイクルを持たせるべきです。
- 接続を受け入れる
- 通信相手を識別・検証する
- 接続レベルの制限を適用する
- 制限された範囲内でリクエストを読み取る
- 要求された操作を認可・検証する
- 承認された操作を実行する
- 制御されたレスポンスを返す
- 切断するか、次の制限されたリクエストを待つ
サーバーは、未認証のクライアントに接続を無期限に保持させるべきではありません。アイドルタイムアウト、リクエストの期限、接続数の上限、キャンセル処理、制限されたキューは、設計の初期段階からアーキテクチャに組み込んでおくべきです。
長時間実行される操作は、パイプの読み取り側を不必要にブロックし続けるべきではありません。サービスはリクエストを受け付け、操作識別子を割り当て、クライアントが別個のステータスリクエストを通じて進捗を照会できるようにすることができます。これにより、一つの接続がサーバーリソースを独占してしまうことを防げます。
サーバーを権威ある存在にする
クライアントは結果を要求し、その結果をどのように達成するかはサーバー側が決めるべきです。
例えば、クライアントは識別子を指定して承認済みの更新のインストールを要求することができます。サーバーは、パッケージの場所を解決し、その署名を検証し、インストールコマンドを決定し、許可されたインストール先を強制すべきです。クライアントが、実行ファイルのパス、ダウンロードURL、コマンドライン引数、対象ディレクトリを指定すべきではありません。
こうすることで、セキュリティ上機微な判断を信頼できるコンポーネント内部に留め、権限境界を越えるクライアント制御下の値の数を減らすことができます。
サーバーはまた、クライアントが以前に下したセキュリティ上の判断を信頼することも避けるべきです。「このユーザーは管理者である」「このファイルは署名されている」「このパスは安全である」といった主張は、サーバー側で独立して検証しなければなりません。
セキュリティ上重要な活動を監査する
安全なアーキテクチャは、機微なデータを露出させることなく、不審な挙動を調査するのに十分な情報を記録すべきです。
- 拒否された接続
- 失敗した識別情報チェック
- 認可されていないコマンド
- 不正な形式または過大なメッセージ
- 繰り返し発生するタイムアウト
- 想定外のプロセス識別情報
- 権限を要する操作とその結果
- 異常な接続数またはリクエスト頻度
ログには、Windowsのユーザー、セッション、通信相手のPID、コマンドの種類、そして適切な場合には結果を記録すべきです。生の秘密情報、認証トークン、機微なペイロード全体をログに書き込むべきではありません。
繰り返し発生する失敗は攻撃を示している場合もありますが、欠陥のあるクライアントのバージョンやデプロイの問題を示している場合もあります。したがって監査データは、セキュリティ調査と運用上のトラブルシューティングの両方を支援するものであるべきです。
推奨されるアーキテクチャ
ほとんどの権限を持つWindowsサービスのシナリオでは、防御可能な設計は次の要素で構成されます。
- 明示的なセキュリティ記述子を持つ、ローカル限定の名前付きパイプ
- 実質的に異なる信頼レベルごとの別個のエンドポイント
- Windows識別情報と通信相手プロセスの両方の検証
- バージョン管理され、長さが制限された、アプリケーション固有のプロトコル
- 各コマンドの認可
- クライアント制御下のすべての値の厳格な検証
- 短く慎重に制御されたなりすましの範囲
- 小規模な権限実行レイヤー
- 制限された接続数、キュー、実行時間
- セキュリティに重点を置いた監査ログ
中心となる原則は、名前付きパイプが信頼レベル間で公開するインターフェースを最小限にすべきだということです。安全なアーキテクチャは、任意の権限操作を安全にしようとするものではありません。そもそも任意の権限操作を公開しないようにするものです。
名前付きパイプセキュリティの実践的チェックリスト
アプリケーションの機能を名前付きパイプを通じて公開する前に、設計が以下の各項目に対応していることを確認してください。
- 信頼境界を定義する。 特に片方が高い権限で動作している場合、パイプを外部に露出したローカルインターフェースとして扱う。
- パイプへのアクセスを明示的に制限する。 デフォルトの権限や
Everyoneのような広範なグループに頼るのではなく、狭いセキュリティ記述子を使う。 - リモートクライアントを拒否する。 パイプをローカル限定の通信用に構成し、リモートアクセスが不要な場合はネットワーク識別情報を拒否する。
- 両端を検証する。 接続してきたWindows識別情報をチェックし、適切な場合には通信相手のPID、実行ファイルのパス、デジタル署名を確認する。
- パイプ名を信頼しない。 予測可能な名前はエンドポイントを識別するものであり、それを作成したプロセスを認証するものではない。
- すべてのコマンドを認可する。 接続の権限が、サーバーが公開するすべての操作へのアクセスを許可するものであってはならない。
- プロトコルを狭く保つ。 任意のファイル、レジストリ、プロセス、コマンド実行機能ではなく、アプリケーション固有の操作を公開する。
- すべてのメッセージを信頼できないものとして扱う。 フレーミング、プロトコルバージョン、コマンド種別、ペイロードサイズ、フィールド値、パス、オブジェクト数を検証する。
- 制限を早期に適用する。 メモリを割り当てたりコストの高い処理を開始したりする前に、無効なサイズやサポート外のリクエストを拒否する。
- なりすましは慎重に使う。 その操作がクライアントの権限を使うべきものである場合にのみなりすまし、範囲を小さく保ち、なりすましに失敗した場合は失敗側に倒す(フェイルクローズ)。
- 権限を要する実行を分離しておく。 解析と検証を、権限を要する操作を実行するコードから分離する。
- リソース使用量を制御する。 同時接続数、保留中リクエスト数、アイドル時間、実行時間、キューの深さ、リクエスト頻度を制限する。
- 制御されたエラーを返す。 スタックトレース、内部パス、トークン、その他の機微な実装詳細を露出させない。
- セキュリティ上重要なイベントを監査する。 拒否された接続、失敗した識別情報チェック、不正な形式のリクエスト、認可されていないコマンド、権限を要する操作を記録する。
- 失敗側に倒す(フェイルクローズ)。 識別情報、認可、検証、なりすましのいずれかを確実に完了できない場合は、そのリクエストを拒否する。
安全な名前付きパイプの実装は、単一の防御策に依存すべきではありません。最も強固な設計は、制限の厳しいアクセス制御、エンドポイントの検証、操作レベルの認可、厳格な入力検証、制限されたリソース使用量、そして狭く範囲を定めた権限機能を組み合わせたものです。
ThreatLockerが名前付きパイプへの攻撃をどのように防御できるかについて詳しく知るには、デモを予約してください。
著者について:
Farid Mustafayevは、ThreatLockerのソフトウェア開発者で、Microsoft Windowsサービス開発とサイバーセキュリティを専門としています。15年以上の業界経験を持ち、ASP.NET WebAPI、Windowsサービス、Windows Forms、WPF、RESTful API、そして低レベルのWindows内部構造を含む.NET技術に深い専門知識を持っています。カーネルレベルの統合やカスタムドライバの拡張を含め、マルウェアやランサムウェアからシステムを保護するために設計されたWindowsサービスの開発と堅牢化を主導してきました。
以前、Mustafayev氏はテクニカルリードとして、アーキテクチャ上の意思決定を主導し、開発者を指導し、拡張性と保守性に優れたシステムを構築してきました。彼の経験には、マイクロサービスベースのアーキテクチャや、可用性・パフォーマンス・セキュリティを重視した、分散環境全体にわたるAWS上のクラウドネイティブソリューションも含まれます。