UAC ב-Windows: איך מפרידים רק פעולות שדורשות Administrator

· עודכן בתאריך: · · פיתוח 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 נפרד.

elevation היא עניין של גבול processתרשים שמראה שאי אפשר להריץ כ-Administrator רק חלק מהפעולות באותו process, ושכיוון ש-elevation היא גבול process צריך להוציא את הפעולה ל-execution unit נפרד.אי אפשר באותו processמה שאפשררוצים הרשאות Administrator רק לחלק מהפעולותelevation בתוך אותו processמוציאים את הפעולה ל-execution unit נפרד

איור 1: elevation נקבעת ברמת process, לא ברמת function. לכן רק תכנון שמוציא החוצה מחזיק.

במאמר הזה נתקדם בסדר הבא:

  1. קודם ההנחות
  2. איזה מודל הפרדה בוחרים
  3. הצורה הכי מעשית: asInvoker + helper EXE בהרשאות Administrator
  4. מלכודות שלא כדאי לפספס במימוש
  5. דוגמאות קוד

דוגמאות הקוד מניחות .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, ואיפה נשמרת התצורה.

השלד של הפתרון המעשיתרשים שמראה שה-UI נשאר asInvoker, פעולות שדורשות Administrator יוצאות ל-EXE נפרד עם requireAdministrator שמופעל ב-runas, ודרך named pipe מעבירים רק typed request.הפעלה עם runastyped request דרך named pipeה-UI נשאר asInvokerhelper EXE (requireAdministrator)ה-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 לא פותר את זה מעצמו.

UAC נשלט לפי token ו-integrity levelתרשים שמראה ש-UAC לא נשלט ברמת function אלא לפי ה-token ו-integrity level של ה-process, ש-process הורה ובן יורשים את אותה רמה, ולכן elevation ברמת method לא אפשרית ונדרש execution unit נפרד.יחידת השליטה של UACtoken ו-integrity level של ה-processprocess הורה ובן יורשים את אותה רמהאין elevation ברמת methodprocess נפרד / 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 נקראים ישר.

הגבול בין medium ל-highתרשים שמראה ש-UI ב-asInvoker שרץ כ-standard user מקבל medium, ש-helper elevated ב-requireAdministrator מקבל high, ושהתכנון נותן לשניים לדבר דרך גבול מפורש.מדברים דרך גבול מפורשmedium: process UI ב-asInvokerhigh: process helper elevatedtoken של standard usertoken של משתמש 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.

הצורה שבה broker EXE נכנס בנוחותתרשים שמראה שכשפעולת Administrator מיותרת ביום-יום ונדרשת רק בלחיצה על כפתור במסך ההגדרות, ישר יותר להפעיל helper EXE פעם אחת ולסיים מאשר להביא Windows Service resident.בתדירות הזו זה מיותרלחיצה על כפתור ספציפי במסך ההגדרותהפעלת helper EXE פעם אחתהפעולה מסתיימת וה-helper נעלםלהביא Windows Service resident

איור 5: לפעולת Administrator מזדמנת, הכי ישר הוא helper EXE שחי רק ברגע הצורך.

3.2 בוחרים service כשזה always-on, unattended, או תכוף

Windows Service הוא מודל שבו אפליקציית standard user מתקשרת, למשל דרך RPC. היתרון: אפשר לקבל עיבוד בצד הניהול בלי elevation prompt. התמורה: אחריות לתפעל process resident.

ה-trade-off של מודל ה-serviceתרשים שמראה שמודל ה-service מאפשר לקבל עיבוד ניהולי בלי elevation prompt, אבל מוסיף אחריות לתפעל process resident, ומתאים לשימוש always-on, unattended ותכוף.יתרוןתמורהOperating System Service Modelמקבלים עיבוד בלי elevation promptאחריות לתפעל process residentמתאים לשימוש 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”.

מקום השימוש הצר של elevated COMתרשים שמראה ש-elevated COM נראה נוח אבל ה-UI ששולט בו חייב להיות מוצג בצד ה-COM, ולכן זה לא מתאים להפעלה חופשית מ-UI לא-elevated.לא מתאים לכיוון הזהelevated COM נראה נוחהפעלה חופשית מ-UI לא-elevatedמקום השימוש צרה-UI ששולט מוצג בצד ה-COM

איור 7: elevated COM מניח ש-UI השליטה יושב בצד ה-COM. הוא לא מפלט כללי.

4. ההמלצה כאן: UI ב-asInvoker + helper EXE ב-requireAdministrator

מכאן נבנה בפירוט את הצורה הכי מעשית.

מבנה UI ב-medium מול helper ב-highתרשים שמראה ש-UI ב-medium integrity מפעיל ב-runas helper ב-high integrity, שולח typed request דרך named pipe שבודק SID ו-PID, וה-helper מפזר לפי allowlist לפני פעולה על יעד קבוע שדורש Administrator.high integrity — process elevated קצר-חייםmedium integrity — לא-elevated עד הסוףabsolute path + Verb=runas (כאן מופיע UAC prompt)typed request (בלי raw command string)MyApp.AdminBroker.exe (requireAdministrator)named pipe — connect רק ל-SID של משתמש ה-UI, ובודקים גם PIDdispatch לפי allowlist של operation — validation חוזר בצד ה-helperMyApp.exe (asInvoker) — מקבל פעולת משתמש ומרכיב requestיעד קבוע שדורש Administrator — מפתח תחת HKLM / רישום service / כלל firewall

איור 8: גבול ה-elevation מיושר עם גבול ה-process. ה-UI נשאר medium; רק ה-helper רץ high לזמן קצר.

שלוש נקודות:

  1. process ה-UI נשאר לא-elevated עד הסוף
  2. ה-helper בהרשאות Administrator קצר-חיים
  3. ה-helper מקבל רק allowlist קבוע

שמירה על שלוש אלה כבר מסדרת את התכנון.

שלוש הנקודות שכדאי לשמורתרשים שמראה שאם שומרים על UI לא-elevated עד הסוף, helper קצר-חיים, ו-allowlist קבוע בלבד, התכנון מתארגן.ה-UI לא-elevated עד הסוףהתכנון מתארגןה-helper קצר-חייםרק allowlist מתקבל

איור 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-menu
  • install-service
  • add-firewall-rule

מקבעים את הפעולה עצמה, ומצמצמים גם את הארגומנטים לטיפוסים מוגבלים: bool / enum / מספר / מחרוזת מתוחמת.

איך נמנעים מ-generic executorתרשים שמראה שהעברת raw command string או נתיב שרירותי ל-helper יוצרת פתח להרצת הכול, וש-UI שנפרץ גורר את ה-helper, בעוד שקיבוע הפעולה והגבלת הארגומנטים מצמצמים את משמעות ה-helper.מעבירים raw command stringנוצר פתח שמריץ הכולUI שנפרץ גורר גם את ה-helperמקבעים פעולה ומצמצמים ארגומנטים לטיפוסהמשמעות של ה-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. אם משאירים את זה לברירת המחדל, אחר כך מגיעה תקלה שקטה: “בסביבה אחת זה עובד, באחרת לא”.

לכן כאן תמיד מגדירים במפורש.

מה שצריך להגדיר במפורש בהפעלה עם runasתרשים שמראה ש-Verb של ProcessStartInfo עובד רק כש-UseShellExecute הוא true, שברירת המחדל שונה בין .NET Framework ל-.NET, ולכן חייבים להגדיר במפורש.אם סומכים על ברירת המחדללכןרוצים Verb=runasצריך UseShellExecute=trueברירת המחדל שונה בין Framework ל-.NETיש סביבות שעובדות ויש שלאתמיד מגדירים במפורש

איור 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
העברת SID עובדת לשני המסלוליםתרשים שמראה שהצורה היחידה שעובדת גם ב-consent prompt וגם ב-credential prompt היא שה-UI שולף SID ומעביר ל-helper, ה-helper נותן connect רק לאותו SID, ובודקים גם PID של הצד שמתחבר.נדחה בגלל הפרש elevationה-UI שולף SID ומעבירה-helper נותן connect רק לאותו SIDבודקים גם PID של הצד שמתחברשימוש ב-CurrentUserOnlyלא מחזיק במקרה הזה

איור 12: הצורה היחידה שעובדת בשני מסלולי ה-prompt היא ACL שנבנה מה-SID שה-UI מעביר.

5.7 בדיקת PID היא הגנה נוספת מול connect מזדמן

שם pipe אקראי כבר עוזר מאוד, אבל האפשרות ש-process אחר שרץ תחת אותו משתמש יתחבר קודם אינה אפס. לכן בצד ה-helper משתמשים ב-GetNamedPipeClientProcessId כדי לבדוק שה-PID תואם את process ה-UI הצפוי.

כמובן, PID תואם לא אומר שאפשר לסמוך על הכול. אם ה-UI נפרץ, גם בקשות מסוכנות מגיעות ל-helper. בדיוק בגלל זה צריך allowlist של operations בצד ה-helper, ו-validation של הארגומנטים.

הגנה בשכבותתרשים שמראה ששם pipe אקראי, ACL מוגבל ל-SID, בדיקת PID של הצד שמתחבר, ו-allowlist עם validation של ארגומנטים מצטברים בשכבות ומקטינים גם connect מזדמן וגם בקשות מסוכנות.שם pipe אקראיACL מוגבל ל-SIDבדיקת PID של הצד שמתחברallowlist ו-validation חוזר של ארגומנטיםגם אם ה-PID תואם, הבקשה לא נסמכת עליו לבד

איור 13: אף שכבה לבד אינה מושלמת. מצמצמים גם את החיבור וגם את הבקשה בשכבות.

אם מסדרים את הכללים עד כאן לפי הסדר מההפעלה עד הסיום, זה נראה כך. מספיק לקלוט שכל צעד בצד ה-UI מקביל לבדיקה בצד ה-helper.

הרצף מהפעלת ה-helper עד סיום בלי מצב elevatedתרשים רצף שמראה שה-UI מכין SID ו-PID ומפעיל את ה-helper ב-runas, ה-helper יוצר pipe מוגן ב-ACL ובודק PID בחיבור, מקבל typed request, דוחה מה שמחוץ ל-allowlist, פועל על היעד הקבוע, מחזיר תוצאה ל-UI ומסתיים בלי להשאיר מצב elevated.יעד שדורש הרשאות AdministratorAdminBroker.exe(high)Windows / UACMyApp.exe(medium)יעד שדורש הרשאות AdministratorAdminBroker.exe(high)Windows / UACMyApp.exe(medium)קובע שם pipe, ומכין את ה-SID וה-PID של עצמוהפעלה עם absolute path ו-Verb=runasאם אושר, מפעיל עם token elevatedיוצר pipe עם ACL שמתיר רק לאותו SIDמתחבר ל-pipeבודק את PID של הצד שמתחברtyped request (שם operation וארגומנטים)דוחה מה שמחוץ ל-allowlist וארגומנטים לא צפוייםפעולה רק על היעד הקבועמחזיר תוצאהמסתיים בלי להשאיר מצב elevated

איור 14: לכל אחד מהצעדים — הפעלה, חיבור, בקשה — יש בדיקה מקבילה בצד ה-helper. דילוג על אחת מהן הופך את השלב לפתוח.

6. נושא הדוגמה

הפעם ניקח רישום / ביטול פריט בתפריט לחיצה ימנית של Explorer, machine-wide.

הסיבה פשוטה:

  • דורש הרשאות Administrator
  • גבול הפעולה ברור
  • אין צורך להעביר ל-helper raw command string
  • זה גם מצב מציאותי בעבודה

מפתחות היעד לרישום קבועים:

  • HKLM\SOFTWARE\Classes\*\shell\MyApp.Open
  • HKLM\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. פרוטוקול פשוט של בקשה אחת ותשובה אחת פחות נוטה לתקלות.

פרוטוקול פשוט עם length prefixתרשים שמראה שבמקום לזרום JSON חופשי ב-pipe כותבים כותרת אורך ואז את גוף ה-JSON, כך שהצד השני קורא בדיוק את האורך, ופרוטוקול של בקשה אחת ותשובה אחת פחות נוטה לתקלות.כותבים כותרת אורךכותבים את גוף ה-JSONהצד השני קורא בדיוק את האורךפשטות של בקשה אחת ותשובה אחת עוזרת

איור 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.

חלוקת תפקידים בין ארגומנטי ההפעלה ל-pipeתרשים שמראה שארגומנטי ההפעלה של ה-helper מעבירים רק מידע מינימלי — שם pipe, PID ו-SID — ופעולת הניהול עצמה נשארת ב-typed request בתוך ה-pipe.מה שמועברמה שנשאר בפניםארגומנטי ההפעלהמידע מינימלי: שם pipe, PID, SIDבתוך ה-pipeפעולת הניהול כ-typed requestתוכן הפעולה לא עולה על הארגומנטים

איור 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 שעושה הכול”.

סדר הבדיקות שפועלות בצד ה-helperתרשים שמראה שה-helper בונה pipe עם ACL מפורש, אחרי החיבור בודק PID של הצד שמתחבר, ומפזר את ה-request לפי שם operation כך שרק פעולות קבועות עוברות.בתוך ה-allowlistמחוץ ל-allowlistבניית pipe עם ACL מפורשבדיקת PID של הצד שמתחברdispatch לפי שם operationמריצים רק את הפעולה הקבועהדוחים ומשיבים בהתאם

איור 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 בלי ששמו לב” היא תקלה שקשה לראות מקריאת קוד.

ארבעה שלבים לבדיקת ההפרדהתרשים שמראה שבודקים בסדר האם process ה-UI נשאר לא-elevated, האם UAC prompt מופיע רק בהפעלת ה-helper, האם פעולת הניהול באמת חלה, והאם היא נכשלת כשצריך להיכשל.האם ה-UI נשאר לא-elevatedהאם ה-prompt מופיע רק בהפעלת ה-helperהאם הפעולה באמת חלההאם היא נכשלת כשצריךבלי הבדיקה הרביעית ההפרדה לא מאומתת

איור 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 ופעולת הניהול הופך בעצמו לנכס תכנון.

הגבול הופך לנכס תכנוןתרשים שמראה שאם מפרידים היטב את חוזה ה-operation, הגבול בין ה-UI לפעולת הניהול נהיה ברור, הוא עצמו הופך לנכס תכנון, וקל יותר גם לעבור אחר כך ל-Windows Service.מפרידים את חוזה ה-operationהגבול בין UI לפעולת הניהול ברורהגבול עצמו הופך לנכס תכנוןקל יותר גם לעבור אחר כך ל-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

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

פיתוח יישומי Windows

הנושא נוגע בתכנון ההרשאות של אפליקציית Windows שלמה — UAC, helper EXE, ההחלטה מתי לעבור ל-Windows Service, ושינויי configuration ברמת המכונה — ולכן הוא מתאים ל-Windows Custom Software Development.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

אפשר להריץ רק חלק מהפעולות באותו 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.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג