更新履歴(5件・最終更新 2026年08月23日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- 昇格がプロセス境界の話であること、モデル選択の考え方、runas起動とパイプACLの設計判断、確認手順などを図でも追えるように、Mermaid図を17点追加しました(地の文500〜750字につき1図の規約に合わせたものです)。あわせて既存の2図を含めて図番号を通しで振り直しました。本文の文章は変えていません。
- 記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
- 第4章のテキスト図を、整合性レベルと検証箇所まで示した構成図に描き直しました。あわせて、起動から終了までの手順と確認の対応をシーケンス図で追加しています。本文の説明は変えていません。
- 外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
- 実際に分離できているかを確認する節を新設しました(UIが非昇格か、helper起動時にだけ確認画面が出るか、失敗すべきときに失敗するか)。試す前に用意するものと評価用VMを勧める注意、整合性レベルと標準ユーザー・昇格プロセスの対応、誰がどのトークンでパイプに接続するかの表、読み方ガイドを追加しました。
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589652)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「Windowsアプリで「管理者権限が必要な処理だけ」を分離する具体的な書き方」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589652 https://comcomponent.com/blog/2026/03/16/001-windows-admin-broker-deep-dive/
- DOI(最新版)
- 10.5281/zenodo.21589652
- DOI(この版)
- 10.5281/zenodo.22064164
以前書いた「Windowsアプリ開発における最低限のセキュリティを守るためのチェックリスト」では、asInvoker を基本にし、管理者権限が必要な処理だけを分離する、という線を書きました。
今回はその部分を、実際にどう書くかまで踏み込みます。
Windows アプリでは、同じプロセスの中の一部の処理だけを都合よく「管理者として実行」することはできません。 昇格はプロセス境界の話なので、必要なのは「その処理だけを別の実行単位に切り出す設計」です。
flowchart TB
accTitle: 昇格はプロセス境界の話
accDescr: 同じプロセスの中の一部の処理だけを管理者として実行することはできず、昇格はプロセス境界の話なので、その処理だけを別の実行単位に切り出す設計が必要であることを示す図。
wish1["一部の処理だけ管理者にしたい"] -.->|"同一プロセス内では不可能"| in1["同じプロセスの中で昇格"]
wish1 -->|"できるのはこちら"| cut1["処理を別の実行単位に切り出す"]
図1: 昇格は関数単位ではなくプロセス単位なので、切り出す設計だけが成立する。
この記事では、以下の順で進めます。
- まず前提
- どの分離モデルを選ぶか
- いちばん実務で使いやすい
asInvoker+ 管理者 helper EXE の形 - 実装時に外したくない罠
- 具体的なコード例
コード例は .NET 8 / Windows デスクトップアプリ を前提にしています。 UI フレームワークは WPF / WinForms / WinUI のどれでもよく、違いが出るのは UI 側のイベントハンドラくらいです。
なお、この記事に登場するコードは、ビルド・実行できるサンプル一式(共通契約ライブラリ、UI / 管理者 helper のデモ、Linux でも実行できるユニットテスト)として GitHub で公開しています。
windows-admin-broker-deep-dive - komurasoft-blog-samples (GitHub)
この記事の読み方
長い記事なので、先に導線を置いておきます。
| 知りたいこと | 読むところ |
|---|---|
| 分離モデルの選び方だけ知りたい | 1〜4 章(結論、4 モデルの比較、おすすめの形) |
| 設計上の判断とその理由 | 5 章(allowlist、パス固定、runas、pipe の ACL、PID 検証) |
| 実装のコード | 7〜14 章(構成、マニフェスト、共通契約、UI 側、helper 側) |
| 正しく分離できたかの確認方法 | 15.6 |
| やってはいけない形 | 16 章 |
コード全文は GitHub のサンプルにも同じものが入っています。設計の話だけ拾いたいなら 1〜5 章と 15〜16 章、実装まで見るなら通しで、という読み方ができます。
試す前に用意するもの
このサンプルを実際に動かすには、次が必要です。
- Windows マシン(UAC の昇格プロンプト、
PipeSecurityによる明示 ACL、GetNamedPipeClientProcessId、HKLM の書き込みはすべて Windows 専用です) - .NET 8 SDK 以降
- 昇格を承認できるアカウント。管理者アカウントなら consent prompt、標準ユーザーなら credential prompt になります。どちらの経路も確認したいなら、両方のアカウントを用意します
- HKLM を書き換えてよいマシン。サンプルは
HKLM\SOFTWARE\Classes\*\shell\MyApp.Openを machine-wide に作ります。常用の開発機ではなく、評価用の VM で試すほうが安全です
ビルドと実行の具体的なコマンドは、サンプルの README にまとめてあります。UI と helper を 同じフォルダへ publish してから実行する点だけ、先に頭に入れておいてください(helper は自分と同じフォルダの MyApp.exe を固定解決するためです)。
1. まず結論
実務での落としどころを先に並べておきます。
- 通常の UI アプリは
asInvokerのまま動かす - 管理者権限が必要な処理は 別 EXE に切り出す
- その helper EXE は
requireAdministratorにする - 起動は
runasで行う - helper との通信は、
runasと相性の悪い標準入出力ではなく、名前付きパイプなどの IPC を使う - helper に渡すのは「生のコマンド文字列」ではなく、型付きの要求だけにする
- helper 側では、要求内容をもう一度検証する
- IPC の接続元は、呼び出し元ユーザー SID と想定 PID で絞る
「管理者で動けば楽」は、最初の 1 回だけです。 あとで UAC、ドラッグ&ドロップ、ログ設計、外部入力、サポート運用、DLL 読み込み、設定保存先あたりで、だいたい嫌な顔をされます。
flowchart TB
accTitle: 実務での落としどころの骨格
accDescr: UIはasInvokerのまま動かし、管理者権限が必要な処理はrequireAdministratorの別EXEに切り出してrunasで起動し、名前付きパイプで型付きの要求だけをやり取りするという骨格を示す図。
uix1["UI は asInvoker のまま"] -->|"runas で起動"| hx1["helper EXE(requireAdministrator)"]
uix1 -->|"名前付きパイプで型付きの要求"| hx1
hx1 --> vfy1["helper 側で要求を再検証"]
図2: 骨格は「非昇格UI + 昇格helper + 型付き要求のIPC」の3点で決まる。
この記事の知識マップ
この記事はWindowsアプリで管理者権限が必要な処理だけを分離する具体的な実装を扱います。UACはプロセスの整合性レベルで制御され親子プロセスは同じレベルでトークンを継承するため、同じプロセス内の一部だけを昇格させることはできず、標準ユーザーのUIと管理者権限のhelper EXEを組み合わせるAdministrator Broker Modelが基本形になります。helperの起動にはrunasを使い、UIとhelperの通信には既定ACLに頼らず明示的なPipeSecurityを設定した名前付きパイプを使い、接続元PIDの検証とhelper側で固定operationのみを受け付けるallowlistによって任意コマンド実行の口を塞ぎます。PipeOptions.CurrentUserOnlyは整合性レベルの違いまで確認してしまうため、この用途には使えません。
flowchart LR
accTitle: 管理者権限処理の分離実装の知識マップ
accDescr: UACの整合性レベルという制約のもとで、Administrator Broker Modelがなぜ標準ユーザーUIと管理者helper EXEを名前付きパイプでつなぎ、ACLとPID検証とoperation allowlistで境界を守るのかを、他の分離モデルとの使い分けとあわせて示した図。
administrator_broker_model["Administrator Broker Model"]
uac["UAC(ユーザーアカウント制御)"]
integrity_level["整合性レベル"]
admin_rights["管理者権限"]
requested_execution_level["requestedExecutionLevel(実行レベル宣言)"]
runas_verb["runas起動(ShellExecute Verb)"]
named_pipe["名前付きパイプ"]
pipe_security_acl["PipeSecurityによる明示的ACL"]
unauthorized_pipe_access["パイプへの不正な接続"]
client_pid_verification["接続元PIDの検証"]
helper_operation_allowlist["helperのoperation allowlist"]
arbitrary_command_execution["任意コマンド実行口"]
sporadic_admin_operation["散発的な管理者操作"]
os_service_model["Operating System Service Model"]
continuous_unattended_admin_operation["常時・無人・頻繁な管理者操作"]
elevated_task_model["Elevated Task Model"]
short_scheduled_admin_task["短い定型の管理者ジョブ"]
admin_com_object_model["Administrator COM Object Model"]
existing_com_integration["既存COM前提の統合"]
currentuseronly_pipeoption["PipeOptions.CurrentUserOnly"]
administrator_broker_model -->|"利用する"| uac
uac -->|"利用する"| integrity_level
uac -.->|"前提とする"| admin_rights
administrator_broker_model -->|"前提とする"| requested_execution_level
runas_verb -->|"利用する"| uac
administrator_broker_model -.->|"利用する"| named_pipe
named_pipe -->|"で構成できる"| pipe_security_acl
named_pipe -.->|"原因になり得る"| unauthorized_pipe_access
pipe_security_acl -->|"軽減する"| unauthorized_pipe_access
client_pid_verification -->|"軽減する"| unauthorized_pipe_access
administrator_broker_model -.->|"利用する"| client_pid_verification
administrator_broker_model -->|"利用する"| helper_operation_allowlist
helper_operation_allowlist -->|"防止する"| arbitrary_command_execution
administrator_broker_model -->|"推奨される対応"| sporadic_admin_operation
os_service_model -->|"推奨される対応"| continuous_unattended_admin_operation
elevated_task_model -->|"推奨される対応"| short_scheduled_admin_task
admin_com_object_model -->|"推奨される対応"| existing_com_integration
currentuseronly_pipeoption -->|"両立しない"| administrator_broker_model
os_service_model -->|"前提とする"| admin_rights
elevated_task_model -->|"前提とする"| admin_rights
admin_com_object_model -->|"前提とする"| admin_rights
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全21件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
2. 前提整理: 同じプロセスの一部だけを管理者化することはできない
Windows の UAC は、「関数単位の昇格」ではなく「プロセスがどのトークン / 整合性レベルで動いているか」で制御されます。 管理者アクセス トークンが必要なアプリは昇格プロンプトの対象になり、親子プロセスは同じ整合性レベルでトークンを継承します。 つまり、非昇格の UI プロセスの中で、あるメソッドだけを急に管理者権限で実行するという設計はできません。 必要なら、別プロセス・サービス・タスク・昇格 COM など、別の実行単位を使います。
この前提を外して考えると、「このボタンを押した瞬間だけ管理者にしたい」という、少し気の毒な設計相談になります。 Windows はそこを魔法では埋めてくれません。
flowchart TB
accTitle: UACはトークンと整合性レベルで制御される
accDescr: UACは関数単位の昇格ではなくプロセスがどのトークン・整合性レベルで動いているかで制御され、親子プロセスは同じ整合性レベルでトークンを継承するため、メソッド単位の昇格はできず別の実行単位が必要になることを示す図。
uac1["UAC の制御単位"] --> tk1["プロセスのトークンと整合性レベル"]
tk1 --> inh1["親子プロセスは同じレベルを継承"]
inh1 --> no1["メソッド単位の昇格はできない"]
no1 --> alt2["別プロセス・サービス・タスク・昇格 COM"]
図3: 制御の単位がプロセスである以上、昇格したい処理は別の実行単位に置くしかない。
2.1 integrity level(整合性レベル)の対応を先に固定する
この記事では以降 medium integrity / high integrity という言い方が出てきます。先に対応を決めておきます。
| 整合性レベル | この記事での意味 | 例 |
|---|---|---|
| medium | 標準ユーザーとして動くプロセス | asInvoker の UI アプリ |
| high | 昇格したプロセス | requireAdministrator の helper EXE |
Windows の Mandatory Integrity Control では low / medium / high / system の 4 段階が定義されていて、標準ユーザーは medium、昇格したユーザーは high を受け取ります。 つまりこの記事の設計は、「medium の UI プロセス」と「high の helper プロセス」を、明示的に線を引いて会話させる、という話です。
この対応が頭に入っていると、5.6 の CurrentUserOnly の話も 16.4 も、素直に読めるようになります。
flowchart TB
accTitle: mediumとhighの線引き
accDescr: 標準ユーザーとして動くasInvokerのUIはmedium、昇格したrequireAdministratorのhelperはhighの整合性レベルを受け取り、この記事の設計はその2つに明示的に線を引いて会話させる話であることを示す図。
med1["medium: asInvoker の UI プロセス"] -->|"線を引いて会話させる"| hg1["high: 昇格した helper プロセス"]
med1 -.-> sd2["標準ユーザーのトークン"]
hg1 -.-> ad2["昇格したユーザーのトークン"]
図4: この設計は、mediumのUIとhighのhelperの間に明示的な境界を引く話になる。
3. どの分離モデルを選ぶか
Microsoft Learn では、管理者権限が必要なアプリの分離方法として、主に次の 4 つが挙げられています。
| モデル | ざっくりした形 | 向いている場面 |
|---|---|---|
| Administrator Broker Model | 標準ユーザーの UI アプリ + 管理者 helper EXE | 管理者操作が散発的で、必要な瞬間だけ UAC を出せばよい |
| Operating System Service Model | 標準ユーザー UI + 常駐 service | 常時稼働の管理機能、バックグラウンド監視、無人処理 |
| Elevated Task Model | 標準ユーザー UI + 管理者権限のスケジュールタスク | 一回ごとに短く終わる定型処理 |
| Administrator COM Object Model | 標準ユーザー UI + 昇格 COM | 既存 COM 設計があり、機能がかなり限定される場合 |
選び方の目安はこうです。
3.1 最初に検討しやすいのは broker EXE
broker EXE がはまりやすいのは、こういう操作です。
- Explorer 連携の登録 / 解除
- HKLM 配下の machine-wide 設定変更
- 自アプリの service 登録 / 解除
- ファイアウォール規則の追加 / 削除
- Program Files 配下の管理者操作
これらは、普段は不要で、設定画面の特定ボタンを押した時だけ必要になりがちです。 この場合は、常駐 service まで持ち出すより、管理者 helper EXE を一回だけ起動して終わる形のほうが素直です。
flowchart TB
accTitle: broker EXEがはまる形
accDescr: 管理者操作が普段は不要で設定画面の特定ボタンを押したときだけ必要になる場合は、常駐serviceより管理者helper EXEを一回だけ起動して終わる形が素直であることを示す図。
btn1["設定画面の特定ボタンを押す"] --> once2["helper EXE を一回だけ起動"]
once2 --> done1["操作を終えて helper は消える"]
btn1 -.->|"この頻度なら不要"| svc2["常駐 service を持ち出す"]
図5: 散発的な管理者操作には、必要な瞬間だけ生きるhelper EXEがいちばん素直。
3.2 service を選ぶのは「常時」「無人」「頻繁」
service は、標準ユーザーアプリから RPC 等で通信するモデルです。 利点は 昇格プロンプトなしで管理側処理を受けられることですが、その代わり、常駐プロセスを運用する責任が増えます。
flowchart TB
accTitle: serviceモデルのトレードオフ
accDescr: serviceモデルは昇格プロンプトなしで管理側処理を受けられる利点がある代わりに、常駐プロセスを運用する責任が増えるというトレードオフを示す図。
svm1["Operating System Service Model"] -->|"利点"| npr1["昇格プロンプトなしで受けられる"]
svm1 -->|"代償"| res2["常駐プロセスを運用する責任"]
svm1 -.-> fitx["常時・無人・頻繁な用途に合う"]
図6: serviceはプロンプトなしと引き換えに常駐運用を背負う選択になる。
service が合うのは、こういう用途です。
- 常時監視
- ログ収集
- バックグラウンド更新
- 装置やデーモンとの常時連携
- 複数 UI セッションから共有される管理機能
3.3 task は「短い定型処理」に向く
Elevated Task Model は、標準ユーザーアプリから管理者権限で動くスケジュールタスクを起動する形です。 service より軽く、終わったら閉じるので、1 回ごとの定型ジョブには合います。
3.4 昇格 COM はかなり限定的
COM elevation moniker は便利そうに見えますが、使いどころは絞られます。 Microsoft Learn でも、昇格 COM を制御できる UI は COM 側で提示する必要がある、とされていて、「非昇格 UI から昇格 COM に好き勝手させる」方向には向いていません。
flowchart TB
accTitle: 昇格COMの使いどころの狭さ
accDescr: 昇格COMは便利そうに見えるが、それを制御できるUIはCOM側で提示する必要があるとされており、非昇格UIから昇格COMに好き勝手させる方向には向かないことを示す図。
look1["昇格 COM は便利そうに見える"] -.-> free1["非昇格 UI から好き勝手に操作"]
free1 -.->|"この方向には向かない"| ngc1["使いどころが絞られる"]
ui2["制御できる UI は COM 側で提示"] --> ngc1
図7: 昇格COMは制御UIをCOM側が持つ前提で、汎用の逃げ道にはならない。
4. 今回のおすすめ: asInvoker UI + requireAdministrator helper EXE
ここからは、いちばん実務で使いやすい形を具体化します。
flowchart TB
subgraph MED["medium integrity ── 最後まで昇格しない"]
UI["MyApp.exe(asInvoker)<br/>利用者の操作を受け、要求を組み立てるだけ"]
end
subgraph HIGH["high integrity ── 短命の昇格プロセス"]
BR["MyApp.AdminBroker.exe<br/>(requireAdministrator)"]
PIPE["名前付きパイプの受け口<br/>接続を許すのは UI ユーザーの SID だけ<br/>接続元 PID も照合する"]
DISP["operation の allowlist で振り分け<br/>引数を helper 側でもう一度検証"]
end
TGT["管理者権限が要る固定の対象<br/>HKLM 配下のキー / service 登録 / ファイアウォール規則"]
UI -->|"絶対パス + Verb=runas で起動<br/>(ここで UAC プロンプトが出る)"| BR
BR --> PIPE
UI -->|"型付きの要求<br/>(生のコマンド文字列は渡さない)"| PIPE
PIPE --> DISP
DISP --> TGT
図8: 昇格の境界をプロセス境界に一致させる。UI 側は medium のまま、helper だけが high で短時間動く
ポイントは 3 つです。
- UI プロセスは最後まで非昇格のまま
- 管理者 helper は短命
- helper が受け付ける操作は固定の allowlist のみ
この 3 つを守るだけで、設計がかなり整理されます。
flowchart TB
accTitle: 守るべき3つのポイント
accDescr: UIプロセスは最後まで非昇格のまま、管理者helperは短命、helperが受け付ける操作は固定のallowlistのみという3つを守るだけで設計がかなり整理されることを示す図。
p1["UI は最後まで非昇格"] --> tidy1["設計がかなり整理される"]
p2["helper は短命"] --> tidy1
p3["受け付ける操作は allowlist のみ"] --> tidy1
図9: 非昇格・短命・allowlistの3点を守るだけで、権限境界の形が定まる。
5. 実装で外したくないルール
ここはコードを書く前に決めたほうがよいところです。
5.1 helper は「なんでも屋」にしない
ダメな例はこれです。
- UI から helper に
reg add ...を丸ごと文字列で渡す - UI から helper に
sc.exe ...を丸ごと文字列で渡す - UI から helper に任意のレジストリパスや任意の EXE パスを渡す
これをやると、UI が壊れたら helper も一緒に壊れます。 管理者 helper は、昇格境界の内側です。 ここに「何でも実行できる口」を作ると、だいぶ危ない。
よい形はこうです。
set-explorer-context-menuinstall-serviceadd-firewall-rule
のように 操作自体を固定し、必要な引数も bool / enum / 数値 / 限定された文字列 に寄せます。
flowchart TB
accTitle: なんでも屋にしないための形
accDescr: 生のコマンド文字列や任意パスをhelperへ渡すと何でも実行できる口ができ、UIが壊れたらhelperも一緒に壊れるため、操作自体を固定し引数も限定された型に寄せることを示す図。
raw1["生のコマンド文字列を渡す"] --> hole1["何でも実行できる口ができる"]
hole1 --> both1["UI が壊れたら helper も壊れる"]
fix2["操作を固定し引数を限定された型に"] --> narrow1["helper の意味が絞られる"]
図10: 昇格境界の内側に渡すのは、固定した操作と限定された引数だけにする。
5.2 helper に渡す path は absolute、しかも UI で決めすぎない
runas で起動する helper EXE 自体は、絶対パスで指定します。
PATH 検索や相対パス任せは避けます。
さらに、helper が実行する対象も、できるだけ helper 側で固定解決します。
今回のサンプルでは、Explorer コンテキストメニューに登録する対象 EXE を helper と同じフォルダにある MyApp.exe に固定します。
5.3 Verb=\"runas\" を使うなら UseShellExecute=true を明示する
.NET では ProcessStartInfo.Verb は UseShellExecute=true のときにだけ有効です。
しかも UseShellExecute の既定値は .NET Framework と .NET Core / .NET で違います。
ここを既定値任せにすると、あとで「動く環境と動かない環境がある」という、地味にむかつく事故が起きます。
なので、ここは必ず明示します。
flowchart TB
accTitle: runas起動で明示すべき設定
accDescr: ProcessStartInfoのVerbはUseShellExecuteがtrueのときにだけ有効で、その既定値は.NET Frameworkと.NETで違うため、既定値任せにすると環境によって動いたり動かなかったりする事故が起き、必ず明示すべきことを示す図。
vb1["Verb=runas を使いたい"] --> req2["UseShellExecute=true が必要"]
req2 -.-> defd1["既定値は Framework と .NET で違う"]
defd1 -->|"任せると"| envd1["動く環境と動かない環境が出る"]
req2 -->|"だから"| exp2["必ず明示する"]
図11: Verbが効く条件と既定値の違いがあるので、UseShellExecuteは明示で固定する。
5.4 runas と標準入出力リダイレクトは相性が悪い
UseShellExecute=true にすると、標準入出力のリダイレクト前提の通信は使いにくくなります。
そのため、helper とのやり取りは named pipe など、別の IPC を使ったほうが素直です。
5.5 名前付きパイプは既定 ACL に頼らない
名前付きパイプは、既定のセキュリティ記述子だと、Everyone や匿名に読み取り権が入る既定になっています。 管理者 helper の IPC にそれをそのまま使うのは、かなり雑です。
必ず明示的な PipeSecurity を設定したほうがよいです。
5.6 PipeOptions.CurrentUserOnly は今回の用途では使わない
これ、ぱっと見だと便利そうです。
ただし Windows では、CurrentUserOnly はユーザーアカウントだけでなく昇格レベルも確認します。
つまり、非昇格 UI と昇格 helper の通信には向きません。
さらに、誰がどのトークンでパイプに接続するかは UAC のプロンプトの種類で変わります。ここを表で固めておくと、なぜ explicit ACL が要るのかが追いやすくなります。
| UI の実行アカウント | 出る UAC プロンプト | helper が動くアカウント | pipe を作る helper の WindowsIdentity.GetCurrent() |
接続してくる UI 側の SID |
|---|---|---|---|---|
| 管理者アカウント(非昇格) | consent prompt(「はい」を押すだけ) | 同じユーザーの昇格トークン | UI と同じユーザー | UI ユーザー |
| 標準ユーザー | credential prompt(別アカウントの資格情報を入力) | 入力された別の管理者アカウント | UI とは別のユーザー | UI ユーザー |
読み方はこうです。
- 上の行なら「helper の現在ユーザー = UI ユーザー」なので、helper 側で自分の SID だけを見て ACL を作っても、たまたま繋がります
- 下の行では helper の現在ユーザーと UI ユーザーが別人です。ここで
WindowsIdentity.GetCurrent()だけを見て ACL を作ると、元の UI ユーザーが自分の要求を送れなくなります - どちらの行でも、
CurrentUserOnlyは「medium の UI」と「high の helper」という昇格レベルの差で弾かれます
つまり、両方の行で成立する唯一の形が「UI 側から SID をもらって、その SID に接続権を与える」になります。
なので今回は、
- UI 側で自分の SID を取得して helper に渡す
- helper 側では UI ユーザー SID にだけ pipe 接続権を与える
- さらに
GetNamedPipeClientProcessIdで 接続元 PID も確認する
という形にします。
flowchart TB
accTitle: SIDの受け渡しで両方の経路が成立する
accDescr: consent promptでもcredential promptでも成立する唯一の形として、UI側で自分のSIDを取得してhelperへ渡し、helper側でそのSIDにだけパイプ接続権を与え、さらに接続元PIDも確認する流れを示す図。
sid1["UI が自分の SID を取得して渡す"] --> aclx["helper がその SID にだけ接続権"]
aclx --> pidx["接続元 PID も確認"]
cuo1["CurrentUserOnly を使う"] -.->|"昇格レベルの差で弾かれる"| ngz1["この用途では成立しない"]
図12: どちらのプロンプト経路でも成立するのは、UIから渡されたSIDでACLを組む形だけ。
5.7 PID 検証は「雑な横入り」を減らすための追加防御
ランダムな pipe 名だけでもだいぶましですが、同じユーザーで動く別プロセスが先に接続する余地はゼロではありません。
そこで helper 側で GetNamedPipeClientProcessId を使い、想定した UI プロセス PID と一致するかを確認します。
もちろん、PID が合っていれば何でも信用してよいわけではありません。 UI が侵害されていれば、helper にも危険な要求が届きます。 だからこそ、helper 側の operation allowlist と引数検証が必要です。
flowchart TB
accTitle: 層を重ねる追加防御
accDescr: ランダムなパイプ名、SID限定のACL、接続元PIDの照合、operation allowlistと引数検証という層を重ねることで、雑な横入りと危険な要求の両方を減らすという考え方を示す図。
l1["ランダムな pipe 名"] --> l2["SID 限定の ACL"]
l2 --> l3["接続元 PID の照合"]
l3 --> l4["allowlist と引数の再検証"]
l4 -.-> why3["PID が合っても要求は信用しない"]
図13: どの1枚も完全ではないので、接続と要求の両方に層を重ねて絞る。
ここまでのルールを起動から終了までの順に並べると、こうなります。UI 側の一手ごとに helper 側の確認が対になっている、という形が読み取れれば十分です。
sequenceDiagram
participant UI as MyApp.exe(medium)
participant OS as Windows / UAC
participant BR as AdminBroker.exe(high)
participant TGT as 管理者権限が要る対象
UI->>UI: パイプ名を決め、自分の SID と PID を用意
UI->>OS: 絶対パス + Verb=runas で起動
OS->>BR: 承認されたら昇格トークンで起動
BR->>BR: その SID にだけ許す ACL でパイプ作成
UI->>BR: パイプへ接続
BR->>BR: 接続元 PID を照合
UI->>BR: 型付きの要求(operation 名と引数)
BR->>BR: allowlist 外と想定外の引数を拒否
BR->>TGT: 固定された対象にだけ操作
BR-->>UI: 結果を返す
BR->>BR: 終了して昇格状態を残さない
図14: 起動・接続・要求のそれぞれに helper 側の確認が対応している。どれか1つを省くと、その段が素通しになる
6. サンプルの題材
今回は、Explorer の右クリックメニューを machine-wide に登録 / 解除する例にします。
理由は単純で、
- 管理者権限が必要
- 操作の境界がはっきりしている
- helper に任意のコマンド文字列を渡さずに済む
- 実務でも普通にあり得る
からです。
登録先は次のような固定キーです。
HKLM\SOFTWARE\Classes\*\shell\MyApp.OpenHKLM\SOFTWARE\Classes\*\shell\MyApp.Open\command
UI は「Explorer の右クリックメニューに登録する」のチェックボックスだけ持ち、実際のレジストリ操作は helper 側で行います。
7. ソリューション構成
MyApp/
MyApp/ UI アプリ (asInvoker)
app.manifest
ElevationBrokerClient.cs
SettingsPage.xaml.cs
MyApp.AdminBroker/ 管理者 helper (requireAdministrator)
app.manifest
Program.cs
BrokerLaunchOptions.cs
ExplorerContextMenuRegistration.cs
MyApp.BrokerProtocol/ 共通契約
BrokerProtocol.cs
共通契約を別プロジェクトにしておくと、
- operation 名
- request / response 型
- パイプのメッセージ形式
を UI と helper で揃えやすくなります。
8. マニフェスト
8.1 UI 側 (MyApp/app.manifest)
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApp.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
8.2 helper 側 (MyApp.AdminBroker/app.manifest)
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApp.AdminBroker.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
UI はずっと asInvoker。
helper だけ requireAdministrator。
ここを逆にすると、せっかく分けた意味が消えます。
9. 共通契約コード
9.1 MyApp.BrokerProtocol/BrokerProtocol.cs
using System.Buffers.Binary;
using System.Text.Json;
namespace MyApp.BrokerProtocol;
public static class BrokerJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
public static class BrokerOperations
{
public const string SetExplorerContextMenu = "set-explorer-context-menu";
}
public sealed record BrokerRequest(string Operation, JsonElement Payload);
public sealed record BrokerResponse(bool Success, string? ErrorCode, string? Message)
{
public static BrokerResponse Ok(string? message = null) => new(true, null, message);
public static BrokerResponse Fail(string errorCode, string message) =>
new(false, errorCode, message);
}
public sealed record SetExplorerContextMenuRequest(bool Enabled);
public static class PipeMessageSerializer
{
private const int MaxPayloadBytes = 256 * 1024;
public static async Task WriteAsync<T>(Stream stream, T value, CancellationToken cancellationToken)
{
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(value, BrokerJson.Options);
if (payload.Length > MaxPayloadBytes)
{
throw new InvalidDataException($"Payload is too large: {payload.Length} bytes.");
}
byte[] header = new byte[sizeof(int)];
BinaryPrimitives.WriteInt32LittleEndian(header, payload.Length);
await stream.WriteAsync(header.AsMemory(0, header.Length), cancellationToken);
await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancellationToken);
await stream.FlushAsync(cancellationToken);
}
public static async Task<T> ReadAsync<T>(Stream stream, CancellationToken cancellationToken)
{
byte[] header = await ReadExactAsync(stream, sizeof(int), cancellationToken);
int payloadLength = BinaryPrimitives.ReadInt32LittleEndian(header);
if (payloadLength <= 0 || payloadLength > MaxPayloadBytes)
{
throw new InvalidDataException($"Invalid payload length: {payloadLength}");
}
byte[] payload = await ReadExactAsync(stream, payloadLength, cancellationToken);
return JsonSerializer.Deserialize<T>(payload, BrokerJson.Options)
?? throw new InvalidDataException($"Failed to deserialize {typeof(T).FullName}.");
}
private static async Task<byte[]> ReadExactAsync(Stream stream, int length, CancellationToken cancellationToken)
{
byte[] buffer = new byte[length];
int offset = 0;
while (offset < length)
{
int read = await stream.ReadAsync(buffer.AsMemory(offset, length - offset), cancellationToken);
if (read == 0)
{
throw new EndOfStreamException("Pipe was closed before the expected number of bytes was read.");
}
offset += read;
}
return buffer;
}
}
ポイントは、pipe に JSON をそのままだらだら流さず、長さ付きで送ることです。 1 回の要求、1 回の応答、という単純なプロトコルにしておくと事故りにくいです。
flowchart TB
accTitle: 長さ付きの単純なプロトコル
accDescr: パイプにJSONをそのまま流さず、長さヘッダー付きで送り、1回の要求と1回の応答だけの単純なプロトコルにすると事故りにくいことを示す図。
m1["長さヘッダーを書く"] --> m2["本文の JSON を書く"]
m2 --> m3["相手は長さぶんだけ確実に読む"]
m3 -.-> simple1["1 要求 1 応答の単純さが効く"]
図15: メッセージは長さ付きで送り、1要求1応答に絞ると事故りにくい。
10. UI 側: helper の起動と通信
10.1 MyApp/ElevationBrokerClient.cs
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO.Pipes;
using System.Security.Principal;
using System.Text.Json;
using MyApp.BrokerProtocol;
namespace MyApp;
public sealed class ElevationBrokerClient
{
private readonly string _helperExePath;
public ElevationBrokerClient(string helperExePath)
{
_helperExePath = Path.GetFullPath(helperExePath);
if (!Path.IsPathRooted(_helperExePath))
{
throw new ArgumentException("Helper executable path must be absolute.", nameof(helperExePath));
}
if (!File.Exists(_helperExePath))
{
throw new FileNotFoundException("Helper executable was not found.", _helperExePath);
}
}
public async Task SetExplorerContextMenuEnabledAsync(bool enabled, CancellationToken cancellationToken = default)
{
string pipeName = $"myapp-broker-{Guid.NewGuid():N}";
int clientPid = Environment.ProcessId;
string clientSid = GetCurrentUserSid();
StartHelper(pipeName, clientPid, clientSid);
using var pipe = new NamedPipeClientStream(
serverName: ".",
pipeName: pipeName,
direction: PipeDirection.InOut,
options: PipeOptions.Asynchronous);
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(TimeSpan.FromSeconds(30));
await pipe.ConnectAsync(connectCts.Token);
BrokerRequest request = new(
BrokerOperations.SetExplorerContextMenu,
JsonSerializer.SerializeToElement(
new SetExplorerContextMenuRequest(enabled),
BrokerJson.Options));
await PipeMessageSerializer.WriteAsync(pipe, request, cancellationToken);
BrokerResponse response = await PipeMessageSerializer.ReadAsync<BrokerResponse>(pipe, cancellationToken);
if (!response.Success)
{
throw new InvalidOperationException(
$"Admin broker returned an error. Code={response.ErrorCode}, Message={response.Message}");
}
}
private void StartHelper(string pipeName, int clientPid, string clientSid)
{
string workingDirectory = Path.GetDirectoryName(_helperExePath)
?? throw new InvalidOperationException("Helper executable directory could not be resolved.");
var startInfo = new ProcessStartInfo
{
FileName = _helperExePath,
Arguments = BuildArguments(pipeName, clientPid, clientSid),
WorkingDirectory = workingDirectory,
UseShellExecute = true,
Verb = "runas"
};
try
{
Process.Start(startInfo)
?? throw new InvalidOperationException("The helper process could not be started.");
}
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
throw new OperationCanceledException("管理者権限の承認がキャンセルされました。", ex);
}
}
private static string GetCurrentUserSid()
{
using WindowsIdentity identity = WindowsIdentity.GetCurrent();
return identity.User?.Value
?? throw new InvalidOperationException("Current user SID could not be resolved.");
}
private static string BuildArguments(string pipeName, int clientPid, string clientSid)
{
return string.Join(
" ",
"--pipe",
QuoteArgument(pipeName),
"--client-pid",
clientPid.ToString(CultureInfo.InvariantCulture),
"--client-sid",
QuoteArgument(clientSid));
}
private static string QuoteArgument(string value)
{
return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
}
}
ここで helper に渡しているのは、pipe 名と接続元確認に必要な最小情報だけです。 管理者操作そのものは、pipe の中で送る 型付き request に閉じ込めます。
flowchart TB
accTitle: 起動引数とpipeの役割分担
accDescr: helperの起動引数にはpipe名と接続元確認に必要な最小情報だけを渡し、管理者操作そのものはpipeの中で送る型付きrequestに閉じ込めるという役割分担を示す図。
argx["起動引数"] -->|"渡すのは"| minx["pipe 名・PID・SID の最小情報"]
pipx["pipe の中"] -->|"閉じ込めるのは"| reqx["型付き request の管理者操作"]
minx -.-> nolx["操作内容は引数に載せない"]
図16: 引数は接続の段取りだけに使い、操作の中身は型付きrequestとしてpipeに閉じる。
この QuoteArgument は、このサンプルで渡している pipe 名、PID、SID のような単純な値を前提にした最小実装です。任意の Windows パスや自由入力文字列をコマンドライン引数に渡す場合は、Windows の argv 解析規則に沿った専用のエスケープ処理に置き換えてください。
11. helper 側: 起動引数の解析
11.1 MyApp.AdminBroker/BrokerLaunchOptions.cs
namespace MyApp.AdminBroker;
internal sealed class BrokerLaunchOptions
{
public required string PipeName { get; init; }
public required int ExpectedClientProcessId { get; init; }
public required string ClientUserSid { get; init; }
public static BrokerLaunchOptions Parse(string[] args)
{
string? pipeName = null;
int? clientPid = null;
string? clientSid = null;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--pipe":
pipeName = ReadNextValue(args, ref i, "--pipe");
break;
case "--client-pid":
string pidText = ReadNextValue(args, ref i, "--client-pid");
if (!int.TryParse(pidText, out int pid) || pid <= 0)
{
throw new ArgumentException($"Invalid client PID: {pidText}");
}
clientPid = pid;
break;
case "--client-sid":
clientSid = ReadNextValue(args, ref i, "--client-sid");
break;
default:
throw new ArgumentException($"Unknown argument: {args[i]}");
}
}
if (string.IsNullOrWhiteSpace(pipeName))
{
throw new ArgumentException("--pipe is required.");
}
if (clientPid is null)
{
throw new ArgumentException("--client-pid is required.");
}
if (string.IsNullOrWhiteSpace(clientSid))
{
throw new ArgumentException("--client-sid is required.");
}
return new BrokerLaunchOptions
{
PipeName = pipeName,
ExpectedClientProcessId = clientPid.Value,
ClientUserSid = clientSid
};
}
private static string ReadNextValue(string[] args, ref int index, string optionName)
{
if (index + 1 >= args.Length)
{
throw new ArgumentException($"A value is required after {optionName}.");
}
index++;
return args[index];
}
}
helper 側は 引数が足りない / 余計な引数がある 時点でエラーにします。 昇格境界の内側で「とりあえず頑張って解釈する」は、やらないほうがよいです。
12. helper 側: pipe 作成・接続元 PID 検証・dispatch
12.1 MyApp.AdminBroker/Program.cs
using System.ComponentModel;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
using MyApp.BrokerProtocol;
namespace MyApp.AdminBroker;
internal static class Program
{
public static async Task<int> Main(string[] args)
{
BrokerLaunchOptions options = BrokerLaunchOptions.Parse(args);
using var brokerCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using NamedPipeServerStream pipe = CreatePipeServer(options);
await pipe.WaitForConnectionAsync(brokerCts.Token);
VerifyClientProcessId(pipe, options.ExpectedClientProcessId);
BrokerRequest request = await PipeMessageSerializer.ReadAsync<BrokerRequest>(pipe, brokerCts.Token);
BrokerResponse response = await DispatchAsync(request);
await PipeMessageSerializer.WriteAsync(pipe, response, brokerCts.Token);
return response.Success ? 0 : 2;
}
private static Task<BrokerResponse> DispatchAsync(BrokerRequest request)
{
try
{
return request.Operation switch
{
BrokerOperations.SetExplorerContextMenu => HandleSetExplorerContextMenuAsync(request.Payload),
_ => Task.FromResult(
BrokerResponse.Fail(
"unsupported_operation",
$"Unsupported operation: {request.Operation}"))
};
}
catch (JsonException ex)
{
return Task.FromResult(BrokerResponse.Fail("invalid_payload", ex.Message));
}
catch (Exception ex)
{
return Task.FromResult(BrokerResponse.Fail("broker_failure", ex.Message));
}
}
private static NamedPipeServerStream CreatePipeServer(BrokerLaunchOptions options)
{
var pipeSecurity = new PipeSecurity();
var clientSid = new SecurityIdentifier(options.ClientUserSid);
SecurityIdentifier helperSid = WindowsIdentity.GetCurrent().User
?? throw new InvalidOperationException("Helper user SID could not be resolved.");
pipeSecurity.AddAccessRule(new PipeAccessRule(
clientSid,
PipeAccessRights.ReadWrite,
AccessControlType.Allow));
pipeSecurity.AddAccessRule(new PipeAccessRule(
helperSid,
PipeAccessRights.FullControl,
AccessControlType.Allow));
pipeSecurity.AddAccessRule(new PipeAccessRule(
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
PipeAccessRights.FullControl,
AccessControlType.Allow));
return NamedPipeServerStreamAcl.Create(
options.PipeName,
PipeDirection.InOut,
maxNumberOfServerInstances: 1,
transmissionMode: PipeTransmissionMode.Byte,
options: PipeOptions.Asynchronous | PipeOptions.WriteThrough,
inBufferSize: 0,
outBufferSize: 0,
pipeSecurity: pipeSecurity);
}
private static void VerifyClientProcessId(NamedPipeServerStream pipe, int expectedClientProcessId)
{
if (!GetNamedPipeClientProcessId(
pipe.SafePipeHandle.DangerousGetHandle(),
out uint actualClientProcessId))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
if (actualClientProcessId != (uint)expectedClientProcessId)
{
throw new InvalidOperationException(
$"Unexpected pipe client PID. Expected={expectedClientProcessId}, Actual={actualClientProcessId}");
}
}
private static Task<BrokerResponse> HandleSetExplorerContextMenuAsync(JsonElement payload)
{
SetExplorerContextMenuRequest request = payload.Deserialize<SetExplorerContextMenuRequest>(BrokerJson.Options)
?? throw new JsonException("Payload could not be parsed.");
ExplorerContextMenuRegistration.Apply(request.Enabled);
return Task.FromResult(BrokerResponse.Ok("Explorer context menu setting was updated."));
}
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetNamedPipeClientProcessId(
IntPtr pipe,
out uint clientProcessId);
}
ここで効いているのは次の点です。
- pipe の ACL を 明示的に組み立てる
- ACL は helper の現在ユーザー SID ではなく、呼び出し元 UI ユーザー SID にも付与する
- 接続後に client PID を検証する
- request を受けたあとも operation 名で dispatchする
switch (request.Operation) で固定の操作しか通さない形にしておくと、helper が「昇格した何でも箱」になりにくいです。
flowchart TB
accTitle: helper側で効いている検証の順
accDescr: helperは明示的なACLでパイプを作り、接続後にクライアントPIDを検証し、requestはoperation名でdispatchして固定の操作しか通さないという、helper側で効いている検証の順を示す図。
hs1["明示的な ACL でパイプを作る"] --> hs2["接続元 PID を検証"]
hs2 --> hs3["operation 名で dispatch"]
hs3 -->|"allowlist 内"| hs4["固定の操作だけ実行"]
hs3 -->|"allowlist 外"| hs5["拒否して応答"]
図17: ACL・PID・dispatchの3段を通ったrequestだけが、固定の操作にたどり着く。
13. 管理者操作の本体: Explorer 右クリックメニュー登録
13.1 MyApp.AdminBroker/ExplorerContextMenuRegistration.cs
using System;
using System.IO;
using Microsoft.Win32;
namespace MyApp.AdminBroker;
internal static class ExplorerContextMenuRegistration
{
private const string MenuKeyPath = @"SOFTWARE\Classes\*\shell\MyApp.Open";
private const string CommandKeyPath = @"SOFTWARE\Classes\*\shell\MyApp.Open\command";
private const string MenuText = "Open with MyApp";
private const string ClientExecutableName = "MyApp.exe";
public static void Apply(bool enabled)
{
string clientExePath = ResolveClientExecutablePath();
using RegistryKey hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, GetRegistryView());
if (enabled)
{
using RegistryKey menuKey = hklm.CreateSubKey(MenuKeyPath)
?? throw new InvalidOperationException($"Failed to create registry key: {MenuKeyPath}");
menuKey.SetValue(null, MenuText, RegistryValueKind.String);
menuKey.SetValue("Icon", $"\"{clientExePath}\",0", RegistryValueKind.String);
using RegistryKey commandKey = hklm.CreateSubKey(CommandKeyPath)
?? throw new InvalidOperationException($"Failed to create registry key: {CommandKeyPath}");
commandKey.SetValue(null, $"\"{clientExePath}\" \"%1\"", RegistryValueKind.String);
}
else
{
hklm.DeleteSubKeyTree(@"SOFTWARE\Classes\*\shell\MyApp.Open", throwOnMissingSubKey: false);
}
}
private static string ResolveClientExecutablePath()
{
string clientExePath = Path.GetFullPath(
Path.Combine(AppContext.BaseDirectory, ClientExecutableName));
if (!File.Exists(clientExePath))
{
throw new FileNotFoundException("Client executable was not found.", clientExePath);
}
return clientExePath;
}
private static RegistryView GetRegistryView()
{
return Environment.Is64BitOperatingSystem
? RegistryView.Registry64
: RegistryView.Registry32;
}
}
このコードの肝は、UI から受け取っていないものにあります。
- UI から 任意のレジストリパスを受け取っていない
- UI から 任意のコマンド文字列を受け取っていない
- 登録対象 EXE は helper 側で 固定解決している
- request の内容は
Enabledだけ
つまり、helper は「Explorer 右クリックメニューの登録状態を切り替える」という、一つの意味しか持たないようにしてあります。
14. UI からの呼び出し例
14.1 MyApp/SettingsPage.xaml.cs
using System.Windows;
namespace MyApp;
public partial class SettingsPage
{
private readonly ElevationBrokerClient _broker = new(
Path.Combine(AppContext.BaseDirectory, "MyApp.AdminBroker.exe"));
private async void ExplorerMenuCheckBox_Click(object sender, RoutedEventArgs e)
{
bool enabled = ExplorerMenuCheckBox.IsChecked == true;
try
{
await _broker.SetExplorerContextMenuEnabledAsync(enabled);
MessageBox.Show("Setting has been updated.", "MyApp");
}
catch (OperationCanceledException)
{
MessageBox.Show("The administrator approval prompt was canceled.", "MyApp");
ExplorerMenuCheckBox.IsChecked = !enabled;
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "Failed to update the setting.");
ExplorerMenuCheckBox.IsChecked = !enabled;
}
}
}
UI 側は普通です。
- チェックボックスの状態を読む
- broker client を呼ぶ
- 失敗したら UI を戻す
だけです。 レジストリを直接触りません。 それが分離です。
15. この実装で押さえていること
このサンプルで実際に守っている線は、こうなっています。
15.1 UI と helper の責務分離
- UI は、利用者の操作を受けるだけ
- helper は、固定の管理者操作だけを実行する
15.2 helper に「任意実行口」を作っていない
- 任意レジストリパスを受けていない
- 任意コマンドラインを受けていない
- 任意 EXE パスを受けていない
15.3 起動経路が固定
- helper EXE は absolute path
runasを明示UseShellExecute = trueを明示
15.4 IPC 接続元を絞っている
- pipe ACL を UI ユーザー SID に限定
- 接続後に client PID を確認
15.5 管理者操作の対象も固定
- レジストリの hive / path が固定
- 登録対象 EXE も固定解決
これくらいまでやると、「UI が壊れたら helper で何でもできる」状態からはかなり離れます。
15.6 実際に分離できているかを確認する
ここまでは設計の話です。書いたものが本当に分離できているかは、動かして確かめないと分かりません。 「UI 全体がいつのまにか昇格していた」は、コードを読んでも気付きにくい事故です。
flowchart TB
accTitle: 分離できているかの4段階の確認
accDescr: UIプロセスが非昇格のままか、helper起動時にだけUACプロンプトが出るか、管理者操作が実際に効いたか、失敗すべきときに失敗するかという4つを順に確認する流れを示す図。
v1["UI が非昇格のままか"] --> v2["helper 起動時にだけプロンプトが出るか"]
v2 --> v3["操作が実際に効いたか"]
v3 --> v4["失敗すべきときに失敗するか"]
v4 -.-> whyv["4 つ目を見ないと分離の確認にならない"]
図18: 動かして4段階を順に見ると、コードだけでは気付けない昇格の漏れを捕まえられる。
確認は、次の 4 つを順に見ます。
1. UI プロセスが非昇格のままか
いちばん重要な点です。UI を起動して、管理者操作を一度実行したあとに確認します。
- タスク マネージャー: 「詳細」タブで列を右クリックし、「昇格」列を表示します。
MyApp.exeが「いいえ」、MyApp.AdminBroker.exeだけが「はい」なら期待どおりです - Process Explorer: Integrity 列を表示します。UI が
Medium、helper がHighになっていれば正解です(Windows の整合性レベルは 2.1 のとおり、標準ユーザー = medium、昇格 = high です)
コードから見たいなら、UI の起動直後に一度だけ確認する形でも足ります。
using System.Security.Principal;
using WindowsIdentity identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
// UI プロセスでは false であるべき
bool isElevatedAdmin = principal.IsInRole(WindowsBuiltInRole.Administrator);
2. helper 起動時にだけ UAC プロンプトが出るか
- UI の起動時にプロンプトが出る -> UI 側のマニフェストが
asInvokerになっていません - 設定チェックボックスを押した瞬間に出る -> 期待どおりです
- プロンプトが一度も出ないのに設定が変わる -> helper が別経路で常時昇格している可能性があります
管理者アカウントなら consent prompt、標準ユーザーなら credential prompt になります(5.6 の表)。両方を試すと、SID の受け渡しが正しいかまで確認できます。
3. 管理者操作が実際に効いたか
Explorer のメニュー登録なら、レジストリを直接見るのがいちばん速いです。
reg query "HKLM\SOFTWARE\Classes\*\shell\MyApp.Open" /s
解除側も同じように確認します。登録だけ試して解除を試さないと、DeleteSubKeyTree の側にバグが残ります。
4. 「失敗すべきときに失敗する」か
ここを確認しないと、分離できているかどうかは分かりません。
- 昇格プロンプトをキャンセルする -> 設定が変わらず、UI のチェックボックスも元に戻ること(
ERROR_CANCELLED= 1223 の扱い。10 章) - helper を直接起動する ->
MyApp.AdminBroker.exe --pipe x --client-pid 1 --client-sid S-1-5-18のように手で叩いても、接続元 PID の検証とタイムアウトで処理が進まないこと - allowlist にない operation を送る ->
unsupported_operationで拒否されること(12 章のDispatchAsync)
手順の具体的なコマンドは、サンプルの README の「Windows での確認手順」 に同じ流れでまとめてあります。
16. よくある NG
16.1 UI 全体を requireAdministrator にする
設定画面の 1 ボタンだけ管理者権限が必要なのに、全部昇格で起動する。 これは、権限境界を雑に潰す方向です。
16.2 helper に生の文字列コマンドを渡す
たとえばこういう設計です。
UI -> helper に "reg add HKLM\\.... /v ... /d ..."
これは helper が command executor になります。 やめたほうがよいです。
16.3 名前付きパイプの既定 ACL をそのまま使う
「ローカル IPC だから大丈夫だろう」は、少し危ない。 パイプは Windows セキュリティの対象なので、ちゃんと ACL を作るほうがよいです。
16.4 CurrentUserOnly に飛びつく
便利そうですが、今回の medium integrity の UI ↔ high integrity の helper には向きません。 ここは explicit ACL のほうが扱いやすいです。
16.5 helper が任意 path を受け取って操作する
たとえば次のようなものです。
- 任意ファイルを Program Files にコピー
- 任意キーを HKLM に書く
- 任意 service 名を削除
- 任意コマンドで firewall rule を追加
helper がそれを受けると、helper 自体が管理者権限の汎用実行口になります。 操作は必ず 固定化したほうがよいです。
17. まとめ
Windows アプリで「一部の処理だけ管理者権限が必要」というのは、珍しい話ではありません。
ただし、その解き方は「全部 requireAdministrator にする」ではなく、実行境界を切ることです。
最初に取りやすいのは、この形です。
- UI は
asInvoker - 管理者処理は helper EXE に分離
- helper は
requireAdministrator - 起動は
runas - 通信は named pipe
- helper は固定 operation しか受けない
- pipe ACL と client PID で接続元を絞る
- helper 側で引数を再検証する
この形にしておくと、あとから service 化したくなったときも移行しやすいです。 operation 契約をきちんと分けておけば、UI と管理者処理の境界がそのまま設計資産になります。
flowchart TB
accTitle: 境界が設計資産になる
accDescr: operation契約をきちんと分けておくと、UIと管理者処理の境界がそのまま設計資産になり、あとからservice化したくなったときにも移行しやすいことを示す図。
ctr1["operation 契約を分けておく"] --> bd1["UI と管理者処理の境界が明確"]
bd1 --> asset1["境界がそのまま設計資産になる"]
asset1 -.-> future1["service 化への移行もしやすい"]
図19: broker型で切った境界は、将来service化するときにもそのまま使える資産になる。
セキュリティの話は、派手な機能を足すことより、雑な境界を残さないことのほうが効きます。 管理者権限も同じです。 全部まとめて持たせるのではなく、必要なところだけ、できるだけ狭く渡す。 そのくらいの地味さが、あとで効いてきます。
18. 参考資料
なお、以下のリンクの一部は URL に view=net-10.0 のようなバージョン指定が入っています。これは Microsoft Learn 側でどの .NET バージョンのドキュメントを表示するかの指定で、この記事の前提である .NET 8 と食い違っているという意味ではありません。ここで使っている PipeOptions / NamedPipeServerStreamAcl / RegistryView はいずれも .NET 8 で利用できます。表示を .NET 8 に合わせたい場合は、ページ上部のバージョンセレクターで切り替えてください。
- この記事のサンプルコード一式(共通契約ライブラリ、デモ、ユニットテスト) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/windows-admin-broker-deep-dive
- 元記事: Windowsアプリ開発における最低限のセキュリティを守るためのチェックリスト https://comcomponent.com/blog/2026/03/14/001-windows-app-security-minimum-checklist/
- Administrator Broker Model - Win32 apps https://learn.microsoft.com/ja-jp/windows/win32/secauthz/administrator-broker-model
- Developing Applications that Require Administrator Privilege https://learn.microsoft.com/en-us/windows/win32/secauthz/developing-applications-that-require-administrator-privilege
- Operating System Service Model - Win32 apps https://learn.microsoft.com/ja-jp/windows/win32/secauthz/operating-system-service-model
- Elevated Task Model - Win32 apps https://learn.microsoft.com/en-us/windows/win32/secauthz/elevated-task-model
- Administrator COM Object Model - Win32 apps https://learn.microsoft.com/ja-jp/windows/win32/secauthz/administrator-com-object-model
- The COM Elevation Moniker https://learn.microsoft.com/en-us/windows/win32/com/the-com-elevation-moniker
- How User Account Control works https://learn.microsoft.com/en-us/windows/security/application-security/application-control/user-account-control/how-it-works
- Mandatory Integrity Control - Win32 apps https://learn.microsoft.com/en-us/windows/win32/secauthz/mandatory-integrity-control
- Process Explorer - Sysinternals https://learn.microsoft.com/en-us/sysinternals/downloads/process-explorer
- WindowsPrincipal.IsInRole Method https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsprincipal.isinrole
- ProcessStartInfo.UseShellExecute https://learn.microsoft.com/ja-jp/dotnet/fundamentals/runtime-libraries/system-diagnostics-processstartinfo-useshellexecute
- Named Pipe Security and Access Rights https://learn.microsoft.com/ja-jp/windows/win32/ipc/named-pipe-security-and-access-rights
- PipeOptions Enum https://learn.microsoft.com/en-us/dotnet/api/system.io.pipes.pipeoptions?view=net-10.0
- NamedPipeServerStreamAcl.Create https://learn.microsoft.com/en-us/dotnet/api/system.io.pipes.namedpipeserverstreamacl.create?view=net-10.0
- GetNamedPipeClientProcessId https://learn.microsoft.com/ja-jp/windows/win32/api/winbase/nf-winbase-getnamedpipeclientprocessid
- RegistryView Enum https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.win32.registryview?view=net-8.0
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
Windowsアプリの機密情報保存 - DPAPIで平文設定を避ける
Windows アプリで接続情報や API トークンを設定ファイルに平文保存しないために、DPAPI / ProtectedData の考え方、CurrentUser と LocalMachine の違い、実装時の注意点を整理します。
Windowsアプリ開発のセキュリティ最低限チェックリスト
WPF / WinForms / WinUI / C++ / C# の業務アプリで、権限、署名、更新、秘密情報、HTTPS、入力検証、DLL読み込み、ログの基本をチェックリスト形式で整理します。
Windows の管理者特権が必要になるのはいつなのか - UAC、保護領域、設計上の見分け方
Windows で管理者特権が必要になる場面を、UAC、保護領域、サービス、ドライバ、per-user/per-machine 設計の観点から実務向けに整理します。
名前付きパイプの実務 ── Windowsプロセス間通信の定番を設計からセキュリティまで
Windowsのプロセス間通信の定番・名前付きパイプを実務目線で解説します。バイト/メッセージモードの選択、複数クライアントを捌くサーバー設計、ACLと偽装のセキュリティ、.NETのNamedPipeStreamまで、一次情報にもとづき整理します。
Windows I/Oの深層(第6回・最終回) ── フィルタードライバーとミニフィルター:ProcmonとウイルススキャンがI/Oに割り込める理由
Windowsのフィルタードライバーとミニフィルターを図解で解説する連載の最終回です。フィルターマネージャーとアルティチュード、pre/postコールバック、Procmonやウイルス対策が全I/Oを検査できる仕組み、「あの環境だけ遅い」の調査手順まで整理します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
UAC、helper EXE、サービス化の見極め、machine-wide 設定変更まで含めて Windows アプリ全体の権限設計に関わるので、Windowsアプリ開発 と相性がよいテーマです。
技術相談・設計レビュー
既存アプリの `requireAdministrator` 常用を見直し、broker 設計や IPC 境界を再整理したい場合は、技術相談・設計レビューとして進めやすいテーマです。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- 同じプロセス内の一部の処理だけを管理者権限で実行できますか?
- できません。WindowsのUACは関数単位の昇格ではなく、プロセスがどのトークン・整合性レベルで動いているかで制御されます。親子プロセスは同じ整合性レベルでトークンを継承するため、非昇格のUIプロセス内で特定のメソッドだけを管理者権限で実行する設計は不可能です。必要な処理は別プロセス・サービス・スケジュールタスク・昇格COMなど、別の実行単位に切り出します。
- 管理者権限が必要な処理の分離方法にはどんな選択肢がありますか?
- Microsoft Learnでは主に4つのモデルが挙げられています。標準ユーザーUIと管理者helper EXEを組み合わせるAdministrator Broker Model、常駐サービスを使うOperating System Service Model、管理者権限のスケジュールタスクを使うElevated Task Model、昇格COMを使うAdministrator COM Object Modelです。管理者操作が散発的で必要な瞬間だけUACを出せばよい場合はbroker EXE、常時・無人・頻繁ならサービス、1回ごとに短く終わる定型処理ならタスクが向いています。
- runasで起動したhelper EXEとの通信に標準入出力は使えますか?
- 使いにくいため避けたほうがよいです。.NETではProcessStartInfo.VerbはUseShellExecute=trueのときにだけ有効で、UseShellExecute=trueにすると標準入出力のリダイレクト前提の通信は使えなくなります。そのためhelperとのやり取りには名前付きパイプなどのIPCを使うのが素直です。パイプは既定ACLに頼らず明示的なPipeSecurityを設定し、呼び出し元ユーザーSIDに接続権を限定したうえで、GetNamedPipeClientProcessIdで接続元PIDも検証します。
- 名前付きパイプにPipeOptions.CurrentUserOnlyを使えば安全ではないですか?
- 非昇格UIと昇格helperの通信には向きません。WindowsのCurrentUserOnlyはユーザーアカウントだけでなく昇格レベルも確認するため、整合性レベルの異なるプロセス間では接続できなくなります。さらに標準ユーザー環境ではUACがcredential promptになり、helperが別の管理者アカウントで動くこともあります。UI側で自分のSIDを取得してhelperに渡し、helper側でそのSIDにだけパイプ接続権を与える明示的なACLのほうが扱いやすいです。