איך כותבים בפועל הפרדה של "רק תהליכים שדורשים הרשאות מנהל" ביישום Windows

· עודכן בתאריך: · · פיתוח Windows, אבטחה, UAC, C# / .NET, Win32

במאמר הקודם, “רשימת בדיקה מינימלית לאבטחה בפיתוח יישומי Windows”, כתבנו את הקו: להתבסס על asInvoker, ולהפריד רק תהליכים שדורשים הרשאות מנהל.

הפעם ניכנס עד לאיך בפועל כותבים את החלק הזה.

ביישום Windows, לא ניתן להריץ בנוחות רק חלק מהתהליכים באותו תהליך “כמנהל”. ההעלאה היא עניין של גבול תהליך, ולכן מה שנדרש הוא “תכנון שמוציא רק את אותו תהליך ליחידת הרצה נפרדת”.

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

איור 1: ההעלאה נקבעת ברמת התהליך ולא ברמת הפונקציה, ולכן רק תכנון שמוציא לחוץ אפשרי.

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

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

דוגמאות הקוד מניחות ‎.NET 8 / יישום שולחני ל-Windows. תשתית ה-UI יכולה להיות כל אחת מ-WPF /‏ WinForms /‏ WinUI, וההבדל היחיד שנוצר הוא בערך ב-handler-ים של אירועים בצד ה-UI.

בנוסף, הקוד שמופיע במאמר הזה זמין כסט קוד לדוגמה שלם שאפשר לבנות ולהריץ (ספריית חוזה משותפת, הדגמה של UI / helper מנהל, ובדיקות יחידה שרצות גם ב-Linux) ב-GitHub.

windows-admin-broker-deep-dive - komurasoft-blog-samples (GitHub)

איך קוראים את המאמר הזה

מאמר ארוך, ולכן נציג קודם מפת ניווט.

מה רוצים לדעת מה לקרוא
רוצים לדעת רק איך בוחרים מודל הפרדה פרקים 1〜4 (מסקנה, השוואת ארבעת המודלים, הצורה המומלצת)
החלטות תכנון והנימוקים שלהן פרק 5 (‏allowlist, קיבוע נתיב,‏ runas,‏ ACL של pipe, אימות PID)
קוד המימוש פרקים 7〜14 (הרכב, מניפסט, חוזה משותף, צד UI, צד helper)
איך בודקים שההפרדה תקינה בפועל 15.6
צורות שאסור לעשות פרק 16

הקוד המלא נמצא גם בדוגמה ב-GitHub באותה צורה. אם רוצים לקטוף רק את דיוני התכנון — פרקים 1〜5 ו-15〜16, ואם רוצים גם את המימוש — לקרוא ברצף.

מה מכינים לפני שמנסים

כדי להריץ בפועל את הדוגמה הזו, צריך את הבאים:

  • מחשב Windows (הנחיית ההעלאה של UAC,‏ ACL מפורש דרך PipeSecurity,‏ GetNamedPipeClientProcessId, וכתיבה ל-HKLM — כל אלה ייעודיים ל-Windows)
  • ‎.NET 8 SDK ומעלה
  • חשבון שיכול לאשר העלאה. חשבון מנהל יקבל consent prompt, ומשתמש רגיל יקבל credential prompt. אם רוצים לבדוק את שני המסלולים, מכינים את שני סוגי החשבונות
  • מחשב שמותר לשנות בו את HKLM. הדוגמה יוצרת ברמת המכונה את HKLM\SOFTWARE\Classes\*\shell\MyApp.Open. בטוח יותר לנסות במחשב וירטואלי להערכה, ולא במחשב הפיתוח הרגיל

הפקודות הקונקרטיות ל-build והרצה מרוכזות ב-README של הדוגמה. רק נקודה אחת כדאי להכניס לראש מראש: מפרסמים (publish) את ה-UI וה-helper לאותה תיקייה ורק אז מריצים (כי ה-helper פותר בקיבוע את MyApp.exe שבאותה תיקייה כמוהו).

1. קודם המסקנה

נסדר קודם את הפתרון המעשי.

  • יישום UI רגיל ממשיך לרוץ ב-asInvoker
  • תהליך שדורש הרשאות מנהל מוצא ל-EXE נפרד
  • אותו helper EXE הופך ל-requireAdministrator
  • ההפעלה מתבצעת עם runas
  • התקשורת עם ה-helper משתמשת ב-pipe בעל שם או IPC דומה, ולא בקלט/פלט סטנדרטי שלא מתאים ל-runas
  • מה שמעבירים ל-helper הוא לא “מחרוזת פקודה גולמית”, אלא רק בקשה מוגדרת-טיפוס
  • בצד ה-helper, מאמתים שוב את תוכן הבקשה
  • מקור ה-IPC מוגבל לפי ה-SID של המשתמש הקורא וה-PID הצפוי

“נוח כשהכול רץ כמנהל” נכון רק בפעם הראשונה. בהמשך, זה נוטה לגרום לפרצוף עצוב סביב UAC, גרירה ושחרור, תכנון לוגים, קלט חיצוני, תפעול תמיכה, טעינת DLL, ומיקום שמירת תצורה.

השלד של הפתרון המעשיתרשים המראה שה-UI נשאר asInvoker, תהליך שדורש הרשאות מנהל מוצא ל-EXE נפרד עם requireAdministrator שמופעל עם runas, ומעבירים דרך pipe בעל שם רק בקשה מוגדרת-טיפוס.הפעלה עם runasבקשה מוגדרת-טיפוס דרך pipe בעל שםה-UI נשאר asInvokerhelper EXE (requireAdministrator)ה-helper מאמת שוב את הבקשה

איור 2: השלד נקבע בשלוש נקודות — UI לא-מועלה + helper מועלה + IPC של בקשה מוגדרת-טיפוס.

מפת הידע של המאמר

המאמר הזה עוסק במימוש קונקרטי של הפרדת רק הפעולות הדורשות הרשאות מנהל ביישום Windows. ‏UAC נשלט ברמת ה-integrity של התהליך, ותהליך צאצא יורש token באותה רמה כמו תהליך ההורה, ולכן אי אפשר להעלות בהרשאה רק חלק מאותו תהליך — ומכאן שהצורה הבסיסית היא Administrator Broker Model, שמשלב UI של משתמש רגיל עם helper EXE בעל הרשאות מנהל. הפעלת ה-helper נעשית עם runas, והתקשורת בין ה-UI ל-helper מתבצעת דרך pipe בעל שם עם PipeSecurity מפורש במקום הסתמכות על ACL ברירת המחדל; אימות ה-PID של הצד המתחבר, יחד עם allowlist בצד ה-helper שמקבל רק פעולות קבועות מראש, סוגרים את הפתח להרצת פקודה שרירותית. ‏PipeOptions.CurrentUserOnly בודק גם הבדל ברמת ה-integrity, ולכן אינו מתאים לשימוש הזה.

מפת הידע של הפרדת עיבוד הרשאות מנהל ביישוםתרשים המראה, במגבלה שמטילה רמת ה-integrity של UAC, מדוע Administrator Broker Model מחבר UI של משתמש רגיל ל-helper EXE של מנהל דרך pipe בעל שם, ושומר על הגבול באמצעות ACL, אימות PID ו-allowlist של פעולות — יחד עם ההשוואה למודלי הפרדה אחרים.משתמש במשתמש במחייבמחייבמשתמש במשתמש במוגדר באמצעותעלול לגרום למצמצםמצמצםמשתמש במשתמש במונעמענה מומלץ למענה מומלץ למענה מומלץ למענה מומלץ לאינו מתיישב עםמחייבמחייבמחייבAdministrator Broker Model‏UAC (בקרת חשבון משתמש)רמת שלמות (integrity level)הרשאות מנהל‏requestedExecutionLevel (הצהרת רמת ההרצה)הפעלה עם runas‏ (ShellExecute Verb)‏named pipe (צינור בעל שם)‏ACL מפורש באמצעות PipeSecurityהתחברות בלתי מורשית לצינוראימות ה-PID של הצד המתחבררשימת הפעולות המותרות ל-helperפרצה להרצת פקודות שרירותיותפעולת מנהל מזדמנתOperating System Service Modelפעולת מנהל רציפה, תכופה וללא נוכחות אדםElevated Task Modelמשימת מנהל קצרה ושגרתיתAdministrator COM Object Modelשילוב המבוסס על COM קייםPipeOptions.CurrentUserOnly

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 21, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle

2. סידור הנחות היסוד: לא ניתן להפוך רק חלק מאותו תהליך למנהל

ה-UAC של Windows נשלט לא לפי “העלאה ברמת פונקציה”, אלא לפי “באיזה טוקן / רמת שלמות רץ התהליך”. יישום שדורש טוקן גישה של מנהל הופך למועמד להנחיית העלאה, ותהליך אב ותהליך בן יורשים את הטוקן באותה רמת שלמות. כלומר, לא ניתן לתכנן להריץ פתאום רק שיטה מסוימת בהרשאות מנהל בתוך תהליך UI לא-מועלה. אם צריך, משתמשים ביחידת הרצה נפרדת — תהליך נפרד, שירות, משימה, COM מועלה וכדומה.

אם מתעלמים מההנחה הזו, מתקבלת פנייה תכנונית קצת עצובה: “רוצים להפוך למנהל רק ברגע שלוחצים על הכפתור הזה”. Windows לא ממלא את הפער הזה בקסם.

UAC נשלט לפי טוקן ורמת שלמותתרשים המראה ש-UAC לא נשלט ברמת פונקציה אלא לפי הטוקן ורמת השלמות שבהם רץ התהליך, ותהליך אב ותהליך בן יורשים את אותה רמה, ולכן לא ניתן להעלות ברמת שיטה ונדרשת יחידת הרצה נפרדת.יחידת השליטה של UACטוקן ורמת שלמות של התהליךתהליך אב ובן יורשים את אותה רמהלא ניתן להעלות ברמת שיטהתהליך נפרד / שירות / משימה / COM מועלה

איור 3: כיוון שיחידת השליטה היא התהליך, התהליך שרוצים להעלות חייב לשבת ביחידת הרצה נפרדת.

2.1 קודם מקבעים את ההתאמה של רמת השלמות (integrity level)

במאמר הזה יופיעו מכאן והלאה הביטויים medium integrity /‏ high integrity. נקבע קודם את ההתאמה.

רמת שלמות המשמעות במאמר הזה דוגמה
medium תהליך שרץ כמשתמש רגיל יישום UI עם asInvoker
high תהליך שהועלה helper EXE עם requireAdministrator

ב-Mandatory Integrity Control של Windows מוגדרות ארבע רמות: low /‏ medium /‏ high /‏ system, ו-משתמש רגיל מקבל medium, ומשתמש שהועלה מקבל high. כלומר, התכנון של המאמר הזה הוא לתת ל-“תהליך UI ברמת medium” ול-“תהליך helper ברמת high” לשוחח דרך גבול מפורש.

אם ההתאמה הזו נמצאת בראש, גם הדיון ב-CurrentUserOnly בפרק 5.6 וגם 16.4 נקראים בטבעיות.

קו הגבול בין medium ל-highתרשים המראה שה-UI ברמת asInvoker שרץ כמשתמש רגיל מקבל medium, וה-helper המועלה ברמת requireAdministrator מקבל high, ושהתכנון של המאמר הזה נותן לשניים לשוחח דרך גבול מפורש.שיחה דרך גבול מפורשmedium: תהליך UI ב-asInvokerhigh: תהליך helper מועלהטוקן של משתמש רגילטוקן של משתמש מועלה

איור 4: התכנון הזה מותח גבול מפורש בין ה-UI ברמת medium ל-helper ברמת high.

3. איזה מודל הפרדה בוחרים

ב-Microsoft Learn מפורטים בעיקר ארבעה אופנים להפרדת יישום שדורש הרשאות מנהל.

מודל הצורה הגסה מתי מתאים
Administrator Broker Model יישום UI של משתמש רגיל + helper EXE מנהל הפעולה המנהלית מזדמנת, ומספיק להוציא UAC רק ברגע הנדרש
Operating System Service Model UI של משתמש רגיל + שירות קבוע תכונת ניהול תמידית, ניטור ברקע, עיבוד ללא נוכחות אדם
Elevated Task Model UI של משתמש רגיל + משימה מתוזמנת בהרשאות מנהל תהליך מוגדר-מראש שמסתיים מהר בכל פעם
Administrator COM Object Model UI של משתמש רגיל + COM מועלה יש כבר תכנון COM קיים, והפונקציונליות מוגבלת למדי

הקווים המנחים לבחירה:

3.1 broker EXE הוא הראשון שקל לבחון

broker EXE נכנס בנוחות לפעולות מהסוג הבא:

  • רישום / ביטול שילוב עם Explorer
  • שינוי תצורה ברמת המכונה תחת HKLM
  • רישום / ביטול שירות של היישום עצמו
  • הוספה / הסרה של כלל חומת אש
  • פעולת מנהל תחת Program Files

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

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

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

3.2 בוחרים שירות כש”תמיד”, “ללא נוכחות אדם”, “תכוף”

שירות הוא מודל שבו יישום משתמש רגיל מתקשר, למשל, דרך RPC. היתרון הוא שאפשר לקבל עיבוד בצד הניהול בלי הנחיית העלאה, אבל בתמורה, האחריות לתפעל תהליך קבוע גדלה.

הסחר של מודל השירותתרשים המראה שמודל השירות מציע יתרון של קבלת עיבוד בצד הניהול בלי הנחיית העלאה, אבל בתמורה מגדיל את האחריות לתפעל תהליך קבוע, ומתאים לשימוש תמידי, ללא נוכחות אדם ותכוף.יתרוןתמורהOperating System Service Modelקבלת עיבוד בלי הנחיית העלאהאחריות לתפעל תהליך קבועמתאים לשימוש תמידי, ללא נוכחות אדם ותכוף

איור 6: השירות מקבל בתמורה להיעדר הנחיה את האחריות לתפעל הרצה קבועה.

שירות מתאים לשימושים כמו:

  • ניטור תמידי
  • איסוף לוגים
  • עדכון ברקע
  • שילוב תמידי עם מכשיר או daemon
  • תכונת ניהול שמשותפת בין כמה פגישות UI

3.3 משימה מתאימה ל”תהליך קצר מוגדר-מראש”

Elevated Task Model הוא צורה שבה יישום משתמש רגיל מפעיל משימה מתוזמנת שרצה בהרשאות מנהל. היא קלה יותר משירות, ונסגרת כשהיא מסתיימת, ולכן היא מתאימה לעבודה מוגדרת-מראש שקורית פעם בכל פעם.

3.4 COM מועלה מוגבל מאוד

‏COM elevation moniker נראה נוח, אבל מקום השימוש בו מצומצם. גם ב-Microsoft Learn כתוב שה-UI שיכול לשלוט ב-COM המועלה צריך להיות מוצג בצד ה-COM, וזה לא מתאים לכיוון “לתת ל-UI לא-מועלה לעשות מה שרוצים ב-COM מועלה”.

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

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

4. ההמלצה של המאמר הזה: UI מסוג asInvoker + helper EXE מסוג requireAdministrator

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

מבנה ה-UI ברמת medium מול ה-helper ברמת highתרשים המראה ש-UI ברמת medium integrity מפעיל עם runas את ה-helper ברמת high integrity, שולח לו בקשה מוגדרת-טיפוס דרך נקודת קצה של pipe בעל שם המאמתת SID ו-PID, וה-helper מבצע חלוקה לפי allowlist לפני שהוא פועל על היעד הקבוע שדורש הרשאות מנהל.high integrity — תהליך מועלה קצר-חייםmedium integrity — לא מתעלה עד הסוףהפעלה עם נתיב מוחלט ו-Verb=runas — כאן מופיעה הנחיית UACבקשה מוגדרת-טיפוס — לא מעבירים מחרוזת פקודה גולמיתMyApp.AdminBroker.exe (requireAdministrator)נקודת קצה של pipe בעל שם — מאפשרת חיבור רק ל-SID של משתמש ה-UI, ומאמתת גם PID של הצד המתחברחלוקה לפי allowlist של operation — הארגומנטים מאומתים שוב בצד ה-helperMyApp.exe (asInvoker) — מקבל את פעולת המשתמש ורק מרכיב את הבקשהיעד קבוע שדורש הרשאות מנהל — מפתח תחת HKLM / רישום שירות / כלל חומת אש

איור 8: גבול ההעלאה מתאים בדיוק לגבול התהליך. ה-UI נשאר ברמת medium, ורק ה-helper רץ ברמת high לזמן קצר.

שלוש נקודות מרכזיות:

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

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

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

איור 9: שמירה על שלושת אלה — לא-מועלה, קצר-חיים, allowlist — קובעת את צורת גבול ההרשאות.

5. כללים שלא כדאי לפספס במימוש

כאן כדאי להחליט לפני כתיבת הקוד.

5.1 לא הופכים את ה-helper ל”כל-יכול”

הדוגמה השגויה נראית כך:

  • ה-UI מעביר ל-helper reg add ... כמחרוזת שלמה
  • ה-UI מעביר ל-helper sc.exe ... כמחרוזת שלמה
  • ה-UI מעביר ל-helper נתיב רישום מערכת שרירותי או נתיב EXE שרירותי

אם עושים את זה, אם ה-UI נשבר, גם ה-helper נשבר יחד איתו. ה-helper המנהל נמצא בתוך גבול ההעלאה. יצירת “פתח שאפשר להריץ בו הכול” כאן מסוכנת מאוד.

הצורה הטובה נראית כך:

  • set-explorer-context-menu
  • install-service
  • add-firewall-rule

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

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

איור 10: מה שמעבירים אל תוך גבול ההעלאה הוא רק פעולה קבועה וארגומנטים מוגבלים.

5.2 הנתיב שמעבירים ל-helper — absolute, ולא מוחלט מדי מצד ה-UI

את ה-helper EXE עצמו, שמופעל עם runas, מציינים בנתיב מוחלט. נמנעים מחיפוש ב-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 והפניית קלט/פלט סטנדרטי לא מסתדרים

כש-UseShellExecute=true, קשה יותר להשתמש בתקשורת שמניחה הפניית קלט/פלט סטנדרטי. לכן טבעי יותר להשתמש ב-IPC אחר, כמו named pipe, לתקשורת עם ה-helper.

5.5 pipe בעל שם לא נשען על ACL ברירת מחדל

ב-pipe בעל שם, תיאור האבטחה בברירת המחדל מכיל בברירת מחדל זכות קריאה ל-Everyone ולאלמוני. שימוש ישיר בזה לצורך ה-IPC של helper מנהל הוא די רשלני.

בטוח יותר להגדיר תמיד PipeSecurity מפורש.

5.6 לא משתמשים ב-PipeOptions.CurrentUserOnly לצורך הזה

זה נראה נוח במבט ראשון. אבל ב-Windows, CurrentUserOnly בודק לא רק את חשבון המשתמש, אלא גם את רמת ההעלאה. כלומר, הוא לא מתאים לתקשורת בין UI לא-מועלה ל-helper מועלה.

בנוסף, מי מתחבר ל-pipe עם איזה טוקן משתנה לפי סוג הנחיית ה-UAC. אם מקבעים את זה בטבלה, קל יותר לעקוב אחר הסיבה שנדרש ACL מפורש (explicit).

חשבון ההרצה של ה-UI הנחיית UAC שמופיעה חשבון שה-helper רץ תחתיו WindowsIdentity.GetCurrent() של ה-helper שיוצר את ה-pipe ה-SID של צד ה-UI שמתחבר
חשבון מנהל (לא-מועלה) consent prompt (רק לחיצה על “כן”) טוקן מועלה של אותו משתמש אותו משתמש כמו ה-UI משתמש ה-UI
משתמש רגיל credential prompt (הזנת אישורים של חשבון אחר) חשבון מנהל אחר שהוזן משתמש שונה מה-UI משתמש ה-UI

איך קוראים את זה:

  • בשורה העליונה, “המשתמש הנוכחי של ה-helper = משתמש ה-UI”, ולכן גם אם ה-helper יוצר ACL רק לפי ה-SID של עצמו, זה מתחבר במקרה
  • בשורה התחתונה, המשתמש הנוכחי של ה-helper ומשתמש ה-UI הם אנשים שונים. אם יוצרים כאן ACL רק לפי WindowsIdentity.GetCurrent(), משתמש ה-UI המקורי לא יוכל לשלוח את בקשתו
  • בשתי השורות, CurrentUserOnly נדחה בגלל ההבדל ברמת ההעלאה בין “UI ברמת medium” ל-“helper ברמת high”

כלומר, הצורה היחידה שמתקיימת בשתי השורות היא “מקבלים את ה-SID מצד ה-UI, ונותנים זכות התחברות לאותו SID”.

לכן, הפעם:

  • ה-UI מקבל את ה-SID של עצמו ומעביר אותו ל-helper
  • ה-helper נותן זכות התחברות ל-pipe רק ל-SID של משתמש ה-UI
  • ובנוסף, מאמת גם את PID מקור ההתחברות עם GetNamedPipeClientProcessId

בצורה כזו.

העברת SID מספקת גם עבור שני המסלוליםתרשים המראה שהצורה היחידה שמתקיימת גם ב-consent prompt וגם ב-credential prompt היא שה-UI מקבל את ה-SID של עצמו ומעביר אותו ל-helper, וה-helper נותן זכות התחברות רק לאותו SID, ומאמת גם את PID מקור ההתחברות.נדחה בגלל הבדל ברמת ההעלאהה-UI מקבל את ה-SID שלו ומעבירה-helper נותן זכות חיבור רק לאותו SIDמאמת גם את PID מקור ההתחברותשימוש ב-CurrentUserOnlyלא מתקיים בשימוש הזה

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

5.7 אימות PID הוא הגנה נוספת לצמצום “חדירה רשלנית”

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

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

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

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

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

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

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

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

הפעם ניקח דוגמה של רישום / ביטול פריט בתפריט לחיצה ימנית של Explorer, ברמת המכונה.

הסיבה פשוטה:

  • דורש הרשאות מנהל
  • גבול הפעולה ברור
  • אין צורך להעביר ל-helper מחרוזת פקודה שרירותית
  • זה גם מצב מציאותי לגמרי בעבודה מעשית

מפתחות המטרה לרישום קבועים כאלה:

  • HKLM\SOFTWARE\Classes\*\shell\MyApp.Open
  • HKLM\SOFTWARE\Classes\*\shell\MyApp.Open\command

ה-UI מחזיק רק תיבת סימון של “רישום בתפריט לחיצה ימנית של Explorer”, ופעולת הרישום עצמה בצד ה-helper.

7. הרכב הפתרון

MyApp/
  MyApp/                         יישום UI (asInvoker)
    app.manifest
    ElevationBrokerClient.cs
    SettingsPage.xaml.cs
  MyApp.AdminBroker/             helper מנהל (requireAdministrator)
    app.manifest
    Program.cs
    BrokerLaunchOptions.cs
    ExplorerContextMenuRegistration.cs
  MyApp.BrokerProtocol/          חוזה משותף
    BrokerProtocol.cs

אם מוציאים את החוזה המשותף לפרויקט נפרד, קל יותר להתאים בין ה-UI וה-helper את:

  • שם ה-operation
  • סוגי ה-request /‏ response
  • פורמט ההודעה ב-pipe

8. המניפסטים

8.1 צד ה-UI (‏MyApp/app.manifest)

<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
  <assemblyIdentity version="1.0.0.0" name="MyApp.app" />
  <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
    <security>
      <requestedPrivileges>
        <requestedExecutionLevel level="asInvoker" uiAccess="false" />
      </requestedPrivileges>
    </security>
  </trustInfo>
</assembly>

8.2 צד ה-helper (‏MyApp.AdminBroker/app.manifest)

<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
  <assemblyIdentity version="1.0.0.0" name="MyApp.AdminBroker.app" />
  <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
    <security>
      <requestedPrivileges>
        <requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
      </requestedPrivileges>
    </security>
  </trustInfo>
</assembly>

ה-UI תמיד asInvoker. רק ה-helper הוא requireAdministrator. אם הופכים את זה, המשמעות של ההפרדה שהשגנו נעלמת.

9. קוד החוזה המשותף

9.1 MyApp.BrokerProtocol/BrokerProtocol.cs

using System.Buffers.Binary;
using System.Text.Json;

namespace MyApp.BrokerProtocol;

public static class BrokerJson
{
    public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };
}

public static class BrokerOperations
{
    public const string SetExplorerContextMenu = "set-explorer-context-menu";
}

public sealed record BrokerRequest(string Operation, JsonElement Payload);

public sealed record BrokerResponse(bool Success, string? ErrorCode, string? Message)
{
    public static BrokerResponse Ok(string? message = null) => new(true, null, message);

    public static BrokerResponse Fail(string errorCode, string message) =>
        new(false, errorCode, message);
}

public sealed record SetExplorerContextMenuRequest(bool Enabled);

public static class PipeMessageSerializer
{
    private const int MaxPayloadBytes = 256 * 1024;

    public static async Task WriteAsync<T>(Stream stream, T value, CancellationToken cancellationToken)
    {
        byte[] payload = JsonSerializer.SerializeToUtf8Bytes(value, BrokerJson.Options);
        if (payload.Length > MaxPayloadBytes)
        {
            throw new InvalidDataException($"Payload is too large: {payload.Length} bytes.");
        }

        byte[] header = new byte[sizeof(int)];
        BinaryPrimitives.WriteInt32LittleEndian(header, payload.Length);

        await stream.WriteAsync(header.AsMemory(0, header.Length), cancellationToken);
        await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancellationToken);
        await stream.FlushAsync(cancellationToken);
    }

    public static async Task<T> ReadAsync<T>(Stream stream, CancellationToken cancellationToken)
    {
        byte[] header = await ReadExactAsync(stream, sizeof(int), cancellationToken);
        int payloadLength = BinaryPrimitives.ReadInt32LittleEndian(header);

        if (payloadLength <= 0 || payloadLength > MaxPayloadBytes)
        {
            throw new InvalidDataException($"Invalid payload length: {payloadLength}");
        }

        byte[] payload = await ReadExactAsync(stream, payloadLength, cancellationToken);

        return JsonSerializer.Deserialize<T>(payload, BrokerJson.Options)
            ?? throw new InvalidDataException($"Failed to deserialize {typeof(T).FullName}.");
    }

    private static async Task<byte[]> ReadExactAsync(Stream stream, int length, CancellationToken cancellationToken)
    {
        byte[] buffer = new byte[length];
        int offset = 0;

        while (offset < length)
        {
            int read = await stream.ReadAsync(buffer.AsMemory(offset, length - offset), cancellationToken);
            if (read == 0)
            {
                throw new EndOfStreamException("Pipe was closed before the expected number of bytes was read.");
            }

            offset += read;
        }

        return buffer;
    }
}

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

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

איור 15: ההודעה נשלחת עם אורך מצורף, ופרוטוקול של בקשה אחת ותגובה אחת מצמצם תקלות.

10. צד ה-UI: הפעלת ה-helper ותקשורת

10.1 MyApp/ElevationBrokerClient.cs

using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO.Pipes;
using System.Security.Principal;
using System.Text.Json;
using MyApp.BrokerProtocol;

namespace MyApp;

public sealed class ElevationBrokerClient
{
    private readonly string _helperExePath;

    public ElevationBrokerClient(string helperExePath)
    {
        _helperExePath = Path.GetFullPath(helperExePath);

        if (!Path.IsPathRooted(_helperExePath))
        {
            throw new ArgumentException("Helper executable path must be absolute.", nameof(helperExePath));
        }

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

    public async Task SetExplorerContextMenuEnabledAsync(bool enabled, CancellationToken cancellationToken = default)
    {
        string pipeName = $"myapp-broker-{Guid.NewGuid():N}";
        int clientPid = Environment.ProcessId;
        string clientSid = GetCurrentUserSid();

        StartHelper(pipeName, clientPid, clientSid);

        using var pipe = new NamedPipeClientStream(
            serverName: ".",
            pipeName: pipeName,
            direction: PipeDirection.InOut,
            options: PipeOptions.Asynchronous);

        using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        connectCts.CancelAfter(TimeSpan.FromSeconds(30));

        await pipe.ConnectAsync(connectCts.Token);

        BrokerRequest request = new(
            BrokerOperations.SetExplorerContextMenu,
            JsonSerializer.SerializeToElement(
                new SetExplorerContextMenuRequest(enabled),
                BrokerJson.Options));

        await PipeMessageSerializer.WriteAsync(pipe, request, cancellationToken);

        BrokerResponse response = await PipeMessageSerializer.ReadAsync<BrokerResponse>(pipe, cancellationToken);

        if (!response.Success)
        {
            throw new InvalidOperationException(
                $"Admin broker returned an error. Code={response.ErrorCode}, Message={response.Message}");
        }
    }

    private void StartHelper(string pipeName, int clientPid, string clientSid)
    {
        string workingDirectory = Path.GetDirectoryName(_helperExePath)
            ?? throw new InvalidOperationException("Helper executable directory could not be resolved.");

        var startInfo = new ProcessStartInfo
        {
            FileName = _helperExePath,
            Arguments = BuildArguments(pipeName, clientPid, clientSid),
            WorkingDirectory = workingDirectory,
            UseShellExecute = true,
            Verb = "runas"
        };

        try
        {
            Process.Start(startInfo)
                ?? throw new InvalidOperationException("The helper process could not be started.");
        }
        catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
        {
            throw new OperationCanceledException("管理者権限の承認がキャンセルされました。", ex);
        }
    }

    private static string GetCurrentUserSid()
    {
        using WindowsIdentity identity = WindowsIdentity.GetCurrent();
        return identity.User?.Value
            ?? throw new InvalidOperationException("Current user SID could not be resolved.");
    }

    private static string BuildArguments(string pipeName, int clientPid, string clientSid)
    {
        return string.Join(
            " ",
            "--pipe",
            QuoteArgument(pipeName),
            "--client-pid",
            clientPid.ToString(CultureInfo.InvariantCulture),
            "--client-sid",
            QuoteArgument(clientSid));
    }

    private static string QuoteArgument(string value)
    {
        return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
    }
}

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

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

איור 16: הארגומנטים משמשים רק לסידור החיבור, ותוכן הפעולה נכלא כ-request מוגדר-טיפוס ב-pipe.

ה-QuoteArgument הזה הוא מימוש מינימלי שמניח ערכים פשוטים כמו שם ה-pipe,‏ PID ו-SID שמועברים בדוגמה הזו. אם מעבירים נתיב שרירותי של Windows או מחרוזת קלט חופשי לארגומנט שורת פקודה, יש להחליף בטיפול 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, ברגע שהארגומנט חסר / יש ארגומנט מיותר, נגרמת שגיאה. בתוך גבול ההעלאה, עדיף לא “לפרש בכל מחיר”.

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 להפוך ל”תיבה מועלית שעושה הכול”.

סדר האימותים שפועלים בצד ה-helperתרשים המראה שה-helper בונה pipe עם ACL מפורש, מאמת אחרי ההתחברות את PID הצד המתחבר, ומבצע dispatch של ה-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 נתיב רישום מערכת שרירותי
  • לא מקבל מה-UI מחרוזת פקודה שרירותית
  • ה-EXE שנרשם נפתר בקיבוע בצד ה-helper
  • תוכן ה-request הוא רק Enabled

כלומר, ה-helper מוגבל למשמעות אחת בלבד: “החלפת מצב הרישום של תפריט לחיצה ימנית ב-Explorer”.

14. דוגמת קריאה מה-UI

14.1 MyApp/SettingsPage.xaml.cs

using System.Windows;

namespace MyApp;

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

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

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

צד ה-UI רגיל.

  • קורא את מצב תיבת הסימון
  • קורא ל-broker client
  • אם נכשל, מחזיר את ה-UI

זהו בלבד. לא נוגע ישירות ברישום המערכת. זו ההפרדה.

15. מה שהמימוש הזה שומר עליו

הקווים שהדוגמה הזו באמת שומרת עליהם:

15.1 הפרדת אחריות בין UI ל-helper

  • ה-UI רק מקבל את פעולת המשתמש
  • ה-helper רק מבצע פעולת ניהול קבועה

15.2 לא נוצר ל-helper “פתח הרצה חופשי”

  • לא מקבל נתיב רישום מערכת שרירותי
  • לא מקבל שורת פקודה שרירותית
  • לא מקבל נתיב EXE שרירותי

15.3 מסלול ההפעלה קבוע

  • ה-helper EXE בנתיב מוחלט
  • runas מוגדר במפורש
  • UseShellExecute = true מוגדר במפורש

15.4 מקור התחברות ה-IPC מוגבל

  • ACL של ה-pipe מוגבל ל-SID של משתמש ה-UI
  • ‏PID הצד המתחבר נבדק אחרי ההתחברות

15.5 גם היעד של פעולת הניהול קבוע

  • ה-hive / הנתיב של הרישום קבועים
  • גם ה-EXE שנרשם נפתר בקיבוע

עם רמת ההקפדה הזו, מתרחקים מאוד ממצב שבו “אם ה-UI נשבר, אפשר לעשות עם ה-helper הכול”.

15.6 בודקים אם ההפרדה תקינה בפועל

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

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

איור 18: הרצה ובדיקה של ארבעת השלבים בסדר תופסות פרצות העלאה שקוד בלבד לא חושף.

הבדיקה מתבצעת לפי ארבעת השלבים הבאים, בסדר.

1. האם תהליך ה-UI נשאר לא-מועלה

הנקודה החשובה ביותר. מפעילים את ה-UI, ובודקים אחרי שביצעו פעם אחת פעולת ניהול.

  • מנהל המשימות: בלשונית “פרטים”, לוחצים ימנית על העמודות ומציגים את עמודת “העלאה”. אם MyApp.exe מציג “לא” ורק MyApp.AdminBroker.exe מציג “כן”, זה כמצופה
  • Process Explorer: מציגים את עמודת ה-Integrity. נכון אם ה-UI מציג Medium וה-helper מציג High (רמות השלמות של Windows, כפי שבפרק 2.1, הן: משתמש רגיל = medium, מועלה = high)

אם רוצים לראות מהקוד, מספיקה בדיקה חד-פעמית מיד אחרי הפעלת ה-UI.

using System.Security.Principal;

using WindowsIdentity identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);

// בתהליך ה-UI זה אמור להיות false
bool isElevatedAdmin = principal.IsInRole(WindowsBuiltInRole.Administrator);

2. האם הנחיית UAC מופיעה רק בהפעלת ה-helper

  • אם ההנחיה מופיעה בהפעלת ה-UI ← המניפסט בצד ה-UI לא asInvoker
  • אם היא מופיעה ברגע שלוחצים על תיבת הסימון בהגדרות ← כמצופה
  • אם ההגדרה משתנה בלי שההנחיה מופיעה אף פעם ← ייתכן שה-helper מועלה תמידית במסלול אחר

חשבון מנהל נותן consent prompt, ומשתמש רגיל נותן credential prompt (הטבלה בפרק 5.6). אם בודקים את שני המסלולים, אפשר לוודא גם אם העברת ה-SID תקינה.

3. האם פעולת הניהול באמת פעלה

לרישום התפריט ב-Explorer, הכי מהיר לבדוק ישירות ברישום המערכת.

reg query "HKLM\SOFTWARE\Classes\*\shell\MyApp.Open" /s

בודקים באותה צורה גם את צד הביטול. אם בודקים רק את הרישום ולא את הביטול, באג בצד DeleteSubKeyTree עלול להישאר.

4. האם זה “נכשל כשצריך להיכשל”

בלי לבדוק את זה, לא ניתן לדעת אם ההפרדה תקינה.

  • ביטול הנחיית ההעלאה ← ההגדרה לא משתנה, וגם תיבת הסימון ב-UI חוזרת למצבה (הטיפול ב-ERROR_CANCELLED = 1223, פרק 10)
  • הפעלה ישירה של ה-helper ← גם אם מריצים ידנית משהו כמו MyApp.AdminBroker.exe --pipe x --client-pid 1 --client-sid S-1-5-18, אימות PID מקור ההתחברות וה-timeout מונעים מהעיבוד להתקדם
  • שליחת operation שלא ב-allowlist ← נדחה עם unsupported_operation (‏DispatchAsync מפרק 12)

הפקודות הקונקרטיות לשלבים מרוכזות באותה זרימה ב-README, “נהלי הבדיקה ב-Windows” של הדוגמה.

16. תקלות נפוצות

16.1 להפוך את כל ה-UI ל-requireAdministrator

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

16.2 להעביר ל-helper פקודה כמחרוזת גולמית

לדוגמה, תכנון כזה:

UI -> helper מקבל "reg add HKLM\\.... /v ... /d ..."

זה הופך את ה-helper ל-command executor. כדאי להימנע מזה.

16.3 להשתמש ב-ACL ברירת המחדל של pipe בעל שם כמו שהוא

“זה IPC מקומי, אז בטח בסדר” קצת מסוכן. ה-pipe נמצא תחת מנגנון האבטחה של Windows, ולכן בטוח יותר ליצור ACL כראוי.

16.4 לקפוץ ישר ל-CurrentUserOnly

זה נראה נוח, אבל הוא לא מתאים ל-UI ברמת medium integrity מול helper ברמת high integrity הפעם. כאן, ACL מפורש (explicit) נוח יותר לטיפול.

16.5 ה-helper מקבל נתיב שרירותי ומבצע עליו פעולה

לדוגמה, כמו אלה:

  • העתקת קובץ שרירותי ל-Program Files
  • כתיבת מפתח שרירותי ל-HKLM
  • מחיקת שם שירות שרירותי
  • הוספת כלל חומת אש עם פקודה שרירותית

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

17. סיכום

ביישום Windows, “רק חלק מהתהליכים דורש הרשאות מנהל” זה לא סיפור נדיר. עם זאת, הפתרון הוא לא “להפוך הכול ל-requireAdministrator”, אלא לחתוך את גבול ההרצה.

הצורה הראשונה שקל לאמץ היא זו:

  • ה-UI ב-asInvoker
  • תהליך הניהול מופרד ל-helper EXE
  • ה-helper ב-requireAdministrator
  • ההפעלה עם runas
  • התקשורת ב-named pipe
  • ה-helper מקבל רק operation קבוע
  • מקור ההתחברות מוגבל ב-ACL של ה-pipe וב-PID הלקוח
  • הארגומנטים מאומתים שוב בצד ה-helper

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

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

איור 19: הגבול שנחתך בסגנון broker נשאר נכס שמשמש גם כשעוברים בעתיד לשירות.

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

18. מקורות

שימו לב, בחלק מהקישורים למטה מופיעה בכתובת ה-URL הגדרת גרסה כמו view=net-10.0. זו הגדרה בצד Microsoft Learn עבור איזו גרסת ‎.NET מוצג התיעוד, וזה לא אומר שיש חוסר התאמה להנחת היסוד של המאמר הזה — ‎.NET 8. ‏PipeOptions /‏ NamedPipeServerStreamAcl /‏ RegistryView שמופיעים כאן זמינים כולם גם ב-‎.NET 8. אם רוצים להתאים את התצוגה ל-‎.NET 8, אפשר להחליף בבורר הגרסה שבראש הדף.

  • סט קוד הדוגמה השלם של המאמר הזה (ספריית חוזה משותפת, הדגמה, בדיקות יחידה) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/windows-admin-broker-deep-dive
  • המאמר המקורי: רשימת בדיקה מינימלית לאבטחה בפיתוח יישומי Windows https://comcomponent.com/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

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

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

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

שאלות נפוצות

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

אפשר להריץ רק חלק מהתהליכים באותו תהליך בהרשאות מנהל?
לא. ה-UAC של Windows לא נשלט ברמת פונקציה, אלא נקבע לפי הטוקן ורמת השלמות שבהם רץ התהליך. תהליך אב ותהליך בן יורשים טוקן באותה רמת שלמות, ולכן אי אפשר לתכנן שרק שיטה מסוימת בתוך תהליך UI לא מועלה תרוץ בהרשאות מנהל. תהליכים שדרושים מוציאים ליחידת הרצה נפרדת — תהליך נפרד, שירות, משימה מתוזמנת, או COM מועלה.
אילו אפשרויות יש להפרדת תהליכים שדורשים הרשאות מנהל?
ב-Microsoft Learn מפורטים בעיקר ארבעה מודלים: Administrator Broker Model שמשלב UI של משתמש רגיל עם helper EXE מנהל, Operating System Service Model שמשתמש בשירות קבוע, Elevated Task Model שמשתמש במשימה מתוזמנת בהרשאות מנהל, ו-Administrator COM Object Model שמשתמש ב-COM מועלה. כשהפעולה המנהלית מזדמנת וצריך את ה-UAC רק ברגע הנדרש, מתאים broker EXE. כשזה תמידי, ללא נוכחות אדם ותכוף, מתאים שירות. לתהליך מוגדר-מראש שמסתיים מהר בכל פעם, מתאימה משימה.
אפשר להשתמש בקלט/פלט סטנדרטי לתקשורת עם helper EXE שהופעל עם runas?
מומלץ להימנע, כי זה מסובך לשימוש. ב-‎.NET,‏ ProcessStartInfo.Verb תקף רק כש-UseShellExecute=true, וכש-UseShellExecute=true, לא ניתן להשתמש בתקשורת שמניחה הפניית קלט/פלט סטנדרטי. לכן טבעי להשתמש ב-IPC כמו pipe בעל שם לתקשורת עם ה-helper. ה-pipe לא נשען על ACL ברירת מחדל, אלא מגדירים PipeSecurity מפורש, מגבילים את זכות ההתחברות ל-SID של המשתמש הקורא, ומאמתים גם את PID מקור ההתחברות עם GetNamedPipeClientProcessId.
האם השימוש ב-PipeOptions.CurrentUserOnly בפייפ בעל שם לא בטוח?
הוא לא מתאים לתקשורת בין UI לא-מועלה ל-helper מועלה. ה-CurrentUserOnly של Windows בודק לא רק את חשבון המשתמש, אלא גם את רמת ההעלאה, ולכן לא ניתן להתחבר בין תהליכים ברמות שלמות שונות. בנוסף, בסביבת משתמש רגיל, UAC הופך ל-credential prompt, וה-helper עלול לרוץ תחת חשבון מנהל אחר. ACL מפורש שבו ה-UI מקבל את ה-SID של עצמו, מעביר אותו ל-helper, וה-helper מעניק זכות התחברות ל-pipe רק לאותו SID — נוח יותר לטיפול.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג