수정 이력(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 앱에서는 같은 프로세스 안의 일부 처리만 편리하게 “관리자로 실행”할 수는 없습니다. 권한 상승은 프로세스 경계의 이야기이므로, 필요한 것은 “그 처리만 다른 실행 단위로 잘라내는 설계”입니다.
flowchart TB
accTitle: 권한 상승은 프로세스 경계의 이야기
accDescr: 같은 프로세스 안의 일부 처리만 관리자로 실행할 수는 없고, 권한 상승은 프로세스 경계의 이야기이므로 그 처리만 다른 실행 단위로 잘라내는 설계가 필요함을 보이는 그림.
wish1["일부 처리만 관리자로 하고 싶다"] -.->|"동일 프로세스 안에서는 불가능"| in1["같은 프로세스 안에서 권한 상승"]
wish1 -->|"가능한 쪽은 이쪽"| cut1["처리를 다른 실행 단위로 잘라낸다"]
그림1: 권한 상승은 함수 단위가 아니라 프로세스 단위이므로, 잘라내는 설계만 성립합니다.
이 글에서는 다음 순서로 진행합니다.
- 먼저 전제
- 어떤 분리 모델을 고를지
- 실무에서 가장 쓰기 쉬운
asInvoker+ 관리자 helper EXE 형태 - 구현할 때 빠뜨리고 싶지 않은 함정
- 구체적인 코드 예
코드 예는 .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 로드, 설정 저장 위치 쪽에서 대개 싫은 얼굴을 봅니다.
flowchart TB
accTitle: 실무에서 맞출 지점의 골격
accDescr: UI는 asInvoker 그대로 두고, 관리자 권한이 필요한 처리는 requireAdministrator의 별도 EXE로 잘라내 runas로 실행하며, named pipe로 타입이 있는 요청만 주고받는다는 골격을 보이는 그림.
uix1["UI는 asInvoker 그대로"] -->|"runas로 실행"| hx1["helper EXE(requireAdministrator)"]
uix1 -->|"named pipe로 타입이 있는 요청"| hx1
hx1 --> vfy1["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는 무결성 레벨의 차이까지 확인해 버리기 때문에 이 용도에는 쓸 수 없습니다.
flowchart LR
accTitle: 관리자 권한 처리 분리 구현의 지식 맵
accDescr: UAC의 무결성 레벨이라는 제약 아래에서 Administrator Broker Model이 왜 표준 사용자 UI와 관리자 helper EXE를 명명 파이프로 연결하고, ACL·PID 검증·operation allowlist로 경계를 지키는지를, 다른 분리 모델과 어떻게 구분해 쓰는지와 함께 보여주는 그림
administrator_broker_model["Administrator Broker Model"]
uac["UAC(사용자 계정 컨트롤)"]
integrity_level["무결성 수준"]
admin_rights["관리자 권한"]
requested_execution_level["requestedExecutionLevel(실행 수준 선언)"]
runas_verb["runas 실행(ShellExecute Verb)"]
named_pipe["명명된 파이프"]
pipe_security_acl["PipeSecurity를 통한 명시적 ACL"]
unauthorized_pipe_access["파이프에 대한 무단 접근"]
client_pid_verification["연결 출처 PID 검증"]
helper_operation_allowlist["helper의 operation allowlist"]
arbitrary_command_execution["임의 명령 실행 지점"]
sporadic_admin_operation["산발적인 관리자 작업"]
os_service_model["Operating System Service Model"]
continuous_unattended_admin_operation["상시·무인·빈번한 관리자 작업"]
elevated_task_model["권한 상승 작업 모델"]
short_scheduled_admin_task["짧고 정형화된 관리자 작업"]
admin_com_object_model["Administrator COM Object Model"]
existing_com_integration["기존 COM 전제 통합"]
currentuseronly_pipeoption["PipeOptions.CurrentUserOnly"]
administrator_broker_model -->|"이용한다"| uac
uac -->|"이용한다"| integrity_level
uac -.->|"전제로 한다"| admin_rights
administrator_broker_model -->|"전제로 한다"| requested_execution_level
runas_verb -->|"이용한다"| uac
administrator_broker_model -.->|"이용한다"| named_pipe
named_pipe -->|"에서 구성할 수 있다"| pipe_security_acl
named_pipe -.->|"원인이 될 수 있다"| unauthorized_pipe_access
pipe_security_acl -->|"완화한다"| unauthorized_pipe_access
client_pid_verification -->|"완화한다"| unauthorized_pipe_access
administrator_broker_model -.->|"이용한다"| client_pid_verification
administrator_broker_model -->|"이용한다"| helper_operation_allowlist
helper_operation_allowlist -->|"방지한다"| arbitrary_command_execution
administrator_broker_model -->|"권장되는 대응"| sporadic_admin_operation
os_service_model -->|"권장되는 대응"| continuous_unattended_admin_operation
elevated_task_model -->|"권장되는 대응"| short_scheduled_admin_task
admin_com_object_model -->|"권장되는 대응"| existing_com_integration
currentuseronly_pipeoption -->|"양립하지 않는다"| administrator_broker_model
os_service_model -->|"전제로 한다"| admin_rights
elevated_task_model -->|"전제로 한다"| admin_rights
admin_com_object_model -->|"전제로 한다"| admin_rights
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 21건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 전제 정리: 같은 프로세스의 일부만 관리자로 만들 수는 없다
Windows의 UAC는 “함수 단위 권한 상승”이 아니라 “프로세스가 어떤 토큰 / 무결성 수준으로 실행되는지”로 제어됩니다. 관리자 액세스 토큰이 필요한 앱은 권한 상승 프롬프트의 대상이 되고, 부모-자식 프로세스는 같은 무결성 수준으로 토큰을 상속합니다. 즉, 상승하지 않은 UI 프로세스 안에서 어떤 메서드만 갑자기 관리자 권한으로 실행한다는 설계는 할 수 없습니다. 필요하면 별도 프로세스·서비스·작업·권한 상승 COM처럼 다른 실행 단위를 씁니다.
이 전제를 빼고 생각하면 “이 버튼을 누른 순간만 관리자로 하고 싶다”는, 조금 딱한 설계 상담이 됩니다. Windows는 그 자리를 마법으로 메워 주지 않습니다.
flowchart TB
accTitle: UAC는 토큰과 무결성 수준으로 제어된다
accDescr: UAC는 함수 단위 권한 상승이 아니라 프로세스가 어떤 토큰·무결성 수준으로 실행되는지로 제어되고, 부모-자식 프로세스는 같은 무결성 수준으로 토큰을 상속하므로 메서드 단위 권한 상승은 할 수 없고 다른 실행 단위가 필요함을 보이는 그림.
uac1["UAC의 제어 단위"] --> tk1["프로세스의 토큰과 무결성 수준"]
tk1 --> inh1["부모-자식 프로세스는 같은 수준을 상속"]
inh1 --> no1["메서드 단위 권한 상승은 할 수 없다"]
no1 --> alt2["별도 프로세스·서비스·작업·권한 상승 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도 자연스럽게 읽힙니다.
flowchart TB
accTitle: medium과 high의 선 긋기
accDescr: 표준 사용자로 실행되는 asInvoker UI는 medium, 권한 상승한 requireAdministrator helper는 high 무결성 수준을 받고, 이 글의 설계는 그 둘에 명시적으로 선을 긋고 대화시킨다는 이야기임을 보이는 그림.
med1["medium: asInvoker UI 프로세스"] -->|"선을 긋고 대화시킨다"| hg1["high: 권한 상승한 helper 프로세스"]
med1 -.-> sd2["표준 사용자의 토큰"]
hg1 -.-> ad2["권한 상승한 사용자의 토큰"]
그림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를 한 번만 실행하고 끝내는 형태가 자연스럽습니다.
flowchart TB
accTitle: broker EXE가 맞는 형태
accDescr: 관리자 조작이 평소에는 필요 없고 설정 화면의 특정 버튼을 눌렀을 때만 필요한 경우에는 상주 service보다 관리자 helper EXE를 한 번만 실행하고 끝내는 형태가 자연스러움을 보이는 그림.
btn1["설정 화면의 특정 버튼을 누른다"] --> once2["helper EXE를 한 번만 실행"]
once2 --> done1["조작을 마치고 helper는 사라진다"]
btn1 -.->|"이 빈도라면 불필요"| svc2["상주 service를 꺼낸다"]
그림5: 산발적인 관리자 조작에는, 필요한 순간에만 살아 있는 helper EXE가 가장 자연스럽습니다.
3.2 service를 고르는 것은 “상시” “무인” “빈번”
service는 표준 사용자 앱에서 RPC 등으로 통신하는 모델입니다. 이점은 권한 상승 프롬프트 없이 관리 측 처리를 받을 수 있다는 것이지만, 그 대신 상주 프로세스를 운용하는 책임이 늘어납니다.
flowchart TB
accTitle: service 모델의 트레이드오프
accDescr: service 모델은 권한 상승 프롬프트 없이 관리 측 처리를 받을 수 있는 이점이 있는 대신, 상주 프로세스를 운용하는 책임이 늘어난다는 트레이드오프를 보이는 그림.
svm1["Operating System Service Model"] -->|"이점"| npr1["권한 상승 프롬프트 없이 받을 수 있다"]
svm1 -->|"대가"| res2["상주 프로세스를 운용하는 책임"]
svm1 -.-> fitx["상시·무인·빈번한 용도에 맞다"]
그림6: service는 프롬프트 없음과 맞바꿔 상주 운용을 떠안는 선택이 됩니다.
service가 맞는 것은 이런 용도입니다.
- 상시 감시
- 로그 수집
- 백그라운드 업데이트
- 장치나 데몬과의 상시 연동
- 여러 UI 세션에서 공유되는 관리 기능
3.3 task는 “짧은 정형 처리”에 맞다
Elevated Task Model은 표준 사용자 앱에서 관리자 권한으로 실행되는 예약 작업을 시작하는 형태입니다. service보다 가볍고, 끝나면 닫히므로 한 번마다의 정형 작업에 맞습니다.
3.4 권한 상승 COM은 꽤 한정적이다
COM elevation moniker는 편리해 보이지만, 쓸 자리는 좁습니다. Microsoft Learn에서도 권한 상승 COM을 제어할 수 있는 UI는 COM 쪽에서 제시해야 한다고 되어 있어, “상승하지 않은 UI에서 권한 상승 COM을 마음대로 다루게 한다” 방향에는 맞지 않습니다.
flowchart TB
accTitle: 권한 상승 COM을 쓸 자리의 좁음
accDescr: 권한 상승 COM은 편리해 보이지만, 그것을 제어할 수 있는 UI는 COM 쪽에서 제시해야 한다고 되어 있어 상승하지 않은 UI에서 권한 상승 COM을 마음대로 다루게 하는 방향에는 맞지 않음을 보이는 그림.
look1["권한 상승 COM은 편리해 보인다"] -.-> free1["상승하지 않은 UI에서 마음대로 조작"]
free1 -.->|"이 방향에는 맞지 않는다"| ngc1["쓸 자리가 좁아진다"]
ui2["제어할 수 있는 UI는 COM 쪽에서 제시"] --> ngc1
그림7: 권한 상승 COM은 제어 UI를 COM 쪽이 가진다는 전제이며, 범용 우회로가 되지 않습니다.
4. 이번 추천: asInvoker UI + requireAdministrator helper EXE
여기서부터는 실무에서 가장 쓰기 쉬운 형태를 구체화합니다.
flowchart TB
subgraph MED["medium integrity ── 끝까지 권한 상승하지 않는다"]
UI["MyApp.exe(asInvoker)<br/>이용자 조작을 받아 요청을 조립할 뿐"]
end
subgraph HIGH["high integrity ── 수명이 짧은 권한 상승 프로세스"]
BR["MyApp.AdminBroker.exe<br/>(requireAdministrator)"]
PIPE["named pipe 수신 쪽<br/>접속을 허용하는 것은 UI 사용자 SID만<br/>클라이언트 PID도 대조한다"]
DISP["operation allowlist로 분기<br/>인자를 helper 쪽에서 한 번 더 검증"]
end
TGT["관리자 권한이 필요한 고정 대상<br/>HKLM 아래 키 / service 등록 / 방화벽 규칙"]
UI -->|"절대 경로 + Verb=runas로 실행<br/>(여기서 UAC 프롬프트가 나온다)"| BR
BR --> PIPE
UI -->|"타입이 있는 요청<br/>(날것 명령 문자열은 넘기지 않는다)"| PIPE
PIPE --> DISP
DISP --> TGT
그림8: 권한 상승 경계를 프로세스 경계에 맞춘다. UI 쪽은 medium 그대로, helper만 high로 짧게 실행됩니다
포인트는 세 가지입니다.
- UI 프로세스는 끝까지 상승하지 않은 그대로
- 관리자 helper는 수명이 짧다
- helper가 받는 조작은 고정 allowlist만
이 세 가지를 지키는 것만으로 설계가 꽤 정리됩니다.
flowchart TB
accTitle: 지켜야 할 세 가지 포인트
accDescr: UI 프로세스는 끝까지 상승하지 않은 그대로, 관리자 helper는 수명이 짧다, helper가 받는 조작은 고정 allowlist만이라는 세 가지를 지키는 것만으로 설계가 꽤 정리됨을 보이는 그림.
p1["UI는 끝까지 상승하지 않은"] --> tidy1["설계가 꽤 정리된다"]
p2["helper는 수명이 짧다"] --> tidy1
p3["받는 조작은 allowlist만"] --> tidy1
그림9: 상승하지 않음·짧은 수명·allowlist 세 점을 지키는 것만으로 권한 경계의 형태가 정해집니다.
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 / 숫자 / 한정된 문자열로 모읍니다.
flowchart TB
accTitle: 만능 해결사로 만들지 않기 위한 형태
accDescr: 날것 명령 문자열이나 임의 경로를 helper에 넘기면 무엇이든 실행할 수 있는 입구가 생기고 UI가 무너지면 helper도 같이 무너지므로, 조작 자체를 고정하고 인자도 한정된 형으로 모은다는 것을 보이는 그림.
raw1["날것 명령 문자열을 넘긴다"] --> hole1["무엇이든 실행할 수 있는 입구가 생긴다"]
hole1 --> both1["UI가 무너지면 helper도 무너진다"]
fix2["조작을 고정하고 인자를 한정된 형으로"] --> narrow1["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.Verb는 UseShellExecute=true일 때만 유효합니다.
게다가 UseShellExecute의 기본값은 .NET Framework와 .NET Core / .NET에서 다릅니다.
여기를 기본값에 맡기면 나중에 “되는 환경과 안 되는 환경이 있다”는, 은근히 짜증 나는 사고가 납니다.
그래서 여기는 반드시 명시합니다.
flowchart TB
accTitle: runas 실행에서 명시해야 할 설정
accDescr: ProcessStartInfo의 Verb는 UseShellExecute가 true일 때만 유효하고 그 기본값은 .NET Framework와 .NET에서 다르므로, 기본값에 맡기면 환경에 따라 되거나 안 되거나 하는 사고가 나며 반드시 명시해야 함을 보이는 그림.
vb1["Verb=runas를 쓰고 싶다"] --> req2["UseShellExecute=true가 필요"]
req2 -.-> defd1["기본값은 Framework와 .NET에서 다르다"]
defd1 -->|"맡기면"| envd1["되는 환경과 안 되는 환경이 나온다"]
req2 -->|"그래서"| exp2["반드시 명시한다"]
그림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도 확인한다
는 형태로 합니다.
flowchart TB
accTitle: SID 전달로 양쪽 경로가 성립한다
accDescr: consent prompt든 credential prompt든 성립하는 유일한 형태로, UI 쪽에서 자기 SID를 구해 helper에 넘기고 helper 쪽에서 그 SID에만 파이프 접속 권한을 주며 나아가 클라이언트 PID도 확인하는 흐름을 보이는 그림.
sid1["UI가 자기 SID를 구해 넘긴다"] --> aclx["helper가 그 SID에만 접속 권한"]
aclx --> pidx["클라이언트 PID도 확인"]
cuo1["CurrentUserOnly를 쓴다"] -.->|"권한 상승 수준 차이로 걸러진다"| ngz1["이 용도에서는 성립하지 않는다"]
그림12: 어느 프롬프트 경로에서도 성립하는 것은 UI에서 받은 SID로 ACL을 짜는 형태뿐입니다.
5.7 PID 검증은 “헐거운 끼어들기”를 줄이기 위한 추가 방어
무작위 pipe 이름만으로도 꽤 나아지지만, 같은 사용자로 실행되는 다른 프로세스가 먼저 접속할 여지는 제로가 아닙니다.
그래서 helper 쪽에서 GetNamedPipeClientProcessId를 써서 예상한 UI 프로세스 PID와 일치하는지를 확인합니다.
물론 PID가 맞다고 무엇이든 믿어도 되는 것은 아닙니다. UI가 침해되어 있으면 helper에도 위험한 요청이 도착합니다. 그래서 helper 쪽의 operation allowlist와 인자 검증이 필요합니다.
flowchart TB
accTitle: 층을 겹치는 추가 방어
accDescr: 무작위 파이프 이름, SID 한정 ACL, 클라이언트 PID 대조, operation allowlist와 인자 검증이라는 층을 겹쳐 헐거운 끼어들기와 위험한 요청 양쪽을 줄인다는 생각을 보이는 그림.
l1["무작위 pipe 이름"] --> l2["SID 한정 ACL"]
l2 --> l3["클라이언트 PID 대조"]
l3 --> l4["allowlist와 인자 재검증"]
l4 -.-> why3["PID가 맞아도 요청은 믿지 않는다"]
그림13: 어느 한 장도 완전하지 않으므로 접속과 요청 양쪽에 층을 겹쳐 좁힙니다.
여기까지의 규칙을 시작부터 종료까지 순서로 늘어놓으면 이렇게 됩니다. UI 쪽의 한 수마다 helper 쪽 확인이 짝을 이룬다는 형태만 읽히면 충분합니다.
sequenceDiagram
participant UI as MyApp.exe(medium)
participant OS as Windows / UAC
participant BR as AdminBroker.exe(high)
participant TGT as 관리자 권한이 필요한 대상
UI->>UI: 파이프 이름을 정하고 자기 SID와 PID를 준비
UI->>OS: 절대 경로 + Verb=runas로 실행
OS->>BR: 승인되면 권한 상승 토큰으로 실행
BR->>BR: 그 SID에만 허용하는 ACL로 파이프 생성
UI->>BR: 파이프에 접속
BR->>BR: 클라이언트 PID를 대조
UI->>BR: 타입이 있는 요청(operation 이름과 인자)
BR->>BR: allowlist 밖과 예상 밖 인자를 거부
BR->>TGT: 고정된 대상에만 조작
BR-->>UI: 결과를 돌려준다
BR->>BR: 종료해서 권한 상승 상태를 남기지 않는다
그림14: 실행·연결·요청 각각에 helper 쪽 확인이 대응합니다. 어느 하나를 빼면 그 단이 통과됩니다
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을 그대로 질질 흘리지 않고 길이를 붙여 보낸다는 것입니다. 요청 한 번, 응답 한 번이라는 단순한 프로토콜로 두면 사고가 적습니다.
flowchart TB
accTitle: 길이를 붙인 단순한 프로토콜
accDescr: 파이프에 JSON을 그대로 흘리지 않고 길이 헤더를 붙여 보내며, 요청 한 번과 응답 한 번만의 단순한 프로토콜로 두면 사고가 적음을 보이는 그림.
m1["길이 헤더를 쓴다"] --> m2["본문 JSON을 쓴다"]
m2 --> m3["상대는 길이만큼만 확실히 읽는다"]
m3 -.-> simple1["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에 가둡니다.
flowchart TB
accTitle: 실행 인자와 pipe의 역할 분담
accDescr: helper 실행 인자에는 pipe 이름과 클라이언트 확인에 필요한 최소 정보만 넘기고, 관리자 조작 자체는 pipe 안에서 보내는 타입이 있는 request에 가둔다는 역할 분담을 보이는 그림.
argx["실행 인자"] -->|"넘기는 것은"| minx["pipe 이름·PID·SID의 최소 정보"]
pipx["pipe 안"] -->|"가두는 것은"| reqx["타입이 있는 request의 관리자 조작"]
minx -.-> nolx["조작 내용은 인자에 싣지 않는다"]
그림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가 “권한 상승한 만능 상자”가 되기 어렵습니다.
flowchart TB
accTitle: helper 쪽에서 효과가 있는 검증의 순서
accDescr: helper는 명시적 ACL로 파이프를 만들고, 접속 후 클라이언트 PID를 검증하며, request는 operation 이름으로 dispatch해 고정 조작만 통과시킨다는, helper 쪽에서 효과가 있는 검증의 순서를 보이는 그림.
hs1["명시적 ACL로 파이프를 만든다"] --> hs2["클라이언트 PID를 검증"]
hs2 --> hs3["operation 이름으로 dispatch"]
hs3 -->|"allowlist 안"| hs4["고정 조작만 실행"]
hs3 -->|"allowlist 밖"| hs5["거부하고 응답"]
그림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 전체가 어느새 권한 상승해 있었다”는 코드를 읽어도 알아채기 어려운 사고입니다.
flowchart TB
accTitle: 분리되어 있는지의 4단계 확인
accDescr: UI 프로세스가 상승하지 않은 그대로인지, helper 실행 시에만 UAC 프롬프트가 나오는지, 관리자 조작이 실제로 먹혔는지, 실패해야 할 때 실패하는지를 차례로 확인하는 흐름을 보이는 그림.
v1["UI가 상승하지 않은 그대로인가"] --> v2["helper 실행 시에만 프롬프트가 나오는가"]
v2 --> v3["조작이 실제로 먹혔는가"]
v3 --> v4["실패해야 할 때 실패하는가"]
v4 -.-> whyv["네 번째를 보지 않으면 분리 확인이 되지 않는다"]
그림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와 관리자 처리의 경계가 그대로 설계 자산이 됩니다.
flowchart TB
accTitle: 경계가 설계 자산이 된다
accDescr: operation 계약을 제대로 나눠 두면 UI와 관리자 처리의 경계가 그대로 설계 자산이 되고, 나중에 service화하고 싶어졌을 때도 옮기기 쉬움을 보이는 그림.
ctr1["operation 계약을 나눠 둔다"] --> bd1["UI와 관리자 처리의 경계가 명확"]
bd1 --> asset1["경계가 그대로 설계 자산이 된다"]
asset1 -.-> future1["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 앱의 기밀 정보 저장 - DPAPI로 평문 설정을 피하기
Windows 앱에서 접속 정보나 API 토큰을 설정 파일에 평문으로 저장하지 않기 위해, DPAPI / ProtectedData의 사고방식, CurrentUser와 LocalMachine의 차이, 구현 시 주의점을 정리합니다.
Windows 앱 개발 보안 최소 체크리스트
WPF / WinForms / WinUI / C++ / C# 업무 앱에서 권한, 서명, 업데이트, 비밀 정보, HTTPS, 입력 검증, DLL 로드, 로그의 기본을 체크리스트 형식으로 정리합니다.
Windows에서 관리자 권한이 필요한 때는 언제인가 - UAC, 보호 영역, 설계로 구분하는 방법
Windows에서 관리자 권한이 필요한 상황을 UAC, 보호 영역, 서비스, 드라이버, per-user/per-machine 설계 관점에서 실무용으로 정리합니다.
Named Pipe 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows의 프로세스 간 통신의 정석인 Named Pipe를 실무 관점에서 해설합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 처리하는 서버 설계, ACL과 impersonation 보안, .NET NamedPipeStream까지 1차 ...
Windows I/O의 심층(제6회·최종회) ── 필터 드라이버와 minifilter: Procmon과 바이러스 검사가 I/O에 끼어들 수 있는 이유
Windows의 필터 드라이버와 minifilter를 그림으로 설명하는 연재의 최종회입니다. Filter Manager와 altitude, pre/post callback, Procmon과 바이러스 백신이 모든 I/O를 검사할 수 있는 구조, 「...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
UAC, helper EXE, 서비스로 전환할지 판단, machine-wide 설정 변경까지 포함해 Windows 앱 전체의 권한 설계와 맞물리므로 Windows 앱 개발과 잘 맞는 주제입니다.
기술 상담 & 설계 리뷰
기존 앱의 `requireAdministrator` 상시 사용을 재검토하고, broker 설계나 IPC 경계를 다시 정리하고 싶을 때는 기술 상담·설계 리뷰로 진행하기 쉬운 주제입니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 같은 프로세스 안의 일부 처리만 관리자 권한으로 실행할 수 있나요?
- 할 수 없습니다. Windows의 UAC는 함수 단위 권한 상승이 아니라, 프로세스가 어떤 토큰·무결성 수준으로 실행되는지로 제어됩니다. 부모-자식 프로세스는 같은 무결성 수준으로 토큰을 상속하므로, 상승하지 않은 UI 프로세스 안에서 특정 메서드만 관리자 권한으로 실행하는 설계는 불가능합니다. 필요한 처리는 별도 프로세스, 서비스, 예약 작업, 권한 상승 COM처럼 다른 실행 단위로 잘라냅니다.
- 관리자 권한이 필요한 처리를 분리하는 방법에는 어떤 선택지가 있나요?
- Microsoft Learn에서는 주로 4가지 모델을 듭니다. 표준 사용자 UI와 관리자 helper EXE를 조합하는 Administrator Broker Model, 상주 서비스를 쓰는 Operating System Service Model, 관리자 권한의 예약 작업을 쓰는 Elevated Task Model, 권한 상승 COM을 쓰는 Administrator COM Object Model입니다. 관리자 조작이 산발적이고 필요한 순간에만 UAC를 띄우면 되는 경우에는 broker EXE, 상시·무인·빈번하면 서비스, 한 번에 짧게 끝나는 정형 처리라면 작업이 맞습니다.
- runas로 실행한 helper EXE와의 통신에 표준 입출력을 쓸 수 있나요?
- 쓰기 어려우므로 피하는 편이 좋습니다. .NET에서 ProcessStartInfo.Verb는 UseShellExecute=true일 때만 유효하고, UseShellExecute=true로 두면 표준 입출력 리디렉션을 전제로 한 통신은 쓸 수 없게 됩니다. 그래서 helper와의 주고받기는 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이 다루기 쉽습니다.