UAC ב-Windows: איך מפרידים רק פעולות שדורשות Administrator
· עודכן בתאריך: · Go Komura · פיתוח Windows, אבטחה, UAC, C# / .NET, Win32
היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 16 Mar 2026)
- פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173506)
מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.
Go Komura (2026). UAC ב-Windows: איך מפרידים רק פעולות שדורשות Administrator. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173506 https://comcomponent.com/he/blog/windows-admin-broker-deep-dive/
- DOI (הגרסה האחרונה)
- 10.5281/zenodo.22173506
- DOI (הגרסה הזו)
- 10.5281/zenodo.22173507
במאמר הקודם, “Checklist מינימלי לאבטחת אפליקציות Windows”, כתבנו את הקו: ברירת המחדל היא asInvoker, ומפרידים רק פעולות שדורשות הרשאות Administrator.
הפעם נגיע עד איך כותבים את זה בפועל.
באפליקציית Windows אי אפשר להריץ בנוחות רק חלק מהפעולות באותו process “כ-Administrator”. Elevation היא עניין של גבול process. מה שצריך הוא תכנון שמוציא רק את הפעולה הזו ל-execution unit נפרד.
flowchart TB
accTitle: elevation היא עניין של גבול process
accDescr: תרשים שמראה שאי אפשר להריץ כ-Administrator רק חלק מהפעולות באותו process, ושכיוון ש-elevation היא גבול process צריך להוציא את הפעולה ל-execution unit נפרד.
wish1["רוצים הרשאות Administrator רק לחלק מהפעולות"] -.->|"אי אפשר באותו process"| in1["elevation בתוך אותו process"]
wish1 -->|"מה שאפשר"| cut1["מוציאים את הפעולה ל-execution unit נפרד"]
איור 1: elevation נקבעת ברמת process, לא ברמת function. לכן רק תכנון שמוציא החוצה מחזיק.
במאמר הזה נתקדם בסדר הבא:
- קודם ההנחות
- איזה מודל הפרדה בוחרים
- הצורה הכי מעשית:
asInvoker+ helper EXE בהרשאות Administrator - מלכודות שלא כדאי לפספס במימוש
- דוגמאות קוד
דוגמאות הקוד מניחות .NET 8 / אפליקציית desktop ל-Windows. תשתית ה-UI יכולה להיות WPF, WinForms או WinUI. ההבדל שיוצא בפועל הוא בעיקר ב-event handler בצד ה-UI.
הקוד שמופיע כאן זמין כסט דוגמה שאפשר לבנות ולהריץ (ספריית contract משותפת, הדגמה של UI / helper בהרשאות Administrator, ו-unit tests שרצים גם ב-Linux) ב-GitHub.
windows-admin-broker-deep-dive - komurasoft-blog-samples (GitHub)
איך לקרוא את המאמר
המאמר ארוך, אז קודם מפת ניווט.
| מה רוצים לדעת | מה לקרוא |
|---|---|
| רק איך בוחרים מודל הפרדה | פרקים 1–4 (מסקנה, השוואת ארבעת המודלים, הצורה המומלצת) |
| החלטות תכנון והנימוקים | פרק 5 (allowlist, absolute path, runas, ACL של pipe, בדיקת PID) |
| קוד המימוש | פרקים 7–14 (מבנה, manifest, contract משותף, צד UI, צד helper) |
| איך בודקים שההפרדה באמת מחזיקה | 15.6 |
| צורות שאסור | פרק 16 |
הקוד המלא נמצא גם בדוגמה ב-GitHub. אם רוצים רק את דיון התכנון — פרקים 1–5 ו-15–16. אם רוצים גם את המימוש — לקרוא ברצף.
מה צריך לפני שמריצים
כדי להריץ את הדוגמה בפועל צריך:
- מחשב Windows (UAC prompt, ACL מפורש דרך
PipeSecurity,GetNamedPipeClientProcessId, וכתיבה ל-HKLM — כולם ספציפיים ל-Windows) - .NET 8 SDK ומעלה
- חשבון שיכול לאשר elevation. חשבון Administrator מקבל consent prompt; standard user מקבל credential prompt. אם רוצים לבדוק את שני המסלולים, מכינים את שני סוגי החשבונות
- מכונה שמותר לשנות בה את HKLM. הדוגמה יוצרת machine-wide את
HKLM\SOFTWARE\Classes\*\shell\MyApp.Open. בטוח יותר לנסות ב-VM לבדיקה, לא במחשב הפיתוח הרגיל
פקודות build והרצה מרוכזות ב-README של הדוגמה. נקודה אחת כדאי לזכור מראש: עושים publish ל-UI ול-helper לאותה תיקייה, ורק אז מריצים (ה-helper פותר באופן קבוע את MyApp.exe שבאותה תיקייה שלו).
1. קודם המסקנה
הפתרון המעשי, בקצרה:
- אפליקציית UI רגילה ממשיכה לרוץ ב-
asInvoker - פעולות שדורשות הרשאות Administrator מוציאים ל-EXE נפרד
- אותו helper EXE מוגדר כ-
requireAdministrator - מפעילים אותו עם
runas - התקשורת עם ה-helper היא named pipe או IPC דומה, לא stdin/stdout שלא מסתדר עם
runas - ל-helper מעבירים typed request, לא raw command string
- בצד ה-helper עושים validation שוב על תוכן הבקשה
- מקור ה-IPC מצטמצם לפי SID של המשתמש שקורא ו-PID הצפוי
“נוח להריץ הכול כ-Administrator” נכון רק בפעם הראשונה. אחר כך זה חוזר ככאב ראש סביב UAC, drag-and-drop, תכנון לוגים, קלט חיצוני, תפעול תמיכה, טעינת DLL, ואיפה נשמרת התצורה.
flowchart TB
accTitle: השלד של הפתרון המעשי
accDescr: תרשים שמראה שה-UI נשאר asInvoker, פעולות שדורשות Administrator יוצאות ל-EXE נפרד עם requireAdministrator שמופעל ב-runas, ודרך named pipe מעבירים רק typed request.
uix1["ה-UI נשאר asInvoker"] -->|"הפעלה עם runas"| hx1["helper EXE (requireAdministrator)"]
uix1 -->|"typed request דרך named pipe"| hx1
hx1 --> vfy1["ה-helper עושה validation שוב על הבקשה"]
איור 2: השלד נקבע בשלוש נקודות — UI לא-elevated, helper elevated, ו-IPC של typed request.
ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 21, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle
2. הנחת יסוד: אי אפשר להפוך רק חלק מאותו process ל-Administrator
UAC ב-Windows לא עושה “elevation ברמת function”. הוא נשלט לפי באיזה token / integrity level רץ ה-process. אפליקציה שצריכה access token של Administrator נכנסת ל-elevation prompt. process הורה ו-process בן יורשים token באותו integrity level. כלומר, אי אפשר לתכנן שבתוך process UI לא-elevated, method אחד פתאום ירוץ בהרשאות Administrator. אם צריך — משתמשים ב-execution unit נפרד: process אחר, Windows Service, scheduled task, elevated COM.
אם מתעלמים מההנחה הזו, מגיעים לבקשת תכנון מהסוג: “רוצים להיות Administrator רק ברגע שלוחצים על הכפתור הזה”. Windows לא פותר את זה מעצמו.
flowchart TB
accTitle: UAC נשלט לפי token ו-integrity level
accDescr: תרשים שמראה ש-UAC לא נשלט ברמת function אלא לפי ה-token ו-integrity level של ה-process, ש-process הורה ובן יורשים את אותה רמה, ולכן elevation ברמת method לא אפשרית ונדרש execution unit נפרד.
uac1["יחידת השליטה של UAC"] --> tk1["token ו-integrity level של ה-process"]
tk1 --> inh1["process הורה ובן יורשים את אותה רמה"]
inh1 --> no1["אין elevation ברמת method"]
no1 --> alt2["process נפרד / service / task / elevated COM"]
איור 3: יחידת השליטה היא ה-process. פעולה שצריך להעלות בהרשאה חייבת לשבת ב-execution unit נפרד.
2.1 קודם ממפים integrity level
מכאן והלאה יופיעו medium integrity / high integrity. נמפה אותם עכשיו.
| integrity level | המשמעות במאמר | דוגמה |
|---|---|---|
| medium | process שרץ כ-standard user | אפליקציית UI עם asInvoker |
| high | process elevated | helper EXE עם requireAdministrator |
ב-Mandatory Integrity Control של Windows מוגדרות ארבע רמות: low / medium / high / system. standard user מקבל medium; משתמש elevated מקבל high. כלומר התכנון כאן הוא לשים גבול מפורש בין process UI ב-medium לבין process helper ב-high, ולתת להם לדבר דרכו.
כשהמיפוי הזה בראש, גם הדיון ב-CurrentUserOnly בפרק 5.6 וגם 16.4 נקראים ישר.
flowchart TB
accTitle: הגבול בין medium ל-high
accDescr: תרשים שמראה ש-UI ב-asInvoker שרץ כ-standard user מקבל medium, ש-helper elevated ב-requireAdministrator מקבל high, ושהתכנון נותן לשניים לדבר דרך גבול מפורש.
med1["medium: process UI ב-asInvoker"] -->|"מדברים דרך גבול מפורש"| hg1["high: process helper elevated"]
med1 -.-> sd2["token של standard user"]
hg1 -.-> ad2["token של משתמש elevated"]
איור 4: התכנון הזה שם גבול מפורש בין UI ב-medium ל-helper ב-high.
3. איזה מודל הפרדה בוחרים
Microsoft Learn מציין בעיקר ארבעה אופנים להפרדת אפליקציה שדורשת הרשאות Administrator.
| מודל | הצורה בקצרה | מתי מתאים |
|---|---|---|
| Administrator Broker Model | אפליקציית UI של standard user + helper EXE בהרשאות Administrator | פעולות מזדמנות; מספיק UAC prompt ברגע הצורך |
| Operating System Service Model | UI של standard user + Windows Service resident | יכולת ניהול always-on, ניטור ברקע, עיבוד unattended |
| Elevated Task Model | UI של standard user + scheduled task בהרשאות Administrator | job קצר וקבוע בכל פעם |
| Administrator COM Object Model | UI של standard user + elevated COM | כבר יש תכנון COM, והפונקציונליות מוגבלת למדי |
קווים לבחירה:
3.1 broker EXE הוא בדרך כלל נקודת הפתיחה
broker EXE נכנס בנוחות לפעולות מהסוג הזה:
- רישום / ביטול שילוב עם Explorer
- שינוי configuration machine-wide תחת HKLM
- רישום / ביטול Windows Service של האפליקציה עצמה
- הוספה / הסרה של כלל firewall
- פעולת Administrator תחת Program Files
אלה נוטים להיות מיותרים ביום-יום, ונדרשים רק בלחיצה על כפתור ספציפי במסך ההגדרות. במקרה כזה, ישר יותר להפעיל את ה-helper EXE פעם אחת ולסיים, מאשר להביא Windows Service resident.
flowchart TB
accTitle: הצורה שבה broker EXE נכנס בנוחות
accDescr: תרשים שמראה שכשפעולת Administrator מיותרת ביום-יום ונדרשת רק בלחיצה על כפתור במסך ההגדרות, ישר יותר להפעיל helper EXE פעם אחת ולסיים מאשר להביא Windows Service resident.
btn1["לחיצה על כפתור ספציפי במסך ההגדרות"] --> once2["הפעלת helper EXE פעם אחת"]
once2 --> done1["הפעולה מסתיימת וה-helper נעלם"]
btn1 -.->|"בתדירות הזו זה מיותר"| svc2["להביא Windows Service resident"]
איור 5: לפעולת Administrator מזדמנת, הכי ישר הוא helper EXE שחי רק ברגע הצורך.
3.2 בוחרים service כשזה always-on, unattended, או תכוף
Windows Service הוא מודל שבו אפליקציית standard user מתקשרת, למשל דרך RPC. היתרון: אפשר לקבל עיבוד בצד הניהול בלי elevation prompt. התמורה: אחריות לתפעל process resident.
flowchart TB
accTitle: ה-trade-off של מודל ה-service
accDescr: תרשים שמראה שמודל ה-service מאפשר לקבל עיבוד ניהולי בלי elevation prompt, אבל מוסיף אחריות לתפעל process resident, ומתאים לשימוש always-on, unattended ותכוף.
svm1["Operating System Service Model"] -->|"יתרון"| npr1["מקבלים עיבוד בלי elevation prompt"]
svm1 -->|"תמורה"| res2["אחריות לתפעל process resident"]
svm1 -.-> fitx["מתאים לשימוש always-on, unattended ותכוף"]
איור 6: service מחליף היעדר prompt באחריות לתפעול process resident.
service מתאים לשימושים כמו:
- ניטור מתמשך
- איסוף לוגים
- עדכון ברקע
- שילוב מתמשך עם מכשיר או daemon
- יכולת ניהול שמשותפת בין כמה UI sessions
3.3 scheduled task מתאים ל-job קצר וקבוע
Elevated Task Model: אפליקציית standard user מפעילה scheduled task שרץ בהרשאות Administrator. זה קל יותר מ-service, ונסגר כשמסתיים, ולכן מתאים ל-job קבוע שקורה פעם בכל פעם.
3.4 elevated COM מוגבל מאוד
COM elevation moniker נראה נוח, אבל מקום השימוש צר. גם ב-Microsoft Learn כתוב שה-UI שיכול לשלוט ב-elevated COM צריך להיות מוצג בצד ה-COM. זה לא מתאים לכיוון “לתת ל-UI לא-elevated לעשות מה שרוצים מול elevated COM”.
flowchart TB
accTitle: מקום השימוש הצר של elevated COM
accDescr: תרשים שמראה ש-elevated COM נראה נוח אבל ה-UI ששולט בו חייב להיות מוצג בצד ה-COM, ולכן זה לא מתאים להפעלה חופשית מ-UI לא-elevated.
look1["elevated COM נראה נוח"] -.-> free1["הפעלה חופשית מ-UI לא-elevated"]
free1 -.->|"לא מתאים לכיוון הזה"| ngc1["מקום השימוש צר"]
ui2["ה-UI ששולט מוצג בצד ה-COM"] --> ngc1
איור 7: elevated COM מניח ש-UI השליטה יושב בצד ה-COM. הוא לא מפלט כללי.
4. ההמלצה כאן: UI ב-asInvoker + helper EXE ב-requireAdministrator
מכאן נבנה בפירוט את הצורה הכי מעשית.
flowchart TB
accTitle: מבנה UI ב-medium מול helper ב-high
accDescr: תרשים שמראה ש-UI ב-medium integrity מפעיל ב-runas helper ב-high integrity, שולח typed request דרך named pipe שבודק SID ו-PID, וה-helper מפזר לפי allowlist לפני פעולה על יעד קבוע שדורש Administrator.
subgraph MED["medium integrity — לא-elevated עד הסוף"]
UI["MyApp.exe (asInvoker) — מקבל פעולת משתמש ומרכיב request"]
end
subgraph HIGH["high integrity — process elevated קצר-חיים"]
BR["MyApp.AdminBroker.exe (requireAdministrator)"]
PIPE["named pipe — connect רק ל-SID של משתמש ה-UI, ובודקים גם PID"]
DISP["dispatch לפי allowlist של operation — validation חוזר בצד ה-helper"]
end
TGT["יעד קבוע שדורש Administrator — מפתח תחת HKLM / רישום service / כלל firewall"]
UI -->|"absolute path + Verb=runas (כאן מופיע UAC prompt)"| BR
BR --> PIPE
UI -->|"typed request (בלי raw command string)"| PIPE
PIPE --> DISP
DISP --> TGT
איור 8: גבול ה-elevation מיושר עם גבול ה-process. ה-UI נשאר medium; רק ה-helper רץ high לזמן קצר.
שלוש נקודות:
- process ה-UI נשאר לא-elevated עד הסוף
- ה-helper בהרשאות Administrator קצר-חיים
- ה-helper מקבל רק allowlist קבוע
שמירה על שלוש אלה כבר מסדרת את התכנון.
flowchart TB
accTitle: שלוש הנקודות שכדאי לשמור
accDescr: תרשים שמראה שאם שומרים על UI לא-elevated עד הסוף, helper קצר-חיים, ו-allowlist קבוע בלבד, התכנון מתארגן.
p1["ה-UI לא-elevated עד הסוף"] --> tidy1["התכנון מתארגן"]
p2["ה-helper קצר-חיים"] --> tidy1
p3["רק allowlist מתקבל"] --> tidy1
איור 9: לא-elevated, קצר-חיים, allowlist — שלושת אלה קובעים את צורת גבול ההרשאות.
5. כללים שלא כדאי לפספס במימוש
כאן כדאי להחליט לפני שכותבים קוד.
5.1 ה-helper לא הופך ל-generic executor
הצורה השגויה נראית כך:
- ה-UI מעביר ל-helper
reg add ...כמחרוזת שלמה - ה-UI מעביר ל-helper
sc.exe ...כמחרוזת שלמה - ה-UI מעביר ל-helper נתיב Registry שרירותי או נתיב EXE שרירותי
אם עושים את זה, UI שנפרץ גורר גם את ה-helper. ה-helper בהרשאות Administrator יושב בתוך גבול ה-elevation. לפתוח שם “פתח שמריץ הכול” מסוכן.
הצורה הטובה:
set-explorer-context-menuinstall-serviceadd-firewall-rule
מקבעים את הפעולה עצמה, ומצמצמים גם את הארגומנטים לטיפוסים מוגבלים: bool / enum / מספר / מחרוזת מתוחמת.
flowchart TB
accTitle: איך נמנעים מ-generic executor
accDescr: תרשים שמראה שהעברת raw command string או נתיב שרירותי ל-helper יוצרת פתח להרצת הכול, וש-UI שנפרץ גורר את ה-helper, בעוד שקיבוע הפעולה והגבלת הארגומנטים מצמצמים את משמעות ה-helper.
raw1["מעבירים raw command string"] --> hole1["נוצר פתח שמריץ הכול"]
hole1 --> both1["UI שנפרץ גורר גם את ה-helper"]
fix2["מקבעים פעולה ומצמצמים ארגומנטים לטיפוס"] --> narrow1["המשמעות של ה-helper מצטמצמת"]
איור 10: אל תוך גבול ה-elevation מעבירים רק פעולה קבועה וארגומנטים מוגבלים.
5.2 הנתיב ל-helper הוא absolute, וה-UI לא מחליט יותר מדי על היעד
את ה-helper EXE עצמו, שמופעל עם runas, מציינים ב-absolute path.
לא סומכים על חיפוש ב-PATH ועל נתיב יחסי.
גם את היעד שה-helper פועל עליו פותרים, כמה שאפשר, באופן קבוע בצד ה-helper.
בדוגמה הזו, ה-EXE שנרשם בתפריט ההקשר של Explorer קבוע ל-MyApp.exe שבאותה תיקייה של ה-helper.
5.3 אם משתמשים ב-Verb="runas", מגדירים במפורש UseShellExecute=true
ב-.NET, ProcessStartInfo.Verb עובד רק כש-UseShellExecute=true.
ובנוסף, ברירת המחדל של UseShellExecute שונה בין .NET Framework ל-.NET Core / .NET.
אם משאירים את זה לברירת המחדל, אחר כך מגיעה תקלה שקטה: “בסביבה אחת זה עובד, באחרת לא”.
לכן כאן תמיד מגדירים במפורש.
flowchart TB
accTitle: מה שצריך להגדיר במפורש בהפעלה עם runas
accDescr: תרשים שמראה ש-Verb של ProcessStartInfo עובד רק כש-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 ו-redirect של stdin/stdout לא מסתדרים
כש-UseShellExecute=true, תקשורת שמניחה redirect של stdin/stdout נהיית קשה.
לכן ישר יותר להשתמש ב-IPC אחר, כמו named pipe, מול ה-helper.
5.5 named pipe לא נשען על ACL ברירת מחדל
ב-named pipe, security descriptor ברירת המחדל נותן הרשאת קריאה ל-Everyone ולאנונימי. להשתמש בזה כמו שהוא ל-IPC של helper בהרשאות Administrator זה רשלני.
עדיף תמיד להגדיר PipeSecurity מפורש.
5.6 לא משתמשים ב-PipeOptions.CurrentUserOnly למקרה הזה
במבט ראשון זה נראה נוח.
אבל ב-Windows, CurrentUserOnly בודק לא רק את חשבון המשתמש אלא גם את רמת ה-elevation.
כלומר, זה לא מתאים לתקשורת בין UI לא-elevated ל-helper elevated.
בנוסף, מי מתחבר ל-pipe עם איזה token משתנה לפי סוג ה-UAC prompt. טבלה מקבעת למה צריך ACL מפורש.
| חשבון ההרצה של ה-UI | UAC prompt שמופיע | החשבון שה-helper רץ תחתיו | WindowsIdentity.GetCurrent() של ה-helper שיוצר את ה-pipe |
ה-SID של צד ה-UI שמתחבר |
|---|---|---|---|---|
| חשבון Administrator (לא-elevated) | consent prompt (לחיצה על Yes) | token elevated של אותו משתמש | אותו משתמש כמו ה-UI | משתמש ה-UI |
| standard user | credential prompt (הזנת credentials של חשבון אחר) | חשבון Administrator אחר שהוזן | משתמש שונה מה-UI | משתמש ה-UI |
איך קוראים את זה:
- בשורה העליונה, המשתמש הנוכחי של ה-helper = משתמש ה-UI. גם אם ה-helper בונה ACL רק לפי ה-SID של עצמו, החיבור יוצא במקרה
- בשורה התחתונה, המשתמש הנוכחי של ה-helper ומשתמש ה-UI הם אנשים שונים. אם בונים כאן ACL רק לפי
WindowsIdentity.GetCurrent(), משתמש ה-UI המקורי לא יוכל לשלוח את הבקשה - בשתי השורות,
CurrentUserOnlyנדחה בגלל הפרש elevation בין UI ב-medium ל-helper ב-high
כלומר, הצורה היחידה שעובדת בשתי השורות היא לקבל SID מצד ה-UI, ולתת הרשאת connect לאותו SID.
לכן במאמר הזה:
- ה-UI שולף את ה-SID של עצמו ומעביר אותו ל-helper
- ה-helper נותן הרשאת connect ל-pipe רק ל-SID של משתמש ה-UI
- ובנוסף בודקים גם את PID של הצד שמתחבר עם
GetNamedPipeClientProcessId
flowchart TB
accTitle: העברת SID עובדת לשני המסלולים
accDescr: תרשים שמראה שהצורה היחידה שעובדת גם ב-consent prompt וגם ב-credential prompt היא שה-UI שולף SID ומעביר ל-helper, ה-helper נותן connect רק לאותו SID, ובודקים גם PID של הצד שמתחבר.
sid1["ה-UI שולף SID ומעביר"] --> aclx["ה-helper נותן connect רק לאותו SID"]
aclx --> pidx["בודקים גם PID של הצד שמתחבר"]
cuo1["שימוש ב-CurrentUserOnly"] -.->|"נדחה בגלל הפרש elevation"| ngz1["לא מחזיק במקרה הזה"]
איור 12: הצורה היחידה שעובדת בשני מסלולי ה-prompt היא ACL שנבנה מה-SID שה-UI מעביר.
5.7 בדיקת PID היא הגנה נוספת מול connect מזדמן
שם pipe אקראי כבר עוזר מאוד, אבל האפשרות ש-process אחר שרץ תחת אותו משתמש יתחבר קודם אינה אפס.
לכן בצד ה-helper משתמשים ב-GetNamedPipeClientProcessId כדי לבדוק שה-PID תואם את process ה-UI הצפוי.
כמובן, PID תואם לא אומר שאפשר לסמוך על הכול. אם ה-UI נפרץ, גם בקשות מסוכנות מגיעות ל-helper. בדיוק בגלל זה צריך allowlist של operations בצד ה-helper, ו-validation של הארגומנטים.
flowchart TB
accTitle: הגנה בשכבות
accDescr: תרשים שמראה ששם pipe אקראי, ACL מוגבל ל-SID, בדיקת PID של הצד שמתחבר, ו-allowlist עם validation של ארגומנטים מצטברים בשכבות ומקטינים גם connect מזדמן וגם בקשות מסוכנות.
l1["שם pipe אקראי"] --> l2["ACL מוגבל ל-SID"]
l2 --> l3["בדיקת PID של הצד שמתחבר"]
l3 --> l4["allowlist ו-validation חוזר של ארגומנטים"]
l4 -.-> why3["גם אם ה-PID תואם, הבקשה לא נסמכת עליו לבד"]
איור 13: אף שכבה לבד אינה מושלמת. מצמצמים גם את החיבור וגם את הבקשה בשכבות.
אם מסדרים את הכללים עד כאן לפי הסדר מההפעלה עד הסיום, זה נראה כך. מספיק לקלוט שכל צעד בצד ה-UI מקביל לבדיקה בצד ה-helper.
sequenceDiagram
accTitle: הרצף מהפעלת ה-helper עד סיום בלי מצב elevated
accDescr: תרשים רצף שמראה שה-UI מכין SID ו-PID ומפעיל את ה-helper ב-runas, ה-helper יוצר pipe מוגן ב-ACL ובודק PID בחיבור, מקבל typed request, דוחה מה שמחוץ ל-allowlist, פועל על היעד הקבוע, מחזיר תוצאה ל-UI ומסתיים בלי להשאיר מצב elevated.
participant UI as MyApp.exe(medium)
participant OS as Windows / UAC
participant BR as AdminBroker.exe(high)
participant TGT as יעד שדורש הרשאות Administrator
UI->>UI: קובע שם pipe, ומכין את ה-SID וה-PID של עצמו
UI->>OS: הפעלה עם absolute path ו-Verb=runas
OS->>BR: אם אושר, מפעיל עם token elevated
BR->>BR: יוצר pipe עם ACL שמתיר רק לאותו SID
UI->>BR: מתחבר ל-pipe
BR->>BR: בודק את PID של הצד שמתחבר
UI->>BR: typed request (שם operation וארגומנטים)
BR->>BR: דוחה מה שמחוץ ל-allowlist וארגומנטים לא צפויים
BR->>TGT: פעולה רק על היעד הקבוע
BR-->>UI: מחזיר תוצאה
BR->>BR: מסתיים בלי להשאיר מצב elevated
איור 14: לכל אחד מהצעדים — הפעלה, חיבור, בקשה — יש בדיקה מקבילה בצד ה-helper. דילוג על אחת מהן הופך את השלב לפתוח.
6. נושא הדוגמה
הפעם ניקח רישום / ביטול פריט בתפריט לחיצה ימנית של Explorer, machine-wide.
הסיבה פשוטה:
- דורש הרשאות Administrator
- גבול הפעולה ברור
- אין צורך להעביר ל-helper raw command string
- זה גם מצב מציאותי בעבודה
מפתחות היעד לרישום קבועים:
HKLM\SOFTWARE\Classes\*\shell\MyApp.OpenHKLM\SOFTWARE\Classes\*\shell\MyApp.Open\command
ה-UI מחזיק רק checkbox של “רישום בתפריט לחיצה ימנית של Explorer”. פעולת ה-Registry עצמה בצד ה-helper.
7. מבנה הפתרון
MyApp/
MyApp/ אפליקציית UI (asInvoker)
app.manifest
ElevationBrokerClient.cs
SettingsPage.xaml.cs
MyApp.AdminBroker/ helper בהרשאות Administrator (requireAdministrator)
app.manifest
Program.cs
BrokerLaunchOptions.cs
ExplorerContextMenuRegistration.cs
MyApp.BrokerProtocol/ contract משותף
BrokerProtocol.cs
אם מוציאים את ה-contract לפרויקט נפרד, קל יותר ליישר בין UI ל-helper את:
- שם ה-operation
- טיפוסי request / response
- פורמט ההודעה ב-pipe
8. ה-manifests
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. קוד ה-contract המשותף
9.1 MyApp.BrokerProtocol/BrokerProtocol.cs
using System.Buffers.Binary;
using System.Text.Json;
namespace MyApp.BrokerProtocol;
public static class BrokerJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
public static class BrokerOperations
{
public const string SetExplorerContextMenu = "set-explorer-context-menu";
}
public sealed record BrokerRequest(string Operation, JsonElement Payload);
public sealed record BrokerResponse(bool Success, string? ErrorCode, string? Message)
{
public static BrokerResponse Ok(string? message = null) => new(true, null, message);
public static BrokerResponse Fail(string errorCode, string message) =>
new(false, errorCode, message);
}
public sealed record SetExplorerContextMenuRequest(bool Enabled);
public static class PipeMessageSerializer
{
private const int MaxPayloadBytes = 256 * 1024;
public static async Task WriteAsync<T>(Stream stream, T value, CancellationToken cancellationToken)
{
byte[] payload = JsonSerializer.SerializeToUtf8Bytes(value, BrokerJson.Options);
if (payload.Length > MaxPayloadBytes)
{
throw new InvalidDataException($"Payload is too large: {payload.Length} bytes.");
}
byte[] header = new byte[sizeof(int)];
BinaryPrimitives.WriteInt32LittleEndian(header, payload.Length);
await stream.WriteAsync(header.AsMemory(0, header.Length), cancellationToken);
await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancellationToken);
await stream.FlushAsync(cancellationToken);
}
public static async Task<T> ReadAsync<T>(Stream stream, CancellationToken cancellationToken)
{
byte[] header = await ReadExactAsync(stream, sizeof(int), cancellationToken);
int payloadLength = BinaryPrimitives.ReadInt32LittleEndian(header);
if (payloadLength <= 0 || payloadLength > MaxPayloadBytes)
{
throw new InvalidDataException($"Invalid payload length: {payloadLength}");
}
byte[] payload = await ReadExactAsync(stream, payloadLength, cancellationToken);
return JsonSerializer.Deserialize<T>(payload, BrokerJson.Options)
?? throw new InvalidDataException($"Failed to deserialize {typeof(T).FullName}.");
}
private static async Task<byte[]> ReadExactAsync(Stream stream, int length, CancellationToken cancellationToken)
{
byte[] buffer = new byte[length];
int offset = 0;
while (offset < length)
{
int read = await stream.ReadAsync(buffer.AsMemory(offset, length - offset), cancellationToken);
if (read == 0)
{
throw new EndOfStreamException("Pipe was closed before the expected number of bytes was read.");
}
offset += read;
}
return buffer;
}
}
הנקודה: לא לזרום JSON חופשי ב-pipe, אלא לשלוח עם length prefix. פרוטוקול פשוט של בקשה אחת ותשובה אחת פחות נוטה לתקלות.
flowchart TB
accTitle: פרוטוקול פשוט עם length prefix
accDescr: תרשים שמראה שבמקום לזרום JSON חופשי ב-pipe כותבים כותרת אורך ואז את גוף ה-JSON, כך שהצד השני קורא בדיוק את האורך, ופרוטוקול של בקשה אחת ותשובה אחת פחות נוטה לתקלות.
m1["כותבים כותרת אורך"] --> m2["כותבים את גוף ה-JSON"]
m2 --> m3["הצד השני קורא בדיוק את האורך"]
m3 -.-> simple1["פשטות של בקשה אחת ותשובה אחת עוזרת"]
איור 15: ההודעה נשלחת עם length prefix. בקשה אחת ותשובה אחת מצמצמות תקלות.
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("אישור הרשאות Administrator בוטל.", 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 והמידע המינימלי לאימות מקור החיבור. פעולת הניהול עצמה נשארת בתוך typed request שנשלח ב-pipe.
flowchart TB
accTitle: חלוקת תפקידים בין ארגומנטי ההפעלה ל-pipe
accDescr: תרשים שמראה שארגומנטי ההפעלה של ה-helper מעבירים רק מידע מינימלי — שם pipe, PID ו-SID — ופעולת הניהול עצמה נשארת ב-typed request בתוך ה-pipe.
argx["ארגומנטי ההפעלה"] -->|"מה שמועבר"| minx["מידע מינימלי: שם pipe, PID, SID"]
pipx["בתוך ה-pipe"] -->|"מה שנשאר בפנים"| reqx["פעולת הניהול כ-typed request"]
minx -.-> nolx["תוכן הפעולה לא עולה על הארגומנטים"]
איור 16: הארגומנטים משמשים רק לסידור החיבור. תוכן הפעולה נשאר typed request ב-pipe.
ה-QuoteArgument הזה הוא מימוש מינימלי שמניח ערכים פשוטים כמו שם pipe, PID ו-SID בדוגמה הזו. אם מעבירים נתיב Windows שרירותי או מחרוזת קלט חופשי כ-command-line argument, מחליפים ב-escape ייעודי שתואם את כללי ה-argv של Windows.
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, ברגע שחסר ארגומנט או שיש ארגומנט מיותר — זו שגיאה. בתוך גבול ה-elevation עדיף לא “לפרש בכל מחיר”.
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);
}
מה שעובד כאן:
- בונים את ה-ACL של ה-pipe במפורש
- ה-ACL ניתן לא רק ל-SID של המשתמש הנוכחי של ה-helper, אלא גם ל-SID של משתמש ה-UI שקורא
- אחרי החיבור בודקים את ה-PID של הצד שמתחבר
- גם אחרי קבלת ה-request, עושים dispatch לפי שם ה-operation
אם מקבעים ב-switch (request.Operation) שרק פעולות קבועות עוברות, קשה יותר ל-helper להפוך ל”תיבה elevated שעושה הכול”.
flowchart TB
accTitle: סדר הבדיקות שפועלות בצד ה-helper
accDescr: תרשים שמראה שה-helper בונה pipe עם ACL מפורש, אחרי החיבור בודק PID של הצד שמתחבר, ומפזר את ה-request לפי שם operation כך שרק פעולות קבועות עוברות.
hs1["בניית pipe עם ACL מפורש"] --> hs2["בדיקת PID של הצד שמתחבר"]
hs2 --> hs3["dispatch לפי שם operation"]
hs3 -->|"בתוך ה-allowlist"| hs4["מריצים רק את הפעולה הקבועה"]
hs3 -->|"מחוץ ל-allowlist"| hs5["דוחים ומשיבים בהתאם"]
איור 17: רק request שעבר שלושה שלבים — ACL, PID, dispatch — מגיע לפעולה הקבועה.
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 נתיב Registry שרירותי
- לא מקבל מה-UI raw command string
- ה-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 רגיל.
- קורא את מצב ה-checkbox
- קורא ל-broker client
- אם נכשל, מחזיר את ה-UI
זה הכול. לא נוגע ישירות ב-Registry. זו ההפרדה.
15. מה שהמימוש הזה שומר
הקווים שהדוגמה באמת שומרת:
15.1 הפרדת אחריות בין UI ל-helper
- ה-UI רק מקבל את פעולת המשתמש
- ה-helper רק מבצע פעולת Administrator קבועה
15.2 ל-helper אין “פתח הרצה חופשי”
- לא מקבל נתיב Registry שרירותי
- לא מקבל command line שרירותי
- לא מקבל נתיב EXE שרירותי
15.3 מסלול ההפעלה קבוע
- ה-helper EXE ב-absolute path
runasמוגדר במפורשUseShellExecute = trueמוגדר במפורש
15.4 מקור החיבור ב-IPC מצטמצם
- ACL של ה-pipe מוגבל ל-SID של משתמש ה-UI
- אחרי החיבור בודקים את PID של הצד שמתחבר
15.5 גם יעד פעולת הניהול קבוע
- ה-hive / הנתיב ב-Registry קבועים
- גם ה-EXE שנרשם נפתר באופן קבוע
ברמה הזו מתרחקים מאוד ממצב שבו “אם ה-UI נפרץ, אפשר לעשות עם ה-helper הכול”.
15.6 בודקים אם ההפרדה מחזיקה בפועל
עד כאן זה תכנון. אם מה שכתבנו באמת מופרד יודעים רק אחרי הרצה ובדיקה. “כל ה-UI נהיה elevated בלי ששמו לב” היא תקלה שקשה לראות מקריאת קוד.
flowchart TB
accTitle: ארבעה שלבים לבדיקת ההפרדה
accDescr: תרשים שמראה שבודקים בסדר האם process ה-UI נשאר לא-elevated, האם UAC prompt מופיע רק בהפעלת ה-helper, האם פעולת הניהול באמת חלה, והאם היא נכשלת כשצריך להיכשל.
v1["האם ה-UI נשאר לא-elevated"] --> v2["האם ה-prompt מופיע רק בהפעלת ה-helper"]
v2 --> v3["האם הפעולה באמת חלה"]
v3 --> v4["האם היא נכשלת כשצריך"]
v4 -.-> whyv["בלי הבדיקה הרביעית ההפרדה לא מאומתת"]
איור 18: הרצה ובדיקה של ארבעת השלבים בסדר תופסות פרצות elevation שקוד לבד לא חושף.
הבדיקה לפי ארבעה שלבים, בסדר.
1. האם process ה-UI נשאר לא-elevated
הנקודה החשובה ביותר. מפעילים את ה-UI, ובודקים אחרי שביצעו פעם אחת פעולת Administrator.
- Task Manager: בלשונית Details לוחצים ימנית על העמודות ומציגים את עמודת Elevated. אם
MyApp.exeמציג No ורקMyApp.AdminBroker.exeמציג Yes — זה כמצופה - Process Explorer: מציגים את עמודת Integrity. נכון אם ה-UI מציג
Mediumוה-helper מציגHigh(integrity level ב-Windows, כמו בפרק 2.1: standard user = medium, elevated = 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. האם UAC prompt מופיע רק בהפעלת ה-helper
- אם ה-prompt מופיע בהפעלת ה-UI — ה-manifest בצד ה-UI אינו
asInvoker - אם הוא מופיע ברגע שלוחצים על ה-checkbox בהגדרות — כמצופה
- אם ההגדרה משתנה בלי ש-prompt מופיע בכלל — ייתכן שה-helper elevated תמיד במסלול אחר
חשבון Administrator נותן consent prompt; standard user נותן credential prompt (הטבלה בפרק 5.6). אם בודקים את שני המסלולים, אפשר לוודא גם שהעברת ה-SID תקינה.
3. האם פעולת הניהול באמת חלה
לרישום התפריט ב-Explorer, הכי מהיר לבדוק ישירות ב-Registry.
reg query "HKLM\SOFTWARE\Classes\*\shell\MyApp.Open" /s
בודקים באותה צורה גם את צד הביטול. אם בודקים רק רישום ולא ביטול, באג בצד DeleteSubKeyTree עלול להישאר.
4. האם זה נכשל כשצריך להיכשל
בלי לבדוק את זה, אי אפשר לדעת אם ההפרדה מחזיקה.
- ביטול ה-elevation prompt — ההגדרה לא משתנה, וגם ה-checkbox ב-UI חוזר למצבו (הטיפול ב-
ERROR_CANCELLED= 1223, פרק 10) - הפעלה ישירה של ה-helper — גם אם מריצים ידנית משהו כמו
MyApp.AdminBroker.exe --pipe x --client-pid 1 --client-sid S-1-5-18, בדיקת PID של הצד שמתחבר וה-timeout מונעים מהעיבוד להתקדם - שליחת operation שלא ב-allowlist — נדחה עם
unsupported_operation(DispatchAsyncמפרק 12)
הפקודות לשלבים מרוכזות באותה זרימה ב-README, “נהלי הבדיקה ב-Windows” של הדוגמה.
16. צורות נפוצות שאסור
16.1 להפוך את כל ה-UI ל-requireAdministrator
רק כפתור אחד במסך ההגדרות דורש הרשאות Administrator, אבל מפעילים הכול elevated. זה כיוון שמוחק ברשלנות את גבול ההרשאות.
16.2 להעביר ל-helper פקודה כ-raw string
למשל תכנון כזה:
UI -> helper מקבל "reg add HKLM\\.... /v ... /d ..."
זה הופך את ה-helper ל-command executor. עדיף לא.
16.3 להשתמש ב-ACL ברירת המחדל של named pipe כמו שהוא
“זה IPC מקומי, אז בטח בסדר” קצת מסוכן. ה-pipe יושב תחת מנגנון האבטחה של Windows, ולכן עדיף לבנות ACL כמו שצריך.
16.4 לקפוץ ישר ל-CurrentUserOnly
זה נראה נוח, אבל הוא לא מתאים ל-UI ב-medium integrity מול helper ב-high integrity במקרה הזה. כאן ACL מפורש נוח יותר לטיפול.
16.5 ה-helper מקבל נתיב שרירותי ופועל עליו
למשל:
- העתקת קובץ שרירותי ל-Program Files
- כתיבת מפתח שרירותי ל-HKLM
- מחיקת שם service שרירותי
- הוספת כלל firewall עם פקודה שרירותית
אם ה-helper מקבל את זה, הוא עצמו הופך לפתח הרצה כללי בהרשאות Administrator. עדיף תמיד לקבע את הפעולה.
17. סיכום
באפליקציית Windows, “רק חלק מהפעולות דורש הרשאות Administrator” אינו סיפור נדיר.
הפתרון אינו “להפוך הכול ל-requireAdministrator”, אלא לחתוך את גבול ההרצה.
הצורה הראשונה שקל לאמץ:
- ה-UI ב-
asInvoker - פעולת הניהול מופרדת ל-helper EXE
- ה-helper ב-
requireAdministrator - ההפעלה עם
runas - התקשורת ב-named pipe
- ה-helper מקבל רק operation קבוע
- מקור החיבור מצטמצם ב-ACL של ה-pipe וב-PID של הלקוח
- הארגומנטים עוברים validation שוב בצד ה-helper
עם הצורה הזו, קל יותר גם לעבור אחר כך ל-Windows Service, אם רוצים. אם מפרידים היטב את חוזה ה-operation, גבול ה-UI ופעולת הניהול הופך בעצמו לנכס תכנון.
flowchart TB
accTitle: הגבול הופך לנכס תכנון
accDescr: תרשים שמראה שאם מפרידים היטב את חוזה ה-operation, הגבול בין ה-UI לפעולת הניהול נהיה ברור, הוא עצמו הופך לנכס תכנון, וקל יותר גם לעבור אחר כך ל-Windows Service.
ctr1["מפרידים את חוזה ה-operation"] --> bd1["הגבול בין UI לפעולת הניהול ברור"]
bd1 --> asset1["הגבול עצמו הופך לנכס תכנון"]
asset1 -.-> future1["קל יותר גם לעבור אחר כך ל-Windows Service"]
איור 19: הגבול שנחתך בסגנון broker נשאר נכס שאפשר להשתמש בו גם במעבר עתידי ל-service.
באבטחה, לא הוספת features בולטים אלא לא להשאיר גבול רשלני — זה מה שמשפיע יותר. כך גם עם הרשאות Administrator. לא מחזיקים את כולן יחד. מוסרים רק את מה שנדרש, כמה שיותר צר. המשמעת המשעממת הזו משפיעה אחר כך.
18. מקורות
שימו לב: בחלק מהקישורים למטה מופיעה בכתובת ה-URL הגדרת גרסה כמו view=net-10.0. זו הגדרה בצד Microsoft Learn לאיזו גרסת .NET מוצג התיעוד, וזה לא אומר שיש חוסר התאמה להנחת היסוד של המאמר — .NET 8. PipeOptions / NamedPipeServerStreamAcl / RegistryView שמופיעים כאן זמינים כולם גם ב-.NET 8. אם רוצים להתאים את התצוגה ל-.NET 8, מחליפים בבורר הגרסה בראש הדף.
- סט קוד הדוגמה השלם של המאמר (ספריית contract משותפת, הדגמה, unit tests) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/windows-admin-broker-deep-dive
- המאמר המקורי: Checklist מינימלי לאבטחת אפליקציות Windows https://comcomponent.com/he/blog/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
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
Secrets ביישומי Windows: DPAPI במקום plaintext ב-config
איך לא לשמור connection strings ו-API tokens ב-plaintext בקובץ config של יישום Windows. המאמר עובר על DPAPI / ProtectedData, על ההבדל בין...
Checklist מינימלי לאבטחת אפליקציות Windows
Checklist לאפליקציות WPF / WinForms / WinUI / C++ / C#: admin rights, code signing, updates, secrets, HTTPS, validation של קלט, טעינת DLL...
Named Pipes בפועל — ה-IPC הסטנדרטי של Windows, מתכנון עד אבטחה
מדריך מעשי ל-Named Pipes, ה-IPC הסטנדרטי ב-Windows. המאמר מסדר לפי מקורות ראשוניים את הבחירה בין byte mode ל-message mode, תכנון server ל...
Exception לא צפוי ב-.NET: מתי מסיימים תהליך ומתי ממשיכים
מתי מסיימים אפליקציית Windows אחרי exception לא צפוי ומתי אפשר להמשיך: לפי state corruption, side effect חיצוני, threads וגבול native.
הרשת עובדת אבל Windows מציג "אין אינטרנט" — לבודד NCSI, DNS, Proxy ו-VPN ב-Windows
למה Windows מציג "אין אינטרנט" בזמן שהרשת עובדת — נקודת המוצא היא קביעת הקישוריות של NCSI. כאן מפרידים בין DNS, proxy, VPN ו-captive port...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
הנושא נוגע בתכנון ההרשאות של אפליקציית Windows שלמה — UAC, helper EXE, ההחלטה מתי לעבור ל-Windows Service, ושינויי configuration ברמת המכונה — ולכן הוא מתאים ל-Windows Custom Software Development.
ייעוץ טכני וסקירת תכנון
אם רוצים לבחון מחדש שימוש קבוע ב-requireAdministrator באפליקציה קיימת, ולסדר מחדש תכנון broker וגבולות IPC, זה נושא שמתאים לייעוץ טכני ול-design review.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- אפשר להריץ רק חלק מהפעולות באותו process בהרשאות Administrator?
- לא. UAC ב-Windows לא עושה elevation ברמת function. הוא נשלט לפי ה-token ו-integrity level שבהם רץ ה-process. process הורה ו-process בן יורשים token באותו integrity level, ולכן אי אפשר לתכנן שרק method מסוים בתוך process UI לא-elevated ירוץ בהרשאות Administrator. את הפעולות שצריך מוציאים ל-execution unit נפרד: process אחר, Windows Service, scheduled task, או elevated COM.
- אילו אפשרויות יש להפרדת פעולות שדורשות הרשאות Administrator?
- Microsoft Learn מציין בעיקר ארבעה מודלים. Administrator Broker Model משלב UI של standard user עם helper EXE בהרשאות Administrator. Operating System Service Model משתמש ב-Windows Service resident. Elevated Task Model משתמש ב-scheduled task בהרשאות Administrator. Administrator COM Object Model משתמש ב-elevated COM. כשהפעולות מזדמנות ומספיק UAC prompt ברגע הצורך — broker EXE. כשזה always-on, unattended ותכוף — service. ל-job קצר וקבוע בכל פעם — scheduled task.
- אפשר להשתמש ב-stdin/stdout מול helper EXE שהופעל עם runas?
- עדיף לא; זה מסורבל. ב-.NET, ProcessStartInfo.Verb עובד רק כש-UseShellExecute=true, ואז אי אפשר לבנות תקשורת על redirect של stdin/stdout. לכן IPC כמו named pipe הוא הדרך הישרה. על ה-pipe לא סומכים על ACL ברירת המחדל: מגדירים PipeSecurity מפורש, מגבילים connect ל-SID של המשתמש שקורא, ובודקים גם את ה-PID של הצד שמתחבר עם GetNamedPipeClientProcessId.
- PipeOptions.CurrentUserOnly על named pipe לא הופך את זה לבטוח?
- זה לא מתאים לתקשורת בין UI לא-elevated ל-helper elevated. ב-Windows, CurrentUserOnly בודק לא רק את חשבון המשתמש אלא גם את רמת ה-elevation, ולכן process-ים ב-integrity level שונה לא מצליחים להתחבר. בנוסף, אצל standard user ה-UAC הופך ל-credential prompt, וה-helper עלול לרוץ תחת חשבון Administrator אחר. ACL מפורש נוח יותר: ה-UI שולף את ה-SID של עצמו, מעביר אותו ל-helper, וה-helper נותן הרשאת connect ל-pipe רק לאותו SID.