在 Windows 應用程式中只分離「需要系統管理員權限的處理」的具體寫法

· 更新日期: · · Windows 開發, 資安, UAC, C# / .NET, Win32

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279409)
補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616292)
初次發布
引用本文(DOI: 10.5281/zenodo.21616291)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈在 Windows 應用程式中只分離「需要系統管理員權限的處理」的具體寫法〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616291 https://comcomponent.com/zh-TW/blog/2026/03/16/001-windows-admin-broker-deep-dive/

DOI(最新版本)
10.5281/zenodo.21616291
DOI(此版本)
10.5281/zenodo.22297131

之前寫過的「Windows 應用程式開發的最低限度資安檢核表」裡,畫下了「以 asInvoker 為基礎,只把需要系統管理員權限的處理分離出來」這條線。

這次要把這個部分深入到實際上要怎麼寫。

在 Windows 應用程式中,沒辦法只讓同一個處理程序中的一部分處理方便地「以系統管理員身分執行」。 權限提升是處理程序邊界的問題,所以真正需要的是「只把那個處理切到另一個執行單位」的設計。

權限提升是處理程序邊界的問題說明無法只讓同一個處理程序中的一部分處理以系統管理員身分執行,權限提升是處理程序邊界的問題,因此需要把該處理切到另一個執行單位的設計的圖。在同一個處理程序內做不到能做到的是這一邊只想讓一部分處理變成系統管理員在同一個處理程序中提升權限把處理切到另一個執行單位

圖 1: 權限提升的單位是處理程序而不是函式,因此只有切出去的設計才成立。

本文按以下順序進行。

  1. 先講前提
  2. 要選哪一種分離模型
  3. 實務上最好用的 asInvoker + 系統管理員 helper EXE 形狀
  4. 實作時不想漏掉的陷阱
  5. 具體的程式碼範例

程式碼範例以 .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、管道的 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 的電腦。範例會在 machine-wide 建立 HKLM\SOFTWARE\Classes\*\shell\MyApp.Open。不要用平常在用的開發機,在評估用的 VM 上試比較安全

建置與執行的具體命令整理在範例的 README 中。只有一點請先記在腦中:UI 與 helper 要發行到同一個資料夾之後再執行(因為 helper 會固定解析自己所在資料夾中的 MyApp.exe)。

1. 先說結論

先把實務上的落腳點列出來。

  • 一般的 UI 應用程式維持 asInvoker 執行
  • 需要系統管理員權限的處理切到另一個 EXE
  • 那個 helper EXE 設為 requireAdministrator
  • 啟動用 runas
  • 與 helper 的通訊不要用不適合搭配 runas 的標準輸入輸出,改用具名管道之類的 IPC
  • 傳給 helper 的不是「原始的命令字串」,只傳有型別的請求
  • helper 側要再驗證一次請求內容
  • IPC 的連線來源要用呼叫端使用者 SID 與預期的 PID限縮

「用系統管理員身分跑比較輕鬆」只有第一次是輕鬆的。 之後在 UAC、拖放、日誌設計、外部輸入、支援維運、DLL 載入、設定儲存位置這些地方,大概都會被擺臉色看。

實務落腳點的骨架說明 UI 維持 asInvoker 執行,需要系統管理員權限的處理切到 requireAdministrator 的另一個 EXE 並以 runas 啟動,再用具名管道只傳遞有型別請求這個骨架的圖。用 runas 啟動用具名管道傳有型別的請求UI 維持 asInvokerhelper EXE(requireAdministrator)helper 側再驗證請求

圖 2: 骨架由「未提升權限的 UI + 已提升權限的 helper + 有型別請求的 IPC」這 3 點決定。

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 21 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

2. 前提梳理: 沒辦法只把同一個處理程序的一部分變成系統管理員

Windows 的 UAC 不是「函式層級的權限提升」,而是用「處理程序以哪個權杖 / 完整性等級在執行」來控制。 需要系統管理員存取權杖的應用程式會成為權限提升提示的對象,父子處理程序則以相同的完整性等級繼承權杖。 也就是說,在未提升權限的 UI 處理程序裡,讓某個方法突然以系統管理員權限執行這種設計是做不到的。 真的需要的話,就用另一個處理程序、服務、排程工作、已提升權限的 COM 等別的執行單位。

如果拿掉這個前提來想,就會變成「只想在按下這個按鈕的瞬間變成系統管理員」這種有點可憐的設計諮詢。 Windows 不會用魔法把那裡填起來。

UAC 由權杖與完整性等級控制說明 UAC 不是函式層級的權限提升,而是由處理程序以哪個權杖與完整性等級在執行來控制,父子處理程序以相同完整性等級繼承權杖,因此無法以方法為單位提升權限而需要另一個執行單位的圖。UAC 的控制單位處理程序的權杖與完整性等級父子處理程序繼承相同的等級無法以方法為單位提升權限另一個處理程序、服務、排程工作、已提升權限的 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 四個階段,標準使用者拿到 medium,已提升權限的使用者拿到 high。 也就是說,本文的設計講的是把「medium 的 UI 處理程序」和「high 的 helper 處理程序」明確畫出界線再讓它們對話。

腦中先有這個對應,5.6 的 CurrentUserOnly 那段和 16.4 都會變得比較好讀。

medium 與 high 的界線說明以標準使用者身分執行的 asInvoker UI 拿到 medium,已提升權限的 requireAdministrator helper 拿到 high 完整性等級,本文的設計就是在這兩者之間明確畫線再讓它們對話的圖。畫出界線再對話medium: asInvoker 的 UI 處理程序high: 已提升權限的 helper 處理程序標準使用者的權杖已提升權限使用者的權杖

圖 4: 這套設計就是在 medium 的 UI 與 high 的 helper 之間畫出明確邊界。

3. 要選哪一種分離模型

Microsoft Learn 針對需要系統管理員權限的應用程式,主要列出以下 4 種分離方式。

模型 大致的形狀 適合的場合
Administrator Broker Model 標準使用者的 UI 應用程式 + 系統管理員 helper EXE 系統管理員操作是零星的,只要在需要的瞬間彈出 UAC 就好
Operating System Service Model 標準使用者 UI + 常駐服務 持續運轉的管理功能、背景監控、無人處理
Elevated Task Model 標準使用者 UI + 系統管理員權限的排程工作 每一次都短短結束的定型處理
Administrator COM Object Model 標準使用者 UI + 已提升權限的 COM 已經有既有的 COM 設計,而且功能範圍相當受限的情況

挑選的判斷基準大致如下。

3.1 最容易先考慮的是 broker EXE

broker EXE 特別合用的,是這一類操作。

  • Explorer 整合的註冊 / 取消註冊
  • HKLM 底下的 machine-wide 設定變更
  • 自家應用程式的服務註冊 / 取消註冊
  • 防火牆規則的新增 / 刪除
  • Program Files 底下的系統管理員操作

這些操作常常是平常不需要,只有按下設定畫面的特定按鈕時才需要。 這種情況下,與其搬出常駐服務,把系統管理員 helper EXE 啟動一次就結束的形狀比較直接了當。

broker EXE 合用的形狀說明當系統管理員操作平常不需要、只有按下設定畫面特定按鈕時才需要,比起常駐服務,把系統管理員 helper EXE 啟動一次就結束的形狀更直接了當的圖。這種頻率不需要按下設定畫面的特定按鈕只啟動 helper EXE 一次操作結束後 helper 就消失搬出常駐服務

圖 5: 零星的系統管理員操作,最直接的就是只在需要的瞬間活著的 helper EXE。

3.2 選服務的理由是「持續」「無人」「頻繁」

服務是由標準使用者應用程式透過 RPC 等方式通訊的模型。 優點是不用出現權限提升提示就能接下管理端的處理,代價則是維運常駐處理程序的責任變重。

服務模型的取捨說明服務模型的優點是不用出現權限提升提示就能接下管理端處理,代價是維運常駐處理程序的責任變重這個取捨的圖。優點代價Operating System Service Model不用提示就能接下維運常駐處理程序的責任適合持續、無人、頻繁的用途

圖 6: 服務是用常駐維運的負擔換來免提示的選擇。

服務合用的是這一類用途。

  • 持續監控
  • 日誌收集
  • 背景更新
  • 與設備或 daemon 的持續介接
  • 由多個 UI 工作階段共用的管理功能

3.3 排程工作適合「短的定型處理」

Elevated Task Model 是由標準使用者應用程式啟動一個以系統管理員權限執行的排程工作。 比服務輕,做完就關掉,所以適合每次一回的定型作業。

3.4 已提升權限的 COM 用途相當受限

COM elevation moniker 看起來很方便,但可用的地方很窄。 Microsoft Learn 也提到,能夠控制已提升權限 COM 的 UI 必須由 COM 側提供,因此它並不適合「從未提升權限的 UI 讓已提升權限的 COM 為所欲為」這個方向。

已提升權限 COM 用途的狹窄說明已提升權限的 COM 看起來方便,但能控制它的 UI 必須由 COM 側提供,因此不適合從未提升權限的 UI 讓它為所欲為的方向的圖。不適合這個方向已提升權限的 COM 看起來很方便從未提升權限的 UI 隨意操作可用的地方很窄能控制它的 UI 由 COM 側提供

圖 7: 已提升權限的 COM 以控制 UI 由 COM 側持有為前提,不會是通用的逃生口。

4. 本次的推薦: asInvoker UI + requireAdministrator helper EXE

接下來把實務上最好用的形狀具體化。

high integrity ── 短命的已提升權限處理程序medium integrity ── 到最後都不提升權限以絕對路徑 + Verb=runas 啟動(UAC 提示會在這裡出現)有型別的請求(不傳原始的命令字串)MyApp.AdminBroker.exe(requireAdministrator)具名管道的接收端只允許 UI 使用者的 SID 連線也核對連線來源 PID用 operation 的 allowlist 分派helper 側再驗證一次引數MyApp.exe(asInvoker)只接收使用者的操作並組出請求需要系統管理員權限的固定對象HKLM 底下的登錄機碼 / 服務註冊 / 防火牆規則

圖 8: 讓權限提升的邊界與處理程序邊界一致。UI 側維持 medium,只有 helper 以 high 短時間執行

重點有 3 個。

  1. UI 處理程序到最後都維持未提升權限
  2. 系統管理員 helper 是短命的
  3. helper 接受的操作只有固定的 allowlist

光是守住這 3 點,設計就會清爽很多。

該守住的 3 個重點說明 UI 處理程序到最後都維持未提升權限、系統管理員 helper 是短命的、helper 接受的操作只有固定 allowlist 這 3 點,光是守住就能讓設計清爽很多的圖。UI 到最後都未提升權限設計清爽很多helper 是短命的接受的操作只有 allowlist

圖 9: 光是守住未提升權限、短命、allowlist 這 3 點,權限邊界的形狀就定下來了。

5. 實作時不想漏掉的規則

這裡是寫程式之前最好先決定好的部分。

5.1 不要把 helper 做成「什麼都接」

不好的例子是這些。

  • 從 UI 把 reg add ... 整串字串丟給 helper
  • 從 UI 把 sc.exe ... 整串字串丟給 helper
  • 從 UI 把任意的登錄檔路徑或任意的 EXE 路徑傳給 helper

這樣做的話,UI 壞掉時 helper 也會跟著一起壞。 系統管理員 helper 位在權限提升邊界的內側。 在這裡開一個「什麼都能執行的口」,相當危險。

好的形狀是這樣。

  • set-explorer-context-menu
  • install-service
  • add-firewall-rule

像這樣把操作本身固定住,需要的引數也收攏成 bool / enum / 數值 / 受限的字串。

不做成什麼都接的形狀說明把原始命令字串或任意路徑傳給 helper 會開出什麼都能執行的口,UI 壞掉時 helper 也會跟著壞,因此要固定操作本身並把引數收攏成受限型別的圖。傳原始的命令字串開出什麼都能執行的口UI 壞掉時 helper 也會壞固定操作並把引數限定型別helper 的意義被收窄

圖 10: 送進權限提升邊界內側的,只能是固定的操作與受限的引數。

5.2 傳給 helper 的 path 要用絕對路徑,而且不要由 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 之間並不一樣。 這裡若交給預設值決定,之後就會出現「有的環境會動、有的環境不會動」這種不起眼卻很惱人的事故。

所以這裡要一定明確寫出來。

runas 啟動時該明確指定的設定說明 ProcessStartInfo 的 Verb 只有在 UseShellExecute 為 true 時才有效,而其預設值在 .NET Framework 與 .NET 之間不同,交給預設值決定會出現有的環境會動有的不會動的事故,因此必須明確指定的圖。交給它決定的話所以想使用 Verb=runas需要 UseShellExecute=true預設值在 Framework 與 .NET 之間不同會出現會動與不會動的環境一定要明確寫出來

圖 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 執行時的帳戶 建立管道的 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 管道連線權限
  • 再用 GetNamedPipeClientProcessId 確認連線來源 PID

這樣的做法。

交遞 SID 讓兩條路徑都成立說明不論 consent prompt 或 credential prompt 都成立的唯一形狀,是由 UI 側取得自己的 SID 傳給 helper,helper 只把管道連線權限給該 SID,再確認連線來源 PID 的流程圖。因提升等級的落差被擋下UI 取得自己的 SID 並傳過去helper 只給該 SID 連線權限也確認連線來源 PID使用 CurrentUserOnly在這個用途中不成立

圖 12: 兩條提示路徑都能成立的,只有用 UI 傳來的 SID 組 ACL 這個形狀。

5.7 PID 驗證是為了減少「隨手插隊」的追加防禦

光是隨機的管道名稱就已經好很多,但同一個使用者底下執行的其他處理程序搶先連上的餘地並不是零。 因此在 helper 側使用 GetNamedPipeClientProcessId,確認是否與預期的 UI 處理程序 PID 一致。

當然,並不是 PID 對得上就什麼都能相信。 只要 UI 被入侵,危險的請求一樣會送到 helper。 正因為如此,才需要 helper 側的 operation allowlist 與引數驗證。

層層疊加的追加防禦說明把隨機管道名稱、限定 SID 的 ACL、連線來源 PID 的核對、operation allowlist 與引數驗證層層疊加,藉此同時減少隨手插隊與危險請求的思路的圖。隨機的管道名稱限定 SID 的 ACL核對連線來源 PIDallowlist 與引數的再驗證PID 對得上也不相信請求

圖 13: 沒有任何一層是完整的,所以要在連線與請求兩邊都疊上層次來收窄。

把目前為止的規則按啟動到結束的順序排開,會是這樣。只要能讀出「UI 側的每一手都與 helper 側的確認成對」這個形狀就夠了。

需要系統管理員權限的對象AdminBroker.exe(high)Windows / UACMyApp.exe(medium)需要系統管理員權限的對象AdminBroker.exe(high)Windows / UACMyApp.exe(medium)決定管道名稱,備妥自己的 SID 與 PID以絕對路徑 + Verb=runas 啟動通過核准後以已提升權限的權杖啟動用只允許該 SID 的 ACL 建立管道連線到管道核對連線來源 PID有型別的請求(operation 名稱與引數)拒絕 allowlist 之外與非預期的引數只對固定的對象執行操作傳回結果結束,不留下已提升權限的狀態

圖 14: 啟動、連線、請求各自都對應到 helper 側的確認。少掉其中任何一個,那一段就會變成不檢查的直通

6. 範例的題材

這次採用把 Explorer 的右鍵功能表以 machine-wide 註冊 / 取消註冊的例子。

理由很單純,因為它

  • 需要系統管理員權限
  • 操作的邊界很清楚
  • 不用把任意的命令字串傳給 helper
  • 在實務上也很常見

登錄目標是下面這種固定的登錄機碼。

  • HKLM\SOFTWARE\Classes\*\shell\MyApp.Open
  • HKLM\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;
    }
}

重點在於不要把 JSON 就這樣一股腦地往管道裡灌,而是加上長度再送。 把協定做成一次請求、一次回應這種單純的形狀,就比較不容易出事。

帶長度的單純協定說明不要把 JSON 直接灌進管道,而是加上長度標頭再送,並把協定做成只有一次請求與一次回應的單純形狀就比較不容易出事的圖。寫入長度標頭寫入本文的 JSON對方只讀取長度所指的位元組數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 的,只有管道名稱與確認連線來源所需的最小資訊。 系統管理員操作本身,封在管道中傳送的有型別 request 裡。

啟動引數與管道的職責分工說明 helper 的啟動引數只傳管道名稱與確認連線來源所需的最小資訊,系統管理員操作本身則封在管道中傳送的有型別 request 裡這個職責分工的圖。傳的是封起來的是啟動引數管道名稱、PID、SID 這些最小資訊管道之中有型別 request 的系統管理員操作操作內容不放在引數上

圖 16: 引數只用來安排連線的手續,操作的內容則以有型別 request 封在管道裡。 這個 QuoteArgument 是以本範例傳遞的管道名稱、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 側: 建立管道、驗證連線來源 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);
}

這裡發揮作用的是下面幾點。

  • 明確組出管道的 ACL
  • ACL 不只給 helper 目前的使用者 SID,也要給呼叫端的 UI 使用者 SID
  • 連線之後驗證 client PID
  • 收到 request 之後也要依 operation 名稱做 dispatch

用 switch (request.Operation) 做成只放行固定操作的形狀,helper 就比較不會變成「已提升權限的萬能箱」。

helper 側生效的驗證順序說明 helper 以明確的 ACL 建立管道、連線後驗證用戶端 PID、request 依 operation 名稱 dispatch 而只放行固定操作,這個在 helper 側生效的驗證順序的圖。在 allowlist 內不在 allowlist 內用明確的 ACL 建立管道驗證連線來源 PID依 operation 名稱 dispatch只執行固定的操作拒絕並回應

圖 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 用絕對路徑
  • 明確寫出 runas
  • 明確寫出 UseShellExecute = true

15.4 收窄了 IPC 的連線來源

  • 管道 ACL 限定成 UI 使用者 SID
  • 連線之後確認 client PID

15.5 系統管理員操作的對象也是固定的

  • 登錄檔的 hive / path 固定
  • 註冊目標 EXE 也是固定解析

做到這個程度,就離「UI 壞掉就能透過 helper 為所欲為」的狀態相當遠了。

15.6 確認是否真的分離成功

到這裡為止談的都是設計。寫出來的東西是不是真的分離了,不動手跑一次是不會知道的。 「UI 整體不知不覺就提升權限了」是那種只讀程式碼很難察覺的事故。

是否分離成功的 4 階段確認說明依序確認 UI 處理程序是否維持未提升權限、是否只有在 helper 啟動時才出現 UAC 提示、系統管理員操作是否真的生效、以及該失敗時是否會失敗這 4 項的流程圖。UI 是否維持未提升權限是否只在 helper 啟動時出現提示操作是否真的生效該失敗時是否會失敗不看第 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

明明只有設定畫面的一個按鈕需要系統管理員權限,卻整支程式都以已提升權限啟動。 這是把權限邊界草率抹平的方向。

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
  • 刪除任意的服務名稱
  • 用任意命令新增防火牆規則

helper 一旦接下這些,helper 本身就成了系統管理員權限的通用執行口。 操作一定要固定化比較好。

17. 總結

在 Windows 應用程式中「只有一部分處理需要系統管理員權限」並不是什麼稀奇的事。 但它的解法不是「全部設成 requireAdministrator」,而是切開執行邊界。

最容易先採用的,是這個形狀。

  • UI 用 asInvoker
  • 系統管理員處理分離到 helper EXE
  • helper 用 requireAdministrator
  • 啟動用 runas
  • 通訊用 named pipe
  • helper 只接受固定的 operation
  • 用管道 ACL 與 client PID 收窄連線來源
  • helper 側再驗證一次引數

做成這個形狀,之後想改成服務時也比較好遷移。 只要把 operation 契約好好分開,UI 與系統管理員處理之間的邊界就直接成為設計資產。

邊界會成為設計資產說明只要把 operation 契約好好分開,UI 與系統管理員處理之間的邊界就直接成為設計資產,之後想改成服務時也比較好遷移的圖。把 operation 契約分開UI 與系統管理員處理的邊界明確邊界直接成為設計資產改成服務的遷移也比較容易

圖 19: 用 broker 形狀切出的邊界,將來要改成服務時也能原樣沿用成資產。

資安的話題,比起加上炫目的功能,不留下草率的邊界更有用。 系統管理員權限也一樣。 不要一次全部交出去,只在必要的地方,盡可能窄地交出去。 這種不起眼的程度,之後才會顯現出價值。

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/zh-TW/blog/2026/03/14/001-windows-app-security-minimum-checklist/
  • Administrator Broker Model - Win32 apps https://learn.microsoft.com/en-us/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/en-us/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/en-us/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/en-us/dotnet/fundamentals/runtime-libraries/system-diagnostics-processstartinfo-useshellexecute
  • Named Pipe Security and Access Rights https://learn.microsoft.com/en-us/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/en-us/windows/win32/api/winbase/nf-winbase-getnamedpipeclientprocessid
  • RegistryView Enum https://learn.microsoft.com/en-us/dotnet/api/microsoft.win32.registryview?view=net-8.0

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

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;持續、無人、頻繁的情況適合服務;每次都短短結束的定型處理則適合排程工作。
與用 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 來控制。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽