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

· · Windows 開發, 資安, UAC, C# / .NET, Win32

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

這次把這部分進一步深入到 實際上要怎麼寫

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

以下按下列順序整理:

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

程式範例以 .NET 8 / Windows 桌面應用程式 為前提。
UI 框架用 WPF / WinForms / WinUI 都行,差別大致只在 UI 側事件處理。

1. 先說結論

先把結論列出來,實務上大致如下。

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

「以系統管理員身分跑比較輕鬆」只有第一次而已。
之後在 UAC、拖放、日誌設計、外部輸入、支援運維、DLL 載入、設定存放位置等地方,大致都會皺眉頭。

2. 前提整理:沒辦法只讓同一行程的一部分變成系統管理員

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

把這個前提拿掉不看,就會變成「我只想在按下那顆按鈕的那一瞬間變成系統管理員」,這種有點可憐的設計諮詢。
Windows 沒辦法用魔法把那個洞補起來。

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

例如下列情境:

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

這些 平常不需要,只有按下設定畫面的特定按鈕時才需要
這種場合,與其搬出常駐 service,不如 把系統管理員 helper EXE 啟動一次就結束 會比較自然。

3.2 選 service 是為了「常時」「無人」「頻繁」

service 是從標準使用者應用程式以 RPC 等通訊的模型。
好處是 不彈提升提示 就能接下管理側處理,代價是多了 必須運維常駐行程 的責任。

例如下列情境:

  • 常時監控
  • 日誌收集
  • 背景更新
  • 與設備或 daemon 的常時連動
  • 多 UI session 共享的管理功能

3.3 task 適合「短的定型處理」

Elevated Task Model 是從標準使用者應用程式啟動以系統管理員權限執行的排程工作。
比 service 輕,結束會關閉,適合 每次一組的定型工作

3.4 提升後的 COM 適用範圍相當窄

COM elevation moniker 看起來很方便,但能用的場合有限。
Microsoft Learn 也明示控制提升後 COM 的 UI 必須從 COM 側提供,所以 並不適合「從未提升 UI 隨意指揮提升後 COM」 的方向。

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

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

[ MyApp.exe ]  asInvoker
      |
      |  ShellExecute / ProcessStartInfo + Verb=runas
      v
[ MyApp.AdminBroker.exe ]  requireAdministrator
      |
      |  named pipe
      v
[ 只執行需要系統管理員權限的固定處理 ]

3 個要點:

  1. UI 行程從頭到尾都不提升
  2. 系統管理員 helper 短命
  3. helper 接受的操作只來自固定 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 / 數值 / 受限字串

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 上不同。
如果依賴預設值,日後就會碰到「有的環境能跑、有的不能跑」這種小事故,很討厭。

所以這裡 一定要明寫

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 會變成 credential prompt,helper 可能會以另一個系統管理員帳號執行。
這種情況下,helper 側若直接用 WindowsIdentity.GetCurrent() 建 ACL,原本的 UI 使用者有可能連不上

所以這次採用:

  • UI 側取得自己的 SID 傳給 helper
  • helper 側 只把 pipe 連線權限 給到 UI 使用者 SID
  • 再用 GetNamedPipeClientProcessId 確認 連線端 PID

這種做法。

5.7 PID 驗證是為了減少「粗糙插隊」的額外防線

光是隨機 pipe 名就已經好很多,但同一使用者的其他行程仍有可能先搶著連線。
所以在 helper 側用 GetNamedPipeClientProcessId 確認 是否與預期的 UI 行程 PID 一致

當然,PID 對上了也不代表什麼都可以信
UI 被攻陷的話,helper 也會收到危險的請求。
所以 helper 側的 operation allowlist 和參數驗證才必要。

6. 範例的題材

本次使用 以 machine-wide 方式在 Explorer 右鍵選單註冊 / 移除 的例子。

原因很單純:

  • 需要系統管理員權限
  • 操作邊界清楚
  • 不必把任意命令字串傳給 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 型別
  • pipe 的訊息格式

在 UI 與 helper 兩邊保持一致。

8. Manifest

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 直接不斷流進 pipe,而是加上長度再送
採用一次請求、一次回應的單純協議,事故會少很多。

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);
        }
    }
}

傳給 helper 的只有 pipe 名稱以及用於驗證連線來源的最少資訊。
系統管理員操作本身封閉在 pipe 裡以 有型別的 request 傳遞。

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、分派

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));
        }
    }

    [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 名稱分派

switch (request.Operation) 只放行固定操作,helper 就不容易變成「可以做任何事的提升箱」。

13. 系統管理員操作的本體:Explorer 右鍵選單註冊

13.1 MyApp.AdminBroker/ExplorerContextMenuRegistration.cs

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);
        }
    }
}

這段程式碼的意圖相當重要:

  • 沒有從 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 連線來源被限縮

  • pipe ACL 限定 UI 使用者 SID
  • 連線後驗證 client PID

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

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

做到這個程度,離「UI 壞掉 helper 就什麼都能做」的狀態就相當遠了。

16. 常見的 NG

16.1 整個 UI 設成 requireAdministrator

設定畫面只有一個按鈕要系統管理員權限,卻全部都用提升後啟動。
這是把權限邊界粗暴抹平的方向。

16.2 把原始字串命令丟給 helper

例如下面這種設計:

UI -> 對 helper 丟 "reg add HKLM\\.... /v ... /d ..."

這樣 helper 就變成 command executor。
不要這樣做。

16.3 直接用具名管道的預設 ACL

「反正只是本機 IPC 應該沒問題吧」這種想法其實有點危險。
pipe 本來就是 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 與系統管理員處理之間的邊界本身就會變成設計資產

資安的事情,比起加上華麗功能,不留下粗糙邊界 更有效果。
系統管理員權限也一樣。
不要全部一起包著持有,只把需要的部分盡量狹窄地遞出去。
這種程度的樸實,之後會起作用。

18. 參考資料

  • 原文: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
  • 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

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

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

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

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽