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 로 좁힌다

「관리자로 돌리면 편하다」는 첫 1회뿐입니다.
나중에 UAC, 드래그 앤 드롭, 로그 설계, 외부 입력, 서포트 운용, DLL 로드, 설정 저장 위치 쪽에서 대개 싫은 얼굴을 봅니다.

2. 전제 정리: 같은 프로세스의 일부만 관리자화할 수는 없다

Windows의 UAC는 「함수 단위 승격」이 아니라 「프로세스가 어떤 토큰 / 무결성 레벨로 도는가」로 제어됩니다.
관리자 액세스 토큰이 필요한 앱은 승격 프롬프트의 대상이 되며, 부모-자식 프로세스는 같은 무결성 레벨로 토큰을 상속합니다.
즉, 비승격 UI 프로세스 안에서 어떤 메서드만 갑자기 관리자 권한으로 실행한다 는 설계는 불가능합니다.
필요하다면 별도 프로세스·서비스·태스크·승격 COM 등 다른 실행 단위를 씁니다.

이 전제를 빼놓고 생각하면 「이 버튼을 누른 순간만 관리자로 하고 싶다」라는 조금 딱한 설계 상담이 됩니다.
Windows는 거기를 마법으로 메워주지 않습니다.

3. 어떤 분리 모델을 고를까

Microsoft Learn에서는 관리자 권한이 필요한 앱의 분리 방법으로 주로 다음 4가지를 들고 있습니다.

모델 대략적인 형태 적합한 장면
Administrator Broker Model 표준 사용자 UI 앱 + 관리자 helper EXE 관리자 조작이 산발적이고, 필요한 순간에만 UAC를 띄우면 되는 경우
Operating System Service Model 표준 사용자 UI + 상주 service 상시 가동 관리 기능, 백그라운드 감시, 무인 처리
Elevated Task Model 표준 사용자 UI + 관리자 권한의 스케줄 태스크 1회마다 짧게 끝나는 정형 처리
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 등으로 통신하는 모델입니다.
이점은 승격 프롬프트 없이 관리 측 처리를 받을 수 있다는 것이지만, 그 대신 상주 프로세스를 운용할 책임 이 늘어납니다.

예컨대 다음과 같은 케이스입니다.

  • 상시 감시
  • 로그 수집
  • 백그라운드 업데이트
  • 장비나 데몬과의 상시 연동
  • 복수 UI 세션에서 공유되는 관리 기능

3.3 task는 「짧은 정형 처리」에 맞다

Elevated Task Model은 표준 사용자 앱에서 관리자 권한으로 도는 스케줄 태스크를 기동하는 형태입니다.
service보다 가볍고, 끝나면 닫히므로 1회마다의 정형 잡 에 맞습니다.

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에서 helper로 reg add ...를 통째로 문자열로 넘긴다
  • UI에서 helper로 sc.exe ...를 통째로 문자열로 넘긴다
  • UI에서 helper로 임의의 레지스트리 패스나 임의의 EXE 패스를 넘긴다

이걸 하면 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.VerbUseShellExecute=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 쪽에서는 UI 사용자 SID에만 pipe 접속 권한을 준다
  • 나아가 GetNamedPipeClientProcessId접속원 PID 도 확인

하는 형태로 합니다.

5.7 PID 검증은 「거친 끼어들기」를 줄이기 위한 추가 방어

랜덤 pipe 이름만으로도 꽤 낫지만 같은 사용자로 도는 다른 프로세스가 먼저 접속할 여지는 제로가 아닙니다.
그래서 helper 쪽에서 GetNamedPipeClientProcessId를 써서 예상한 UI 프로세스 PID와 일치하는지를 확인합니다.

물론 PID가 맞으면 뭐든 신용해도 된다는 건 아닙니다.
UI가 침해되면 helper에도 위험한 요청이 도착합니다.
그래서 helper 쪽의 operation allowlist와 인자 검증이 필요합니다.

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

포인트는 pipe에 JSON을 그대로 줄줄 흘리지 말고, 길이 붙여서 보내는 것 입니다.
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);
        }
    }
}

여기서 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 검증·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));
        }
    }

    [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가 「승격된 무엇이든 상자」가 되기 어렵습니다.

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는 absolute path
  • 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로 한다

설정 화면의 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와 관리자 처리의 경계가 그대로 설계 자산 이 됩니다.

보안 이야기는 화려한 기능을 더하는 것보다 거친 경계를 남기지 않는 편이 효과 있습니다.
관리자 권한도 마찬가지입니다.
전부 몰아서 가지게 하지 말고, 필요한 곳만, 가능한 한 좁게 넘긴다.
그 정도의 수수함이 나중에 효과가 옵니다.

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 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기