Windows 앱에서 “관리자 권한이 필요한 처리만” 분리하는 구체적인 작성법

· 업데이트: · · Windows 개발, 보안, UAC, C# / .NET, Win32

수정 이력(7건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635149)
권한 상승이 프로세스 경계의 이야기라는 점, 모델 선택 방식, runas 실행과 파이프 ACL 설계 판단, 확인 절차 등을 그림으로도 따라갈 수 있도록 Mermaid 그림 17개를 추가했습니다(본문 500~750자당 그림 1개의 규칙에 맞춘 것입니다). 기존 그림 2개까지 포함해 그림 번호를 처음부터 다시 매겼습니다. 본문 문장은 바꾸지 않았습니다.
글 맨 앞에 “이 글의 지식 맵” 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
4장의 텍스트 그림을 무결성 수준과 검증 지점까지 나타낸 구성도로 다시 그렸습니다. 실행부터 종료까지의 절차와 확인의 대응을 시퀀스 그림으로 추가했습니다. 본문 설명은 바꾸지 않았습니다.
외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참고하세요.
실제로 분리되어 있는지를 확인하는 절을 새로 만들었습니다(UI가 상승하지 않은 상태인지, helper 실행 시에만 확인 화면이 나오는지, 실패해야 할 때 실패하는지). 시험하기 전에 준비할 것과 평가용 VM을 권하는 주의, 무결성 수준과 표준 사용자·권한 상승 프로세스의 대응, 누가 어떤 토큰으로 파이프에 연결하는지의 표, 읽는 법 안내를 추가했습니다.
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635148)

이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.

Go Komura (2026). 「Windows 앱에서 “관리자 권한이 필요한 처리만” 분리하는 구체적인 작성법」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635148 https://comcomponent.com/ko/blog/windows-admin-broker-deep-dive/

DOI(최신 버전)
10.5281/zenodo.21635148
DOI(이 버전)
10.5281/zenodo.22217443

이전에 쓴 “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, pipe ACL, PID 검증)
구현 코드 7~14장(구성, 매니페스트, 공통 계약, UI 쪽, helper 쪽)
올바르게 분리됐는지 확인하는 방법 15.6
하면 안 되는 형태 16장

코드 전문은 GitHub 샘플에도 같은 내용이 들어 있습니다. 설계만 가져가려면 1~5장과 15~16장, 구현까지 보려면 처음부터 끝까지, 라는 읽기가 가능합니다.

실행해 보기 전에 준비할 것

이 샘플을 실제로 돌리려면 다음이 필요합니다.

  • Windows 머신(UAC 권한 상승 프롬프트, PipeSecurity로 만드는 명시 ACL, GetNamedPipeClientProcessId, HKLM 쓰기는 모두 Windows 전용입니다)
  • .NET 8 SDK 이상
  • 권한 상승을 승인할 수 있는 계정. 관리자 계정이면 consent prompt, 표준 사용자면 credential prompt가 됩니다. 두 경로를 모두 확인하고 싶다면 양쪽 계정을 준비합니다
  • HKLM을 바꿔도 되는 머신. 샘플은 HKLM\SOFTWARE\Classes\*\shell\MyApp.Open을 machine-wide로 만듭니다. 평소 쓰는 개발기가 아니라 평가용 VM에서 시험하는 편이 안전합니다

빌드와 실행의 구체적인 명령은 샘플의 README에 정리되어 있습니다. UI와 helper를 같은 폴더로 publish한 뒤 실행한다는 점만 먼저 기억해 두면 됩니다(helper가 자신과 같은 폴더의 MyApp.exe를 고정해서 찾기 때문입니다).

1. 먼저 결론

실무에서 맞출 지점을 먼저 나열합니다.

  • 일반 UI 앱은 asInvoker 그대로 돌린다
  • 관리자 권한이 필요한 처리는 별도 EXE로 잘라낸다
  • 그 helper EXE는 requireAdministrator로 한다
  • 실행은 runas로 한다
  • helper와의 통신은 runas와 잘 맞지 않는 표준 입출력이 아니라 named pipe 같은 IPC를 쓴다
  • helper에 넘기는 것은 “날것의 명령 문자열”이 아니라 타입이 있는 요청만으로 한다
  • helper 쪽에서는 요청 내용을 한 번 더 검증한다
  • IPC 연결 주체는 호출 측 사용자 SID와 예상 PID로 좁힌다

“관리자로 돌리면 편하다”는 처음 한 번뿐입니다. 나중에 UAC, 드래그 앤 드롭, 로그 설계, 외부 입력, 지원 운용, DLL 로드, 설정 저장 위치 쪽에서 대개 싫은 얼굴을 봅니다.

실무에서 맞출 지점의 골격UI는 asInvoker 그대로 두고, 관리자 권한이 필요한 처리는 requireAdministrator의 별도 EXE로 잘라내 runas로 실행하며, named pipe로 타입이 있는 요청만 주고받는다는 골격을 보이는 그림.runas로 실행named pipe로 타입이 있는 요청UI는 asInvoker 그대로helper EXE(requireAdministrator)helper 쪽에서 요청을 재검증

그림2: 골격은 “상승하지 않은 UI + 권한 상승 helper + 타입이 있는 요청의 IPC” 세 점으로 정해집니다.

이 글의 지식 맵

이 글은 Windows 앱에서 관리자 권한이 필요한 처리만 분리하는 구체적인 구현을 다룹니다. UAC는 프로세스의 무결성 레벨로 제어되며 부모-자식 프로세스는 같은 레벨로 토큰을 상속하기 때문에, 같은 프로세스 안의 일부만 승격시킬 수는 없고, 표준 사용자의 UI와 관리자 권한의 helper EXE를 조합하는 Administrator Broker Model이 기본형이 됩니다. helper의 기동에는 runas를 쓰고, UI와 helper의 통신에는 기본 ACL에 의존하지 않고 명시적으로 PipeSecurity를 설정한 명명 파이프를 쓰며, 접속원 PID 검증과 helper 쪽에서 고정된 operation만 받아들이는 allowlist를 통해 임의 명령 실행의 통로를 막습니다. PipeOptions.CurrentUserOnly는 무결성 레벨의 차이까지 확인해 버리기 때문에 이 용도에는 쓸 수 없습니다.

관리자 권한 처리 분리 구현의 지식 맵UAC의 무결성 레벨이라는 제약 아래에서 Administrator Broker Model이 왜 표준 사용자 UI와 관리자 helper EXE를 명명 파이프로 연결하고, ACL·PID 검증·operation allowlist로 경계를 지키는지를, 다른 분리 모델과 어떻게 구분해 쓰는지와 함께 보여주는 그림이용한다이용한다전제로 한다전제로 한다이용한다이용한다에서 구성할 수 있다원인이 될 수 있다완화한다완화한다이용한다이용한다방지한다권장되는 대응권장되는 대응권장되는 대응권장되는 대응양립하지 않는다전제로 한다전제로 한다전제로 한다Administrator Broker ModelUAC(사용자 계정 컨트롤)무결성 수준관리자 권한requestedExecutionLevel(실행 수준 선언)runas 실행(ShellExecute Verb)명명된 파이프PipeSecurity를 통한 명시적 ACL파이프에 대한 무단 접근연결 출처 PID 검증helper의 operation allowlist임의 명령 실행 지점산발적인 관리자 작업Operating System Service Model상시·무인·빈번한 관리자 작업권한 상승 작업 모델짧고 정형화된 관리자 작업Administrator COM Object Model기존 COM 전제 통합PipeOptions.CurrentUserOnly

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 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에서는 관리자 권한이 필요한 앱의 분리 방법으로 주로 다음 네 가지를 듭니다.

모델 대략의 형태 맞는 장면
Administrator Broker Model 표준 사용자 UI 앱 + 관리자 helper EXE 관리자 조작이 산발적이고, 필요한 순간에만 UAC를 띄우면 되는 경우
Operating System Service Model 표준 사용자 UI + 상주 service 상시 가동 관리 기능, 백그라운드 감시, 무인 처리
Elevated Task Model 표준 사용자 UI + 관리자 권한 예약 작업 한 번에 짧게 끝나는 정형 처리
Administrator COM Object Model 표준 사용자 UI + 권한 상승 COM 기존 COM 설계가 있고, 기능이 꽤 한정되는 경우

고르는 기준은 다음과 같습니다.

3.1 처음에 검토하기 쉬운 것은 broker EXE

broker EXE가 잘 맞는 것은 이런 조작입니다.

  • Explorer 연동 등록 / 해제
  • HKLM 아래 machine-wide 설정 변경
  • 자기 앱의 service 등록 / 해제
  • 방화벽 규칙 추가 / 삭제
  • Program Files 아래 관리자 조작

이들은 평소에는 필요 없고, 설정 화면의 특정 버튼을 눌렀을 때만 필요한 경우가 많습니다. 이 경우에는 상주 service까지 꺼내기보다 관리자 helper EXE를 한 번만 실행하고 끝내는 형태가 자연스럽습니다.

broker EXE가 맞는 형태관리자 조작이 평소에는 필요 없고 설정 화면의 특정 버튼을 눌렀을 때만 필요한 경우에는 상주 service보다 관리자 helper EXE를 한 번만 실행하고 끝내는 형태가 자연스러움을 보이는 그림.이 빈도라면 불필요설정 화면의 특정 버튼을 누른다helper EXE를 한 번만 실행조작을 마치고 helper는 사라진다상주 service를 꺼낸다

그림5: 산발적인 관리자 조작에는, 필요한 순간에만 살아 있는 helper EXE가 가장 자연스럽습니다.

3.2 service를 고르는 것은 “상시” “무인” “빈번”

service는 표준 사용자 앱에서 RPC 등으로 통신하는 모델입니다. 이점은 권한 상승 프롬프트 없이 관리 측 처리를 받을 수 있다는 것이지만, 그 대신 상주 프로세스를 운용하는 책임이 늘어납니다.

service 모델의 트레이드오프service 모델은 권한 상승 프롬프트 없이 관리 측 처리를 받을 수 있는 이점이 있는 대신, 상주 프로세스를 운용하는 책임이 늘어난다는 트레이드오프를 보이는 그림.이점대가Operating System Service Model권한 상승 프롬프트 없이 받을 수 있다상주 프로세스를 운용하는 책임상시·무인·빈번한 용도에 맞다

그림6: service는 프롬프트 없음과 맞바꿔 상주 운용을 떠안는 선택이 됩니다.

service가 맞는 것은 이런 용도입니다.

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

3.3 task는 “짧은 정형 처리”에 맞다

Elevated Task Model은 표준 사용자 앱에서 관리자 권한으로 실행되는 예약 작업을 시작하는 형태입니다. service보다 가볍고, 끝나면 닫히므로 한 번마다의 정형 작업에 맞습니다.

3.4 권한 상승 COM은 꽤 한정적이다

COM elevation moniker는 편리해 보이지만, 쓸 자리는 좁습니다. Microsoft Learn에서도 권한 상승 COM을 제어할 수 있는 UI는 COM 쪽에서 제시해야 한다고 되어 있어, “상승하지 않은 UI에서 권한 상승 COM을 마음대로 다루게 한다” 방향에는 맞지 않습니다.

권한 상승 COM을 쓸 자리의 좁음권한 상승 COM은 편리해 보이지만, 그것을 제어할 수 있는 UI는 COM 쪽에서 제시해야 한다고 되어 있어 상승하지 않은 UI에서 권한 상승 COM을 마음대로 다루게 하는 방향에는 맞지 않음을 보이는 그림.이 방향에는 맞지 않는다권한 상승 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)named pipe 수신 쪽접속을 허용하는 것은 UI 사용자 SID만클라이언트 PID도 대조한다operation allowlist로 분기인자를 helper 쪽에서 한 번 더 검증MyApp.exe(asInvoker)이용자 조작을 받아 요청을 조립할 뿐관리자 권한이 필요한 고정 대상HKLM 아래 키 / service 등록 / 방화벽 규칙

그림8: 권한 상승 경계를 프로세스 경계에 맞춘다. UI 쪽은 medium 그대로, helper만 high로 짧게 실행됩니다

포인트는 세 가지입니다.

  1. UI 프로세스는 끝까지 상승하지 않은 그대로
  2. 관리자 helper는 수명이 짧다
  3. helper가 받는 조작은 고정 allowlist만

이 세 가지를 지키는 것만으로 설계가 꽤 정리됩니다.

지켜야 할 세 가지 포인트UI 프로세스는 끝까지 상승하지 않은 그대로, 관리자 helper는 수명이 짧다, helper가 받는 조작은 고정 allowlist만이라는 세 가지를 지키는 것만으로 설계가 꽤 정리됨을 보이는 그림.UI는 끝까지 상승하지 않은설계가 꽤 정리된다helper는 수명이 짧다받는 조작은 allowlist만

그림9: 상승하지 않음·짧은 수명·allowlist 세 점을 지키는 것만으로 권한 경계의 형태가 정해집니다.

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 / 숫자 / 한정된 문자열로 모읍니다.

만능 해결사로 만들지 않기 위한 형태날것 명령 문자열이나 임의 경로를 helper에 넘기면 무엇이든 실행할 수 있는 입구가 생기고 UI가 무너지면 helper도 같이 무너지므로, 조작 자체를 고정하고 인자도 한정된 형으로 모은다는 것을 보이는 그림.날것 명령 문자열을 넘긴다무엇이든 실행할 수 있는 입구가 생긴다UI가 무너지면 helper도 무너진다조작을 고정하고 인자를 한정된 형으로helper의 의미가 좁아진다

그림10: 권한 상승 경계 안쪽에 넘기는 것은 고정한 조작과 한정된 인자만으로 합니다.

5.2 helper에 넘기는 path는 absolute, 게다가 UI에서 너무 정하지 않는다

runas로 실행하는 helper EXE 자체는 절대 경로로 지정합니다. PATH 검색이나 상대 경로에 맡기지 않습니다.

나아가 helper가 실행하는 대상도 되도록 helper 쪽에서 경로를 고정해 결정합니다. 이번 샘플에서는 Explorer 컨텍스트 메뉴에 등록할 대상 EXE를 helper와 같은 폴더에 있는 MyApp.exe로 고정합니다.

5.3 Verb=\"runas\"를 쓸 거면 UseShellExecute=true를 명시한다

.NET에서 ProcessStartInfo.VerbUseShellExecute=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 named pipe는 기본 ACL에 의존하지 않는다

named pipe는 기본 보안 기술자라면 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에만 pipe 접속 권한을 준다
  • 나아가 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 검증은 “헐거운 끼어들기”를 줄이기 위한 추가 방어

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

물론 PID가 맞다고 무엇이든 믿어도 되는 것은 아닙니다. UI가 침해되어 있으면 helper에도 위험한 요청이 도착합니다. 그래서 helper 쪽의 operation allowlist와 인자 검증이 필요합니다.

층을 겹치는 추가 방어무작위 파이프 이름, SID 한정 ACL, 클라이언트 PID 대조, operation allowlist와 인자 검증이라는 층을 겹쳐 헐거운 끼어들기와 위험한 요청 양쪽을 줄인다는 생각을 보이는 그림.무작위 pipe 이름SID 한정 ACL클라이언트 PID 대조allowlist와 인자 재검증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;
    }
}

포인트는 pipe에 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에 넘기는 것은 pipe 이름과 클라이언트 확인에 필요한 최소 정보뿐입니다. 관리자 조작 자체는 pipe 안에서 보내는 타입이 있는 request에 가둡니다.

실행 인자와 pipe의 역할 분담helper 실행 인자에는 pipe 이름과 클라이언트 확인에 필요한 최소 정보만 넘기고, 관리자 조작 자체는 pipe 안에서 보내는 타입이 있는 request에 가둔다는 역할 분담을 보이는 그림.넘기는 것은가두는 것은실행 인자pipe 이름·PID·SID의 최소 정보pipe 안타입이 있는 request의 관리자 조작조작 내용은 인자에 싣지 않는다

그림16: 인자는 접속 준비에만 쓰고, 조작 내용은 타입이 있는 request로 pipe에 가둡니다.QuoteArgument는 이 샘플에서 넘기는 pipe 이름, PID, SID 같은 단순한 값을 전제로 한 최소 구현입니다. 임의의 Windows 경로나 자유 입력 문자열을 명령줄 인자로 넘기는 경우에는 Windows argv 해석 규칙에 맞는 전용 이스케이프 처리로 바꿉니다.

11. helper 쪽: 실행 인자 해석

11.1 MyApp.AdminBroker/BrokerLaunchOptions.cs

namespace MyApp.AdminBroker;

internal sealed class BrokerLaunchOptions
{
    public required string PipeName { get; init; }
    public required int ExpectedClientProcessId { get; init; }
    public required string ClientUserSid { get; init; }

    public static BrokerLaunchOptions Parse(string[] args)
    {
        string? pipeName = null;
        int? clientPid = null;
        string? clientSid = null;

        for (int i = 0; i < args.Length; i++)
        {
            switch (args[i])
            {
                case "--pipe":
                    pipeName = ReadNextValue(args, ref i, "--pipe");
                    break;
                case "--client-pid":
                    string pidText = ReadNextValue(args, ref i, "--client-pid");
                    if (!int.TryParse(pidText, out int pid) || pid <= 0)
                    {
                        throw new ArgumentException($"Invalid client PID: {pidText}");
                    }

                    clientPid = pid;
                    break;
                case "--client-sid":
                    clientSid = ReadNextValue(args, ref i, "--client-sid");
                    break;
                default:
                    throw new ArgumentException($"Unknown argument: {args[i]}");
            }
        }

        if (string.IsNullOrWhiteSpace(pipeName))
        {
            throw new ArgumentException("--pipe is required.");
        }

        if (clientPid is null)
        {
            throw new ArgumentException("--client-pid is required.");
        }

        if (string.IsNullOrWhiteSpace(clientSid))
        {
            throw new ArgumentException("--client-sid is required.");
        }

        return new BrokerLaunchOptions
        {
            PipeName = pipeName,
            ExpectedClientProcessId = clientPid.Value,
            ClientUserSid = clientSid
        };
    }

    private static string ReadNextValue(string[] args, ref int index, string optionName)
    {
        if (index + 1 >= args.Length)
        {
            throw new ArgumentException($"A value is required after {optionName}.");
        }

        index++;
        return args[index];
    }
}

helper 쪽은 인자가 부족하거나 / 여분 인자가 있는 시점에서 오류로 합니다. 권한 상승 경계 안쪽에서 “일단 어떻게든 해석해 본다”는 하지 않는 편이 좋습니다.

12. helper 쪽: pipe 생성·클라이언트 PID 검증·dispatch

12.1 MyApp.AdminBroker/Program.cs

using System.ComponentModel;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
using MyApp.BrokerProtocol;

namespace MyApp.AdminBroker;

internal static class Program
{
    public static async Task<int> Main(string[] args)
    {
        BrokerLaunchOptions options = BrokerLaunchOptions.Parse(args);

        using var brokerCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
        using NamedPipeServerStream pipe = CreatePipeServer(options);

        await pipe.WaitForConnectionAsync(brokerCts.Token);

        VerifyClientProcessId(pipe, options.ExpectedClientProcessId);

        BrokerRequest request = await PipeMessageSerializer.ReadAsync<BrokerRequest>(pipe, brokerCts.Token);
        BrokerResponse response = await DispatchAsync(request);

        await PipeMessageSerializer.WriteAsync(pipe, response, brokerCts.Token);

        return response.Success ? 0 : 2;
    }

    private static Task<BrokerResponse> DispatchAsync(BrokerRequest request)
    {
        try
        {
            return request.Operation switch
            {
                BrokerOperations.SetExplorerContextMenu => HandleSetExplorerContextMenuAsync(request.Payload),
                _ => Task.FromResult(
                    BrokerResponse.Fail(
                        "unsupported_operation",
                        $"Unsupported operation: {request.Operation}"))
            };
        }
        catch (JsonException ex)
        {
            return Task.FromResult(BrokerResponse.Fail("invalid_payload", ex.Message));
        }
        catch (Exception ex)
        {
            return Task.FromResult(BrokerResponse.Fail("broker_failure", ex.Message));
        }
    }

    private static NamedPipeServerStream CreatePipeServer(BrokerLaunchOptions options)
    {
        var pipeSecurity = new PipeSecurity();
        var clientSid = new SecurityIdentifier(options.ClientUserSid);
        SecurityIdentifier helperSid = WindowsIdentity.GetCurrent().User
            ?? throw new InvalidOperationException("Helper user SID could not be resolved.");

        pipeSecurity.AddAccessRule(new PipeAccessRule(
            clientSid,
            PipeAccessRights.ReadWrite,
            AccessControlType.Allow));

        pipeSecurity.AddAccessRule(new PipeAccessRule(
            helperSid,
            PipeAccessRights.FullControl,
            AccessControlType.Allow));

        pipeSecurity.AddAccessRule(new PipeAccessRule(
            new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
            PipeAccessRights.FullControl,
            AccessControlType.Allow));

        return NamedPipeServerStreamAcl.Create(
            options.PipeName,
            PipeDirection.InOut,
            maxNumberOfServerInstances: 1,
            transmissionMode: PipeTransmissionMode.Byte,
            options: PipeOptions.Asynchronous | PipeOptions.WriteThrough,
            inBufferSize: 0,
            outBufferSize: 0,
            pipeSecurity: pipeSecurity);
    }

    private static void VerifyClientProcessId(NamedPipeServerStream pipe, int expectedClientProcessId)
    {
        if (!GetNamedPipeClientProcessId(
                pipe.SafePipeHandle.DangerousGetHandle(),
                out uint actualClientProcessId))
        {
            throw new Win32Exception(Marshal.GetLastWin32Error());
        }

        if (actualClientProcessId != (uint)expectedClientProcessId)
        {
            throw new InvalidOperationException(
                $"Unexpected pipe client PID. Expected={expectedClientProcessId}, Actual={actualClientProcessId}");
        }
    }

    private static Task<BrokerResponse> HandleSetExplorerContextMenuAsync(JsonElement payload)
    {
        SetExplorerContextMenuRequest request = payload.Deserialize<SetExplorerContextMenuRequest>(BrokerJson.Options)
            ?? throw new JsonException("Payload could not be parsed.");

        ExplorerContextMenuRegistration.Apply(request.Enabled);
        return Task.FromResult(BrokerResponse.Ok("Explorer context menu setting was updated."));
    }

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool GetNamedPipeClientProcessId(
        IntPtr pipe,
        out uint clientProcessId);
}

여기서 효과가 있는 것은 다음입니다.

  • pipe ACL을 명시적으로 조립한다
  • ACL은 helper의 현재 사용자 SID가 아니라, 호출 측 UI 사용자 SID에도 부여한다
  • 접속 후에 client PID를 검증한다
  • request를 받은 뒤에도 operation 이름으로 dispatch한다

switch (request.Operation)으로 고정 조작만 통과시키는 형태로 두면 helper가 “권한 상승한 만능 상자”가 되기 어렵습니다.

helper 쪽에서 효과가 있는 검증의 순서helper는 명시적 ACL로 파이프를 만들고, 접속 후 클라이언트 PID를 검증하며, request는 operation 이름으로 dispatch해 고정 조작만 통과시킨다는, helper 쪽에서 효과가 있는 검증의 순서를 보이는 그림.allowlist 안allowlist 밖명시적 ACL로 파이프를 만든다클라이언트 PID를 검증operation 이름으로 dispatch고정 조작만 실행거부하고 응답

그림17: ACL·PID·dispatch 세 단을 통과한 request만 고정 조작에 도달합니다.

13. 관리자 조작의 본체: Explorer 오른쪽 클릭 메뉴 등록

13.1 MyApp.AdminBroker/ExplorerContextMenuRegistration.cs

using System;
using System.IO;
using Microsoft.Win32;

namespace MyApp.AdminBroker;

internal static class ExplorerContextMenuRegistration
{
    private const string MenuKeyPath = @"SOFTWARE\Classes\*\shell\MyApp.Open";
    private const string CommandKeyPath = @"SOFTWARE\Classes\*\shell\MyApp.Open\command";
    private const string MenuText = "Open with MyApp";
    private const string ClientExecutableName = "MyApp.exe";

    public static void Apply(bool enabled)
    {
        string clientExePath = ResolveClientExecutablePath();

        using RegistryKey hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, GetRegistryView());

        if (enabled)
        {
            using RegistryKey menuKey = hklm.CreateSubKey(MenuKeyPath)
                ?? throw new InvalidOperationException($"Failed to create registry key: {MenuKeyPath}");

            menuKey.SetValue(null, MenuText, RegistryValueKind.String);
            menuKey.SetValue("Icon", $"\"{clientExePath}\",0", RegistryValueKind.String);

            using RegistryKey commandKey = hklm.CreateSubKey(CommandKeyPath)
                ?? throw new InvalidOperationException($"Failed to create registry key: {CommandKeyPath}");

            commandKey.SetValue(null, $"\"{clientExePath}\" \"%1\"", RegistryValueKind.String);
        }
        else
        {
            hklm.DeleteSubKeyTree(@"SOFTWARE\Classes\*\shell\MyApp.Open", throwOnMissingSubKey: false);
        }
    }

    private static string ResolveClientExecutablePath()
    {
        string clientExePath = Path.GetFullPath(
            Path.Combine(AppContext.BaseDirectory, ClientExecutableName));

        if (!File.Exists(clientExePath))
        {
            throw new FileNotFoundException("Client executable was not found.", clientExePath);
        }

        return clientExePath;
    }

    private static RegistryView GetRegistryView()
    {
        return Environment.Is64BitOperatingSystem
            ? RegistryView.Registry64
            : RegistryView.Registry32;
    }
}

이 코드의 핵심은 UI에서 받지 않은 것에 있습니다.

  • UI에서 임의의 레지스트리 경로를 받지 않았다
  • UI에서 임의의 명령 문자열을 받지 않았다
  • 등록 대상 EXE는 helper 쪽에서 경로를 고정해 결정한다
  • request 내용은 Enabled

즉 helper는 “Explorer 오른쪽 클릭 메뉴 등록 상태를 전환한다”는 하나의 의미만 갖도록 되어 있습니다.

14. UI에서의 호출 예

14.1 MyApp/SettingsPage.xaml.cs

using System.Windows;

namespace MyApp;

public partial class SettingsPage
{
    private readonly ElevationBrokerClient _broker = new(
        Path.Combine(AppContext.BaseDirectory, "MyApp.AdminBroker.exe"));

    private async void ExplorerMenuCheckBox_Click(object sender, RoutedEventArgs e)
    {
        bool enabled = ExplorerMenuCheckBox.IsChecked == true;

        try
        {
            await _broker.SetExplorerContextMenuEnabledAsync(enabled);
            MessageBox.Show("Setting has been updated.", "MyApp");
        }
        catch (OperationCanceledException)
        {
            MessageBox.Show("The administrator approval prompt was canceled.", "MyApp");
            ExplorerMenuCheckBox.IsChecked = !enabled;
        }
        catch (Exception ex)
        {
            MessageBox.Show(ex.Message, "Failed to update the setting.");
            ExplorerMenuCheckBox.IsChecked = !enabled;
        }
    }
}

UI 쪽은 평범합니다.

  • 체크박스 상태를 읽는다
  • broker client를 호출한다
  • 실패하면 UI를 되돌린다

뿐입니다. 레지스트리를 직접 건드리지 않습니다. 그것이 분리입니다.

15. 이 구현에서 잡고 있는 것

이 샘플에서 실제로 지키는 선은 이렇게 되어 있습니다.

15.1 UI와 helper의 책임 분리

  • UI는 이용자 조작을 받을 뿐
  • helper는 고정된 관리자 조작만 실행한다

15.2 helper에 “임의 실행 입구”를 만들지 않았다

  • 임의 레지스트리 경로를 받지 않았다
  • 임의 명령줄을 받지 않았다
  • 임의 EXE 경로를 받지 않았다

15.3 실행 경로가 고정

  • helper EXE는 absolute path
  • runas를 명시
  • UseShellExecute = true를 명시

15.4 IPC 연결 주체를 좁힌다

  • pipe ACL을 UI 사용자 SID로 한정
  • 접속 후 client PID를 확인

15.5 관리자 조작의 대상도 고정

  • 레지스트리 hive / path가 고정
  • 등록 대상 EXE도 경로를 고정해 결정

이 정도까지 하면 “UI가 무너지면 helper로 무엇이든 할 수 있는” 상태에서는 꽤 멀어집니다.

15.6 실제로 분리되어 있는지 확인한다

여기까지는 설계 이야기입니다. 쓴 것이 정말 분리되어 있는지는 돌려 보지 않으면 모릅니다. “UI 전체가 어느새 권한 상승해 있었다”는 코드를 읽어도 알아채기 어려운 사고입니다.

분리되어 있는지의 4단계 확인UI 프로세스가 상승하지 않은 그대로인지, helper 실행 시에만 UAC 프롬프트가 나오는지, 관리자 조작이 실제로 먹혔는지, 실패해야 할 때 실패하는지를 차례로 확인하는 흐름을 보이는 그림.UI가 상승하지 않은 그대로인가helper 실행 시에만 프롬프트가 나오는가조작이 실제로 먹혔는가실패해야 할 때 실패하는가네 번째를 보지 않으면 분리 확인이 되지 않는다

그림18: 돌려 보고 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 named pipe의 기본 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와 관리자 처리의 경계가 그대로 설계 자산이 됩니다.

경계가 설계 자산이 된다operation 계약을 제대로 나눠 두면 UI와 관리자 처리의 경계가 그대로 설계 자산이 되고, 나중에 service화하고 싶어졌을 때도 옮기기 쉬움을 보이는 그림.operation 계약을 나눠 둔다UI와 관리자 처리의 경계가 명확경계가 그대로 설계 자산이 된다service화로의 이전도 쉽다

그림19: broker형으로 자른 경계는 나중에 service화할 때도 그대로 쓸 수 있는 자산이 됩니다.

보안 이야기는 화려한 기능을 더하는 것보다 헐거운 경계를 남기지 않는 것이 더 먹힙니다. 관리자 권한도 같습니다. 전부 한꺼번에 쥐여 주는 것이 아니라, 필요한 곳에만, 되도록 좁게 넘긴다. 그 정도의 수수한 태도가 나중에 먹힙니다.

18. 참고 자료

참고로 아래 링크 일부는 URL에 view=net-10.0 같은 버전 지정이 들어 있습니다. 이것은 Microsoft Learn 쪽에서 어느 .NET 버전 문서를 표시할지의 지정이며, 이 글의 전제인 .NET 8과 어긋난다는 뜻은 아닙니다. 여기서 쓰는 PipeOptions / NamedPipeServerStreamAcl / RegistryView는 모두 .NET 8에서 쓸 수 있습니다. 표시를 .NET 8에 맞추고 싶다면 페이지 위쪽 버전 선택기에서 바꾸면 됩니다.

  • 이 글의 샘플 코드 일체(공통 계약 라이브러리, 데모, 단위 테스트) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/windows-admin-broker-deep-dive
  • 원글: Windows 앱 개발에서 최소한의 보안을 지키기 위한 체크리스트 https://comcomponent.com/blog/2026/03/14/001-windows-app-security-minimum-checklist/
  • Administrator Broker Model - Win32 apps https://learn.microsoft.com/ja-jp/windows/win32/secauthz/administrator-broker-model
  • Developing Applications that Require Administrator Privilege https://learn.microsoft.com/en-us/windows/win32/secauthz/developing-applications-that-require-administrator-privilege
  • Operating System Service Model - Win32 apps https://learn.microsoft.com/ja-jp/windows/win32/secauthz/operating-system-service-model
  • Elevated Task Model - Win32 apps https://learn.microsoft.com/en-us/windows/win32/secauthz/elevated-task-model
  • Administrator COM Object Model - Win32 apps https://learn.microsoft.com/ja-jp/windows/win32/secauthz/administrator-com-object-model
  • The COM Elevation Moniker https://learn.microsoft.com/en-us/windows/win32/com/the-com-elevation-moniker
  • How User Account Control works https://learn.microsoft.com/en-us/windows/security/application-security/application-control/user-account-control/how-it-works
  • Mandatory Integrity Control - Win32 apps https://learn.microsoft.com/en-us/windows/win32/secauthz/mandatory-integrity-control
  • Process Explorer - Sysinternals https://learn.microsoft.com/en-us/sysinternals/downloads/process-explorer
  • WindowsPrincipal.IsInRole Method https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.windowsprincipal.isinrole
  • ProcessStartInfo.UseShellExecute https://learn.microsoft.com/ja-jp/dotnet/fundamentals/runtime-libraries/system-diagnostics-processstartinfo-useshellexecute
  • Named Pipe Security and Access Rights https://learn.microsoft.com/ja-jp/windows/win32/ipc/named-pipe-security-and-access-rights
  • PipeOptions Enum https://learn.microsoft.com/en-us/dotnet/api/system.io.pipes.pipeoptions?view=net-10.0
  • NamedPipeServerStreamAcl.Create https://learn.microsoft.com/en-us/dotnet/api/system.io.pipes.namedpipeserverstreamacl.create?view=net-10.0
  • GetNamedPipeClientProcessId https://learn.microsoft.com/ja-jp/windows/win32/api/winbase/nf-winbase-getnamedpipeclientprocessid
  • RegistryView Enum https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.win32.registryview?view=net-8.0

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

Windows 앱 개발

UAC, helper EXE, 서비스로 전환할지 판단, machine-wide 설정 변경까지 포함해 Windows 앱 전체의 권한 설계와 맞물리므로 Windows 앱 개발과 잘 맞는 주제입니다.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

같은 프로세스 안의 일부 처리만 관리자 권한으로 실행할 수 있나요?
할 수 없습니다. 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와의 주고받기는 named pipe 같은 IPC가 자연스럽습니다. 파이프는 기본 ACL에 의존하지 말고 명시적인 PipeSecurity를 설정해 호출 측 사용자 SID에 접속 권한을 한정한 뒤, GetNamedPipeClientProcessId로 클라이언트 PID도 검증합니다.
named pipe에 PipeOptions.CurrentUserOnly를 쓰면 안전하지 않나요?
상승하지 않은 UI와 권한 상승 helper의 통신에는 맞지 않습니다. Windows의 CurrentUserOnly는 사용자 계정뿐 아니라 권한 상승 수준까지 확인하므로, 무결성 수준이 다른 프로세스 사이에서는 접속하지 못합니다. 게다가 표준 사용자 환경에서는 UAC가 credential prompt가 되어 helper가 다른 관리자 계정으로 실행되기도 합니다. UI 쪽에서 자신의 SID를 구해 helper에 넘기고, helper 쪽에서 그 SID에만 파이프 접속 권한을 주는 명시적 ACL이 다루기 쉽습니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

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

블로그 목록으로 돌아가기