Windows 앱에서 「관리자 권한이 필요한 처리만」을 분리하는 구체적인 방법
· 小村 豪 · Windows 개발, 보안, UAC, C# / .NET, Win32
이전에 쓴 「Windows 앱 개발에서 최소한의 보안을 지키기 위한 체크리스트」에서는 asInvoker를 기본으로 하고 관리자 권한이 필요한 처리만 분리한다는 라인을 썼습니다.
이번에는 그 부분을 실제로 어떻게 쓸지까지 파고듭니다.
Windows 앱에서는 같은 프로세스 안의 일부 처리만 편리하게 「관리자로 실행」할 수는 없습니다.
승격은 프로세스 경계의 이야기이므로, 필요한 건 「그 처리만을 다른 실행 단위로 잘라내는 설계」입니다.
여기서는 다음 순서로 정리합니다.
- 먼저 전제
- 어떤 분리 모델을 고를지
- 가장 실무에서 쓰기 쉬운
asInvoker+ 관리자 helper EXE 형태 - 구현 시 놓치고 싶지 않은 함정
- 구체적인 코드 예
코드 예는 .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개입니다.
- UI 프로세스는 끝까지 비승격 상태
- 관리자 helper는 단명
- helper가 받아들이는 조작은 고정 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 / 숫자 / 한정된 문자열 에 맞춥니다.
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 쪽에서는 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.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회 응답이라는 단순한 프로토콜로 해 두면 사고가 덜 납니다.
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
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows 앱에서 설정 파일에 기밀 정보를 평문으로 저장하지 않기 위한 베스트 프랙티스
Windows 데스크톱 앱에서 자격 증명이나 토큰을 평문 설정 파일에 두지 않기 위한 현실적인 설계를 정리합니다. DPAPI/ProtectedData의 사고 방식, CurrentUser와 LocalMachine의 차이, 로그 누출 회피까지 실무 ...
Windows 앱 개발에서 최저한의 보안을 지키기 위한 체크리스트
WPF・WinForms・WinUI・Win32 등 Windows 앱 개발에서 권한, 서명, 비밀 정보, HTTPS, 입력 검증, DLL 읽기, 로그까지 릴리스 전에 빠뜨리면 사고로 이어지는 최저한의 보안 항목을 체크리스트 형태로 정리합니다.
Windows의 관리자 특권이 필요해지는 것은 언제인가 - UAC, 보호 영역, 설계상의 구분 방법
Windows에서 관리자 권한이 필요한지는 사용자 직함이 아닌 앱이 건드리는 경계로 정해진다는 관점에서, UAC 동작과 보호 영역, per-user 대 per-machine, 매니페스트와 분리 모델까지 정리하여 불필요한 승격을 줄이는 설계 판단 ...
Windows 샌드박스로 Windows 앱 개발의 검증을 빠르게 하는 방법 - 관리자 권한 문제, 클린 환경, 권한 부족・리소스 부족의 재현을 실무용으로 정리
Windows Sandbox로 Windows 앱의 클린 환경 검증을 빠르게 하는 실무 노하우를 정리합니다. .wsb 파일을 용도별로 나누고, 입력은 읽기 전용・출력만 쓰기 가능으로 분리하며, 표준 사용자나 메모리 부족, GPU 없는 상태의 재현까...
자동 업데이트 기능의 보안 기본 - 나쁜 패턴과 베스트 프랙티스
자동 업데이트를 신뢰 경계로 다루는 사고방식을 정리합니다. HTTPS만으로 부족한 이유, signed metadata와 클라이언트 측 검증, 키 분리, fail-closed와 rollback, MSIX·ClickOnce 등 Windows의 기존 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
저자 프로필
기사 저자의 프로필 페이지입니다.
Go Komura
합동회사 코무라소프트 대표
Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.
공개 링크