كيفيّة فصل «المعالجات التي تحتاج امتيازات المسؤول فقط» في تطبيقات Windows

· آخر تحديث: · · تطوير Windows, الأمان, UAC, C# / .NET, Win32

سجل التعديلات (3 تحديثات، آخر تحديث 3 Sep، 2026)

سجل بالتغييرات التي أُجريت على هذا المقال. وحيثما حُفظت نسخة سابقة، تبقى متاحة للقراءة عبر رابط دائم يحمل معرّف DOI.

أُضيفت روابط الاستشارة الموجودة في الأصل الياباني (consultation_services). ولم يتغيّر نصّ المقالة نفسه. قراءة النسخة السابقة لهذا التحديث (DOI: 10.5281/zenodo.22240843)
أُعيدَت الترجمة العربية كترجمة كاملة عن النص الياباني الأصلي، وأُضيفَت خريطة المعرفة.
أعيدت الترجمة كترجمة كاملة عن النص الياباني الأصلي. كانت النسخة العربية السابقة مختصراً يسقط أبواباً وجداول ورسوم Mermaid وتعليقات الأشكال وFAQ. أُعيدت هذه العناصر وفق الأصل الياباني، والادّعاءات التقنية مطابقة للنسخة اليابانية.
النشر الأول
الاستشهاد بهذا المقال(DOI: 10.5281/zenodo.21621376)

هذا المقال محفوظ على Zenodo. يرد أدناه معرّف DOI الذي يشير دائمًا إلى أحدث نسخة، ومعرّف DOI المثبَّت على النسخة التي تقرؤها.

小村 豪 (2026). كيفيّة فصل «المعالجات التي تحتاج امتيازات المسؤول فقط» في تطبيقات Windows. شركة كومورا سوفت ذ.م.م.. https://doi.org/10.5281/zenodo.21621376 https://comcomponent.com/ar/blog/2026/03/16/001-windows-admin-broker-deep-dive/

DOI (أحدث نسخة)
10.5281/zenodo.21621376
DOI (هذه النسخة)
10.5281/zenodo.22279794

في مقال سابق، «قائمة تحقّق للحدّ الأدنى من الأمان في تطوير تطبيقات Windows»، كتبتُ الخطّ: اجعل asInvoker أساساً، وافصل المعالجات التي تحتاج امتيازات المسؤول فقط.

هذه المرّة أدخل ذلك الجزء حتّى كيفيّة كتابته فعلاً.

في تطبيقات Windows لا يمكن تنفيذ جزء فقط من المعالجات داخل نفس العمليّة «بصفة المسؤول» كما يحلو لك. الرفع شأن حدود العمليّة، فالمطلوب تصميم «يقطع تلك المعالجة وحدها إلى وحدة تنفيذ أخرى».

الرفع شأن حدود العمليّةمخطّط يبيّن أنّه لا يمكن تنفيذ جزء فقط من المعالجات داخل نفس العمليّة بصفة المسؤول، وأنّ الرفع شأن حدود العمليّة لذا يلزم تصميم يقطع تلك المعالجة وحدها إلى وحدة تنفيذ أخرى.مستحيل داخل نفس العمليّةما يمكن هو هذانريد جزءاً فقط من المعالجات بصفة المسؤولرفع داخل نفس العمليّةقطع المعالجة إلى وحدة تنفيذ أخرى

الشكل 1: الرفع بوحدة العمليّة لا بوحدة الدالّة، لذا لا يقوم إلا تصميم القطع.

يسير هذا المقال بالترتيب التالي.

  1. الفرضيّات أوّلاً
  2. أيّ نموذج فصل نختار
  3. الشكل الأسهل استخداماً في العمل: asInvoker + helper EXE للمسؤول
  4. مطبات لا تريد تجاوزها عند التنفيذ
  5. أمثلة شيفرة ملموسة

أمثلة الشيفرة بفرضيّة .NET 8 / تطبيق سطح مكتب Windows. إطار الواجهة أيّ من WPF / WinForms / WinUI، والفرق يظهر تقريباً في معالج الأحداث في جهة الواجهة فقط.

الشيفرة الواردة هنا منشورة على GitHub كعيّنة كاملة قابلة للبناء والتشغيل (مكتبة عقد مشتركة، وعرض توضيحيّ للواجهة / helper المسؤول، واختبارات وحدة تعمل على Linux أيضاً).

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

كيف تقرأ هذا المقال

المقال طويل، لذا نضع مساراً أوّلاً.

ما تريد معرفته أين تقرأ
اختيار نموذج الفصل فقط الفصول 1〜4 (الخلاصة، مقارنة النماذج الأربعة، الشكل الموصى به)
أحكام التصميم وأسبابها الفصل 5 (allowlist، تثبيت المسار، runas، ACL للـ pipe، تحقّق PID)
شيفرة التنفيذ الفصول 7〜14 (البنية، البيان، العقد المشترك، جهة الواجهة، جهة الـ 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 على نطاق الجهاز. أأمن التجريب على جهاز افتراضيّ للتقييم لا على جهاز تطوير يوميّ

أوامر البناء والتشغيل الملموسة ملخّصة في README العيّنة. ضع في بالك مسبقاً نقطة واحدة: انشر الواجهة والـ helper في نفس المجلّد ثمّ نفّذ (لأنّ الـ helper يحلّ MyApp.exe في نفس مجلّده حلاً ثابتاً).

1. الخلاصة أوّلاً

نرتّب أوّلاً موضع الهبوط في العمل.

  • تطبيق الواجهة العاديّ يعمل كما هو بـ asInvoker
  • المعالجات التي تحتاج امتيازات المسؤول تُقطَع إلى EXE منفصل
  • ذلك الـ helper EXE يُجعَل requireAdministrator
  • الإقلاع بـ runas
  • التواصل مع الـ helper ليس بالإدخال/الإخراج القياسيّ سيّئ التوافق مع runas، بل بـ IPC مثل named pipe
  • ما يُمرَّر إلى الـ helper ليس «سلسلة أوامر خامّة» بل طلبات ذات نوع فقط
  • في جهة الـ helper يُتحقَّق من محتوى الطلب مرّة أخرى
  • جهة اتّصال IPC تُضيَّق بـ SID المستخدم المستدعي و PID المتوقَّع

«أسهل إن عمل بصفة المسؤول» صحيح في المرّة الأولى فقط. لاحقاً، حول UAC والسحب والإفلات وتصميم السجلّ والدخل الخارجيّ وتشغيل الدعم وتحميل DLL وموضع حفظ الإعداد، تُقابَل عادةً بوجه كريه.

هيكل موضع الهبوط في العملمخطّط يبيّن الهيكل: الواجهة تعمل كما هي بـ asInvoker، والمعالجات التي تحتاج امتيازات المسؤول تُقطَع إلى EXE منفصل بـ requireAdministrator وتُقلَع بـ runas، وتُتبادل طلبات ذات نوع فقط عبر named pipe.إقلاع بـ runasطلب ذو نوع عبر named pipeالواجهة تبقى asInvokerhelper EXE (requireAdministrator)إعادة التحقّق من الطلب في جهة الـ helper

الشكل 2: الهيكل يُحسَم بثلاث: واجهة غير مرفوعة + helper مرفوع + IPC لطلبات ذات نوع.

في المخطّط، يشير الخطّ المتّصل إلى علاقة قائمة دائماً، ويشير الخطّ المتقطّع إلى علاقة مشروطة (شروط قيامها مذكورة في شرح كلّ علاقة في الصفحة التفصيليّة). القائمة الكاملة للعلاقات (المجموع 21، مع الأدلّة ودرجة اليقين) وتعريفات المفاهيم الرئيسة مجمّعة في صفحة تفاصيل خريطة المعرفة (باليابانية). البيانات: JSON-LD / Turtle

2. ترتيب الفرضيّة: لا يمكن جعل جزء فقط من نفس العمليّة بصفة المسؤول

UAC في Windows ليس «رفعاً بوحدة الدالّة» بل يُتحكَّم فيه بـ «بأيّ رمز / مستوى سلامة تعمل العمليّة». التطبيق الذي يحتاج رمز وصول المسؤول يصير هدفاً لمطالبة الرفع، وترث عمليّات الأب والابن الرمز بنفس مستوى السلامة. أي أنّ تصميماً ينفّذ دالّة معيّنة فجأة بامتيازات المسؤول داخل عمليّة واجهة غير مرفوعة غير ممكن. إن لزم فاستخدم وحدة تنفيذ أخرى: عمليّة منفصلة أو خدمة أو مهمّة أو COM مرفوع.

إن فُكّر بإسقاط هذه الفرضيّة، صار الاستشارة التصميميّة نوعاً مؤسفاً قليلاً: «نريد أن نصير مسؤولين لحظة الضغط على هذا الزرّ فقط». Windows لا يملأ ذلك بسحر.

UAC يُتحكَّم فيه بالرمز ومستوى السلامةمخطّط يبيّن أنّ UAC ليس رفعاً بوحدة الدالّة بل يُتحكَّم فيه بالرمز ومستوى السلامة اللذين تعمل بهما العمليّة، وأنّ عمليّات الأب والابن ترث الرمز بنفس المستوى فلا يمكن رفع بوحدة الدالّة ويلزم وحدة تنفيذ أخرى.وحدة تحكّم UACرمز العمليّة ومستوى السلامةالأب والابن يرثان نفس المستوىلا يمكن الرفع بوحدة الدالّةعمليّة أخرى أو خدمة أو مهمّة أو COM مرفوع

الشكل 3: ما دامت وحدة التحكّم هي العمليّة، فلا موضع للمعالجة التي تريد رفعها إلا وحدة تنفيذ أخرى.

2.1 ثبّت أوّلاً مقابلة مستوى السلامة (integrity level)

في ما يلي من المقال يظهر لفظا medium integrity / high integrity. نقرّر المقابلة مسبقاً.

مستوى السلامة معناه في هذا المقال مثال
medium عمليّة تعمل كمستخدم قياسيّ تطبيق واجهة asInvoker
high عمليّة مرفوعة helper EXE بـ requireAdministrator

في Mandatory Integrity Control في Windows تُعرَّف أربع درجات: low / medium / high / system، والمستخدم القياسيّ يتلقّى medium، والمستخدم المرفوع high. أي أنّ تصميم هذا المقال حديث عن جعل «عمليّة واجهة medium» و «عمليّة helper high» تتحدّثان بعد رسم خطّ صريح بينهما.

إن استقرّت هذه المقابلة في الذهن، صار حديث CurrentUserOnly في 5.6 وحديث 16.4 أيضاً قابلاً للقراءة مباشرة.

خطّ الفصل بين medium و highمخطّط يبيّن أنّ واجهة asInvoker العاملة كمستخدم قياسيّ تتلقّى medium، وأنّ helper requireAdministrator المرفوع يتلقّى high، وأنّ تصميم المقال حديث عن جعل الاثنين يتحدّثان بعد رسم خطّ صريح.نتحدّث بعد رسم خطّmedium: عمليّة واجهة asInvokerhigh: عمليّة helper مرفوعةرمز المستخدم القياسيّرمز المستخدم المرفوع

الشكل 4: هذا التصميم حديث عن رسم حدود صريحة بين واجهة medium و helper high.

3. أيّ نموذج فصل نختار

في Microsoft Learn تُذكَر أساساً أربعة أساليب لفصل التطبيقات التي تحتاج امتيازات المسؤول.

النموذج الشكل التقريبيّ المشهد المناسب
Administrator Broker Model تطبيق واجهة لمستخدم قياسيّ + helper EXE للمسؤول عمليّات المسؤول متفرّقة ويكفي إظهار UAC في اللحظة اللازمة
Operating System Service Model واجهة مستخدم قياسيّ + خدمة مقيمة وظائف إدارة دائمة التشغيل، ومراقبة في الخلفيّة، ومعالجة بلا مراقبة
Elevated Task Model واجهة مستخدم قياسيّ + مهمّة مجدولة بامتيازات المسؤول معالجة نمطيّة تنتهي قصيرة في كلّ مرّة
Administrator COM Object Model واجهة مستخدم قياسيّ + 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: الخدمة اختيار تحمل تشغيل مقيم مقابل الاستغناء عن المطالبة.

ما يناسب الخدمة استخدامات كهذه.

  • مراقبة دائمة
  • جمع السجلّات
  • تحديث في الخلفيّة
  • ارتباط دائم بجهاز أو عفريت
  • وظيفة إدارة تُشارَك من عدّة جلسات واجهة

3.3 المهمّة تناسب «معالجة نمطيّة قصيرة»

Elevated Task Model شكل يقلع فيه تطبيق المستخدم القياسيّ مهمّة مجدولة تعمل بامتيازات المسؤول. أخفّ من الخدمة وتُغلق عند الانتهاء، فيناسب وظائف نمطيّة لكلّ مرّة.

3.4 COM المرفوع محدود جدّاً

يبدو COM elevation moniker مريحاً، لكن موضع الاستخدام يضيق. حتّى Microsoft Learn يقول إنّ واجهة التحكّم في COM المرفوع يلزم تقديمها في جهة COM، ولا يناسب اتّجاه «ترك COM المرفوع يفعل ما يشاء من واجهة غير مرفوعة».

ضيق موضع استخدام COM المرفوعمخطّط يبيّن أنّ COM المرفوع يبدو مريحاً لكن يلزم تقديم واجهة التحكّم فيه في جهة COM، ولا يناسب اتّجاه ترك COM المرفوع يفعل ما يشاء من واجهة غير مرفوعة.لا يناسب هذا الاتّجاهCOM المرفوع يبدو مريحاًتشغيل حرّ من واجهة غير مرفوعةموضع الاستخدام يضيقواجهة التحكّم تُقدَّم في جهة COM

الشكل 7: COM المرفوع بفرضيّة أنّ واجهة التحكّم في جهة COM، فلا يصير مهرباً عامّاً.

4. الموصى به هذه المرّة: واجهة asInvoker + helper EXE بـ requireAdministrator

من هنا نجعل الشكل الأسهل استخداماً في العمل ملموساً.

high integrity ── عمليّة رفع قصيرة العمرmedium integrity ── لا يُرفَع حتّى النهايةإقلاع بمسار مطلق + Verb=runas(هنا تظهر مطالبة UAC)طلب ذو نوع(لا تُمرَّر سلسلة أوامر خامّة)MyApp.AdminBroker.exe(requireAdministrator)منفذ named pipeمن يُسمَح له بالاتّصال SID مستخدم الواجهة فقطويُطابَق أيضاً PID جهة الاتّصالتوزيع بـ allowlist للعمليّاتوإعادة التحقّق من الوسائط في جهة الـ helperMyApp.exe (asInvoker)يتلقّى تشغيل المستخدم ويجمع الطلب فقطهدف ثابت يحتاج امتيازات المسؤولمفتاح تحت HKLM / تسجيل خدمة / قاعدة جدار ناريّ

الشكل 8: نطابق حدود الرفع بحدود العمليّة. جهة الواجهة تبقى medium، والـ helper وحده يعمل high لوقت قصير

النقاط ثلاث.

  1. عمليّة الواجهة تبقى غير مرفوعة حتّى النهاية
  2. helper المسؤول قصير العمر
  3. العمليّات التي يقبلها الـ helper allowlist ثابتة فقط

بالالتزام بهذه الثلاث وحدها يترتب التصميم كثيراً.

ثلاث نقاط ينبغي الالتزام بهامخطّط يبيّن أنّ الالتزام بثلاث: عمليّة الواجهة تبقى غير مرفوعة حتّى النهاية، و helper المسؤول قصير العمر، والعمليّات التي يقبلها الـ helper allowlist ثابتة فقط، يرتّب التصميم كثيراً.الواجهة غير مرفوعة حتّى النهايةيترتب التصميم كثيراًالـ helper قصير العمرما يُقبَل allowlist فقط

الشكل 9: بالالتزام بغير المرفوع وقصير العمر و allowlist وحدها، يتحدّد شكل حدود الامتياز.

5. قواعد لا تريد تجاوزها في التنفيذ

هذا موضع أولى تقريره قبل كتابة الشيفرة.

5.1 لا تجعل الـ helper «يفعل كلّ شيء»

مثال سيّئ هو هذا.

  • تمرير reg add ... كاملاً كسلسلة من الواجهة إلى الـ helper
  • تمرير sc.exe ... كاملاً كسلسلة من الواجهة إلى الـ helper
  • تمرير مسار سجلّ اعتباطيّ أو مسار EXE اعتباطيّ من الواجهة إلى الـ helper

إن فُعل هذا، إن انكسرت الواجهة انكسر الـ helper معها. helper المسؤول داخل حدود الرفع. إن صُنع هنا «منفذ ينفّذ أيّ شيء» فالأمر خطر جدّاً.

الشكل الحسن كالتالي.

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

ثبّت العمليّة نفسها هكذا، وقرّب الوسائط اللازمة أيضاً إلى bool / enum / عدد / سلسلة محدودة.

الشكل الذي يمنع «يفعل كلّ شيء»مخطّط يبيّن أنّ تمرير سلسلة أوامر خامّة أو مسار اعتباطيّ إلى الـ helper يصنع منفذ تنفيذ لأيّ شيء فتنكسر الواجهة والـ helper معاً، لذا تُثبَّت العمليّة نفسها وتُقرَّب الوسائط إلى أنواع محدودة.تمرير سلسلة أوامر خامّةيُصنَع منفذ ينفّذ أيّ شيءإن انكسرت الواجهة انكسر الـ helperتثبيت العمليّة وتقريب الوسائط إلى أنواع محدودةيضيق معنى الـ helper

الشكل 10: ما يُمرَّر إلى داخل حدود الرفع عمليّة ثابتة ووسائط محدودة فقط.

5.2 مسار الممرَّر إلى الـ helper مطلق، ولا تفرط الواجهة في تقريره

EXE الـ helper نفسه الذي يُقلَع بـ 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 صحيح، وأنّ القيمة الافتراضيّة تختلف بين .NET Framework و .NET، فإن تُرك للافتراضيّ عمل في بيئة ولم يعمل في أخرى، لذا ينبغي التصريح حتماً.إن تُركلذلكنريد استخدام Verb=runasيلزم UseShellExecute=trueالافتراضيّ يختلف بين Framework و .NETتظهر بيئة تعمل وبيئة لا تعملصرّح حتماً

الشكل 11: لشرط عمل Verb واختلاف الافتراضيّ، ثبّت UseShellExecute بالتصريح.

5.4 runas وإعادة توجيه الإدخال/الإخراج القياسيّ سيّئا التوافق

مع UseShellExecute=true يصعب التواصل المفترض على إعادة توجيه الإدخال/الإخراج القياسيّ. لذلك أوضح أن يكون التبادل مع الـ helper IPC آخر مثل named pipe.

5.5 لا تعتمد على ACL الافتراضيّة لـ named pipe

named pipe، بواصف الأمن الافتراضيّ، يدخل حقّ قراءة لـ Everyone وللمجهول في الافتراضيّ. استخدام ذلك كما هو في IPC لـ helper المسؤول مستخفّ جدّاً.

أولى ضبط PipeSecurity صريح حتماً.

5.6 لا تستخدم PipeOptions.CurrentUserOnly لهذا الغرض

هذا يبدو مريحاً من النظرة الأولى. لكن على Windows، CurrentUserOnly يتحقّق لا من حساب المستخدم وحده بل من مستوى الرفع أيضاً. أي أنّه لا يناسب التواصل بين واجهة غير مرفوعة و helper مرفوع.

ثمّ من يتّصل بالـ pipe وبأيّ رمز يتغيّر حسب نوع مطالبة UAC. إن ثُبِّت هذا في جدول سهل تتبّع سبب الحاجة إلى ACL صريحة.

حساب تنفيذ الواجهة مطالبة UAC الظاهرة الحساب الذي يعمل به الـ helper WindowsIdentity.GetCurrent() للـ helper منشئ الـ pipe SID جهة الواجهة المتّصلة
حساب مسؤول (غير مرفوع) مطالبة موافقة (الضغط على «نعم» فقط) رمز رفع لنفس المستخدم نفس مستخدم الواجهة مستخدم الواجهة
مستخدم قياسيّ مطالبة بيانات اعتماد (إدخال بيانات حساب مسؤول آخر) حساب مسؤول آخر أُدخل مستخدم آخر غير الواجهة مستخدم الواجهة

القراءة كالتالي.

  • في الصفّ الأعلى «المستخدم الحالي للـ helper = مستخدم الواجهة»، فإن صنعتَ ACL بالنظر إلى SID الخاصّ بالـ helper فقط اتّصل الأمر مصادفة
  • في الصفّ الأسفل المستخدم الحالي للـ helper ومستخدم الواجهة شخصان مختلفان. إن صنعتَ ACL بالنظر إلى WindowsIdentity.GetCurrent() وحده هنا، تعذّر على مستخدم الواجهة الأصليّ إرسال طلبه
  • في الصفّين كليهما، يُرفَض CurrentUserOnly بفرق مستوى الرفع «واجهة medium» و «helper high»

أي أنّ الشكل الوحيد الذي يقوم في الصفّين هو «استلام SID من جهة الواجهة وإعطاء حقّ الاتّصال لذلك SID».

لذلك هذه المرّة يكون الشكل:

  • في جهة الواجهة الحصول على SID الخاصّ بها وتمريره إلى الـ helper
  • في جهة الـ helper إعطاء حقّ اتّصال الـ pipe لـ SID مستخدم الواجهة فقط
  • ثمّ تأكيد PID جهة الاتّصال أيضاً بـ GetNamedPipeClientProcessId
تمرير SID يجعل المسارين يقومانمخطّط يبيّن الشكل الوحيد الذي يقوم في مطالبة الموافقة ومطالبة بيانات الاعتماد كليهما: جهة الواجهة تحصل على SID الخاصّ بها وتمرّره إلى الـ helper، والـ helper يعطي حقّ اتّصال الـ pipe لذلك SID فقط، ثمّ يؤكّد أيضاً PID جهة الاتّصال.يُرفَض بفرق مستوى الرفعالواجهة تحصل على SID الخاصّ بها وتمرّرهالـ helper يعطي حقّ الاتّصال لذلك SID فقطتأكيد PID جهة الاتّصال أيضاًاستخدام CurrentUserOnlyلا يقوم لهذا الغرض

الشكل 12: ما يقوم في مساري المطالبة كليهما شكل بناء ACL بـ SID الممرَّر من الواجهة فقط.

5.7 تحقّق PID دفاع إضافيّ لتقليل «الدخول الجانبيّ المستخفّ»

اسم pipe عشوائيّ وحده أفضل بكثير، لكن مجال اتّصال عمليّة أخرى تعمل بنفس المستخدم أوّلاً ليس صفراً. لذلك يستخدم الـ helper GetNamedPipeClientProcessId ويؤكّد هل يطابق PID عمليّة الواجهة المتوقَّع.

طبعاً، ليس أنّ تطابق PID يعني الوثوق بأيّ شيء. إن اختُرقت الواجهة وصلت طلبات خطرة إلى الـ helper أيضاً. لذلك يلزم allowlist العمليّات والتحقّق من الوسائط في جهة الـ helper.

دفاع إضافيّ برصف طبقاتمخطّط يبيّن فكرة تقليل الدخول الجانبيّ المستخفّ والطلبات الخطرة كليهما برصف طبقات: اسم pipe عشوائيّ، و ACL مقصورة على SID، ومطابقة PID جهة الاتّصال، و allowlist العمليّات والتحقّق من الوسائط.اسم pipe عشوائيّACL مقصورة على SIDمطابقة PID جهة الاتّصالallowlist وإعادة التحقّق من الوسائطحتّى مع تطابق PID لا نثق بالطلب

الشكل 13: لا طبقة واحدة كاملة، فنضيّق برصف طبقات على الاتّصال والطلب كليهما.

إن رتّبنا القواعد حتّى هنا من الإقلاع إلى الانتهاء، صارت كالتالي. يكفي أن تُقرأ على أنّ كلّ خطوة في جهة الواجهة يقابلها تأكيد في جهة الـ helper.

هدف يحتاج امتيازات المسؤولAdminBroker.exe (high)Windows / UACMyApp.exe (medium)هدف يحتاج امتيازات المسؤولAdminBroker.exe (high)Windows / UACMyApp.exe (medium)نقرّر اسم الـ pipe ونجهّز SID و PID الخاصّين بناإقلاع بمسار مطلق + Verb=runasإن وُوفق يُقلَع برمز مرفوعإنشاء pipe بـ ACL تسمح لذلك SID فقطالاتّصال بالـ pipeمطابقة PID جهة الاتّصالطلب ذو نوع (اسم العمليّة والوسائط)رفض ما خارج allowlist والوسائط غير المتوقَّعةالتشغيل على الهدف الثابت فقطإعادة النتيجةالإنهاء دون إبقاء حالة الرفع

الشكل 14: لكلّ من الإقلاع والاتّصال والطلب تأكيد مقابل في جهة الـ helper. إن أُسقط واحد صار ذلك السلم مفتوحاً

6. موضوع العيّنة

هذه المرّة مثال تسجيل / إلغاء تسجيل قائمة النقر باليمين في Explorer على نطاق الجهاز.

السبب بسيط:

  • تحتاج امتيازات المسؤول
  • حدود العمليّة واضحة
  • لا حاجة لتمرير سلسلة أوامر اعتباطيّة إلى الـ helper
  • وارد كالعادة في العمل

موضع التسجيل مفتاح ثابت كهذا.

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

الواجهة تحمل مربّع اختيار «التسجيل في قائمة النقر باليمين في 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

إن جُعل العقد المشترك مشروعاً منفصلاً، سهل توحيد

  • أسماء العمليّات
  • أنواع request / response
  • شكل رسالة الـ pipe

بين الواجهة والـ helper.

8. البيان (manifest)

8.1 جهة الواجهة (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>

الواجهة 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 المتنالطرف يقرأ بقدر الطول حتماًتنفع بساطة طلب واحد واستجابة واحدة

الشكل 15: الرسالة تُرسَل بطول مرفق وتُضيَّق إلى طلب واحد واستجابة واحدة فيقلّ الحادث.

10. جهة الواجهة: إقلاع الـ helper والتواصل

10.1 MyApp/ElevationBrokerClient.cs

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

namespace MyApp;

public sealed class ElevationBrokerClient
{
    private readonly string _helperExePath;

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

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

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

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

        StartHelper(pipeName, clientPid, clientSid);

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

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

        await pipe.ConnectAsync(connectCts.Token);

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

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

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

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

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

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

        try
        {
            Process.Start(startInfo)
                ?? throw new InvalidOperationException("The helper process could not be started.");
        }
        catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
        {
            throw new OperationCanceledException("أُلغي اعتماد صلاحيات المسؤول.", ex);
        }
    }

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

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

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

ما يُمرَّر هنا إلى الـ helper الحدّ الأدنى اللازم لاسم الـ pipe وتأكيد جهة الاتّصال فقط. عمليّة المسؤول نفسها تُحبَس في طلب ذي نوع يُرسَل داخل الـ pipe.

تقسيم الأدوار بين وسائط الإقلاع والـ pipeمخطّط يبيّن تقسيم الأدوار: وسائط إقلاع الـ helper تحمل الحدّ الأدنى اللازم لاسم الـ pipe وتأكيد جهة الاتّصال فقط، وعمليّة المسؤول نفسها تُحبَس في طلب ذي نوع يُرسَل داخل الـ pipe.ما يُمرَّرما يُحبَسوسائط الإقلاعالحدّ الأدنى: اسم الـ pipe و PID و SIDداخل الـ pipeعمليّة المسؤول في طلب ذي نوعمحتوى العمليّة لا يُحمَل على الوسائط

الشكل 16: الوسائط لترتيب الاتّصال فقط، ومحتوى العمليّة يُغلَق في الـ pipe كطلب ذي نوع. QuoteArgument هذا تنفيذ أدنى يفترض قيماً بسيطة مثل اسم الـ pipe و PID و SID الممرَّرة في هذه العيّنة. إن مرّرتَ مسار Windows اعتباطيّاً أو سلسلة إدخال حرّ كوسيط سطر أوامر، فاستبدله بمعالجة تهريب مخصّصة وفق قواعد تحليل 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 مستخدم الواجهة المستدعي
  • بعد الاتّصال التحقّق من client PID
  • بعد تلقّي الطلب dispatch باسم العمليّة أيضاً

إن جُعل الشكل لا يمرّر إلا عمليّات ثابتة بـ switch (request.Operation)، صعب أن يصير الـ helper «صندوق أيّ شيء مرفوع».

ترتيب التحقّق النافع في جهة الـ helperمخطّط يبيّن ترتيب التحقّق النافع في جهة الـ helper: إنشاء الـ pipe بـ ACL صريحة، والتحقّق من PID العميل بعد الاتّصال، و dispatch الطلب باسم العمليّة فلا يمرّ إلا تشغيل ثابت.داخل allowlistخارج allowlistإنشاء الـ pipe بـ ACL صريحةالتحقّق من PID جهة الاتّصالdispatch باسم العمليّةتنفيذ العمليّة الثابتة فقطالرفض والردّ

الشكل 17: الطلب الذي يعبر ثلاث 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;
    }
}

لبّ هذه الشيفرة فيما لم يُستلَم من الواجهة.

  • لم يُستلَم مسار سجلّ اعتباطيّ من الواجهة
  • لم تُستلَم سلسلة أوامر اعتباطيّة من الواجهة
  • EXE هدف التسجيل يُحلّ ثابتاً في جهة الـ helper
  • محتوى الطلب Enabled فقط

أي أنّ الـ helper جُعل لا يحمل إلا معنى واحداً: «تبديل حالة تسجيل قائمة النقر باليمين في Explorer».

14. مثال الاستدعاء من الواجهة

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;
        }
    }
}

جهة الواجهة عاديّة.

  • قراءة حالة مربّع الاختيار
  • استدعاء عميل الـ broker
  • إرجاع الواجهة عند الفشل

فقط. لا تلمس السجلّ مباشرة. هذا هو الفصل.

15. ما يُمسَك في هذا التنفيذ

الخطّ الذي تحميه هذه العيّنة فعلاً كالتالي.

15.1 فصل مسؤوليّات الواجهة والـ helper

  • الواجهة تتلقّى تشغيل المستخدم فقط
  • الـ helper ينفّذ عمليّة مسؤول ثابتة فقط

15.2 لم يُصنَع «منفذ تنفيذ اعتباطيّ» في الـ helper

  • لا يستقبل مسار سجلّ اعتباطيّ
  • لا يستقبل سطر أوامر اعتباطيّ
  • لا يستقبل مسار EXE اعتباطيّ

15.3 مسار الإقلاع ثابت

  • helper EXE بمسار مطلق
  • runas مصرَّح
  • UseShellExecute = true مصرَّح

15.4 جهة اتّصال IPC مضيَّقة

  • ACL الـ pipe مقصورة على SID مستخدم الواجهة
  • بعد الاتّصال تأكيد client PID

15.5 هدف عمليّة المسؤول ثابت أيضاً

  • hive / path السجلّ ثابت
  • EXE هدف التسجيل يُحلّ ثابتاً أيضاً

إلى هذا الحدّ تبتعد كثيراً عن حالة «إن انكسرت الواجهة استطاع الـ helper أيّ شيء».

15.6 نتأكّد أنّ الفصل تمّ فعلاً

حتّى هنا حديث تصميم. هل ما كُتب مفصول فعلاً لا يُعرَف دون تشغيله والتأكّد. «ارتفعت الواجهة كلّها دون أن ندري» حادث يصعب ملاحظته بقراءة الشيفرة.

تأكيد الفصل على أربع درجاتمخطّط يبيّن تدفّق التأكيد بالترتيب: هل عمليّة الواجهة غير مرفوعة كما هي، وهل تظهر مطالبة UAC عند إقلاع الـ helper فقط، وهل نفعت عمليّة المسؤول فعلاً، وهل يفشل حين ينبغي أن يفشل.هل الواجهة غير مرفوعة كما هيهل تظهر المطالبة عند إقلاع الـ helper فقطهل نفعت العمليّة فعلاًهل يفشل حين ينبغي أن يفشلدون الرابعة لا يصير تأكيد الفصل

الشكل 18: إن نُظرت الدرجات الأربع بالترتيب عند التشغيل، التُقط تسرّب رفع لا يُلاحَظ بالشيفرة وحدها.

التأكيد ينظر إلى الأربع التالية بالترتيب.

1. هل عمليّة الواجهة غير مرفوعة كما هي

أهمّ نقطة. أقلع الواجهة وتأكّد بعد تنفيذ عمليّة المسؤول مرّة.

  • مدير المهامّ: في علامة التبويب «تفاصيل» انقر يميناً على الأعمدة واعرض عمود «مرفوع». إن كان MyApp.exe «لا» و MyApp.AdminBroker.exe وحده «نعم» فهذا المتوقَّع
  • Process Explorer: اعرض عمود Integrity. إن كانت الواجهة Medium والـ helper High فهذا الصحيح (مستوى السلامة في Windows كما في 2.1: مستخدم قياسيّ = medium، مرفوع = high)

إن أردت النظر من الشيفرة، يكفي تأكيد مرّة واحدة فور إقلاع الواجهة.

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 فقط

  • تظهر المطالبة عند إقلاع الواجهة -> بيان جهة الواجهة ليس asInvoker
  • تظهر لحظة الضغط على مربّع اختيار الإعداد -> كما يُتوقَّع
  • لا تظهر المطالبة مرّة ومع ذلك يتغيّر الإعداد -> يحتمل أن يكون الـ helper مرفوعاً دائماً بمسار آخر

إن كان حساب مسؤول فمطالبة موافقة، وإن كان مستخدماً قياسيّاً فمطالبة بيانات اعتماد (جدول 5.6). جرّب الاثنين فتؤكّد أيضاً أنّ تمرير SID صحيح.

3. هل نفعت عمليّة المسؤول فعلاً

لتسجيل قائمة Explorer، أسرع النظر مباشرة إلى السجلّ.

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

جهة الإلغاء تُؤكَّد بالمثل. إن جُرِّب التسجيل وحده دون الإلغاء بقي عيب في جهة DeleteSubKeyTree.

4. هل «يفشل حين ينبغي أن يفشل»

دون تأكيد هذا لا يُعرَف هل تمّ الفصل.

  • إلغاء مطالبة الرفع -> الإعداد لا يتغيّر ويعود مربّع اختيار الواجهة أيضاً (معاملة ERROR_CANCELLED = 1223. الفصل 10)
  • إقلاع الـ helper مباشرة -> حتّى إن طُرق يدوياً مثل MyApp.AdminBroker.exe --pipe x --client-pid 1 --client-sid S-1-5-18 لا تتقدّم المعالجة بتحقّق PID جهة الاتّصال وانتهاء المهلة
  • إرسال عمليّة ليست في allowlist -> تُرفَض بـ unsupported_operation (DispatchAsync في الفصل 12)

أوامر الإجراءات الملموسة ملخّصة بنفس التدفّق في «إجراءات التأكيد على Windows» في README العيّنة.

16. أخطاء شائعة

16.1 جعل الواجهة كلّها requireAdministrator

زرّ واحد في شاشة الإعداد يحتاج امتيازات المسؤول، ومع ذلك يُقلَع الكلّ مرفوعاً. هذا اتّجاه يسحق حدود الامتياز باستخفاف.

16.2 تمرير سلسلة أوامر خامّة إلى الـ helper

مثلاً تصميم كهذا.

UI -> helper に "reg add HKLM\\.... /v ... /d ..."

هذا يجعل الـ helper منفّذ أوامر. الأولى التوقّف.

16.3 استخدام ACL الافتراضيّة لـ named pipe كما هي

«IPC محلّيّ إذن لا بأس» خطر قليلاً. الـ pipe هدف أمن Windows، فأولى صنع ACL كما ينبغي.

16.4 القفز إلى CurrentUserOnly

يبدو مريحاً، لكنّه لا يناسب واجهة medium integrity ↔ helper high integrity هذه المرّة. هنا ACL صريحة أسهل معاملة.

16.5 الـ helper يستقبل مساراً اعتباطيّاً ويشغّل

مثلاً مثل:

  • نسخ ملفّ اعتباطيّ إلى Program Files
  • كتابة مفتاح اعتباطيّ إلى HKLM
  • حذف اسم خدمة اعتباطيّ
  • إضافة قاعدة جدار ناريّ بأمر اعتباطيّ

إن قبله الـ helper صار الـ helper نفسه منفذ تنفيذ عامّاً بامتيازات المسؤول. العمليّة ينبغي تثبيتها حتماً.

17. الخلاصة

«جزء فقط من المعالجات يحتاج امتيازات المسؤول» في تطبيقات Windows ليس حديثاً نادراً. لكن حلّه ليس «جعل الكلّ requireAdministrator» بل قطع حدود التنفيذ.

ما يسهل تناوله أوّلاً هذا الشكل.

  • الواجهة asInvoker
  • معالجة المسؤول تُفصَل إلى helper EXE
  • الـ helper requireAdministrator
  • الإقلاع runas
  • التواصل named pipe
  • الـ helper لا يقبل إلا عمليّة ثابتة
  • جهة الاتّصال تُضيَّق بـ ACL الـ pipe و client PID
  • الوسائط يُعاد التحقّق منها في جهة الـ helper

إن جُعل بهذا الشكل، سهل الانتقال لاحقاً حين تريد التحويل إلى خدمة. إن فُصل عقد العمليّة كما ينبغي، صارت حدود الواجهة ومعالجة المسؤول كما هي أصلاً تصميميّاً.

الحدود تصير أصلاً تصميميّاًمخطّط يبيّن أنّ فصل عقد العمليّة كما ينبغي يجعل حدود الواجهة ومعالجة المسؤول واضحة فتصير الحدود كما هي أصلاً تصميميّاً ويسهل الانتقال لاحقاً إلى خدمة.فصل عقد العمليّة مسبقاًحدود الواجهة ومعالجة المسؤول واضحةتصير الحدود كما هي أصلاً تصميميّاًيسهل أيضاً الانتقال إلى خدمة

الشكل 19: الحدود المقطوعة بنوع broker تصير أصلاً يُستخدم كما هو أيضاً عند التحويل إلى خدمة مستقبلاً.

حديث الأمن أنجع فيه عدم إبقاء حدود مستخفّة من إضافة وظائف لامعة. امتيازات المسؤول كذلك. لا تُحمَل كلّها جملة، بل تُمرَّر حيث تلزم، بأضيق قدر ممكن. هذا القدر من الهدوء يفعل فعله لاحقاً.

18. روابط مرجعية

بعض الروابط التالية تحمل في عنوان URL تعييناً للإصدار مثل view=net-10.0. هذا تعيين أيّ إصدار .NET تعرضه وثائق Microsoft Learn، ولا يعني تعارضاً مع فرضيّة هذا المقال .NET 8. PipeOptions / NamedPipeServerStreamAcl / RegistryView المستخدمة هنا متاحة كلّها في .NET 8. إن أردت موافقة العرض مع .NET 8 فبدّل من منتقي الإصدار أعلى الصفحة.

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

تطوير تطبيقات ويندوز

لأنه يمسّ تصميم الصلاحيات في تطبيق Windows بأكمله، بما يشمل UAC وhelper EXE وتقدير الحاجة إلى التحويل إلى خدمة وتغيير الإعدادات على مستوى الجهاز (machine-wide)، فهو موضوع يتناسب مع تطوير تطبيقات Windows.

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

هل يمكن تنفيذ جزء فقط من المعالجات داخل نفس العمليّة بامتيازات المسؤول؟
لا. UAC في Windows ليس رفعاً بوحدة الدالّة، بل يُتحكَّم فيه بالرمز ومستوى السلامة اللذين تعمل بهما العمليّة. ترث عمليّات الأب والابن الرمز بنفس مستوى السلامة، لذا يستحيل تصميم ينفّذ دالّة معيّنة فقط بامتيازات المسؤول داخل عمليّة واجهة غير مرفوعة. المعالجات اللازمة تُقطَع إلى وحدة تنفيذ أخرى: عمليّة منفصلة أو خدمة أو مهمّة مجدولة أو COM مرفوع.
ما خيارات فصل المعالجات التي تحتاج امتيازات المسؤول؟
Microsoft Learn يذكر أساساً أربعة نماذج. Administrator Broker Model الذي يجمع واجهة مستخدم قياسيّة مع 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 مثل named pipe مع الـ helper. لا تعتمد على ACL الافتراضيّة للـ pipe؛ اضبط PipeSecurity صريحاً، قصّر حقّ الاتّصال على SID المستخدم المستدعي، ثمّ تحقّق أيضاً من PID جهة الاتّصال بـ GetNamedPipeClientProcessId.
أليس استخدام PipeOptions.CurrentUserOnly على named pipe كافياً للأمان؟
لا يناسب التواصل بين واجهة غير مرفوعة و helper مرفوع. CurrentUserOnly على Windows يتحقّق لا من حساب المستخدم وحده بل من مستوى الرفع أيضاً، فلا يمكن الاتّصال بين عمليّتين بمستويي سلامة مختلفين. ثمّ في بيئة مستخدم قياسيّ قد يصير UAC مطالبة ببيانات اعتماد، فيعمل الـ helper بحساب مسؤول آخر. أوضح أن تحصل جهة الواجهة على SID الخاصّ بها وتمرّره إلى الـ helper، وأن يعطي الـ helper حقّ اتّصال الـ pipe لذلك SID وحده عبر ACL صريحة.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

العودة إلى المدونة