كيف تعزل العمل الذي يحتاج إلى المسؤول فقط داخل تطبيقات Windows

· · تطوير Windows, الأمان, UAC, C# / .NET, Win32

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

هذا المقال يأخذ تلك القاعدة أبعد ويحوّلها إلى شيفرة.

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

أودّ هنا أن أمرّ على:

  1. القيد الأساسيّ
  2. كيف تختار نموذج الفصل
  3. الشكل الأكثر عمليّة لكثير من تطبيقات سطح المكتب: واجهة asInvoker مع helper EXE مرفَّع
  4. الأخطاء التي تستحقّ التجنّب أثناء التنفيذ
  5. أمثلة شيفرة محدَّدة

تفترض الأمثلة .NET 8 على سطح مكتب Windows.
سواء أكانت الواجهة WPF أم WinForms أم WinUI، فإنّ التصميم الأساسيّ لا يتغيّر كثيراً.

1. النسخة المختصرة

في الممارسة العمليّة، يبدو الخطّ الأساسيّ عادةً هكذا:

  • أبقِ عمليّة الواجهة العاديّة عند asInvoker
  • انقل العمل الحصريّ للمسؤول إلى EXE منفصل
  • ضع علامة على ذلك الـ helper EXE بأنّه requireAdministrator
  • ابدأه بـ runas
  • استخدم named pipes أو آليّة IPC حقيقيّة أخرى بدلاً من الإدخال/الإخراج القياسيّ
  • أرسِل طلبات ذات نوع (typed requests)، لا سلاسل أوامر خام
  • تحقّق من الطلب مرّة أخرى في جانب الـ helper
  • أحكِم اتّصال IPC حسب SID المستخدم المتّصل والـ PID المتوقّع

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

2. القيد الأساسيّ: لا يمكنك ترفيع جزء فقط من عمليّة واحدة

UAC ليس نظام «ترفيع على مستوى الدالّة».
إنّه يدور حول أيّ token وأيّ integrity level تعمل به العمليّة نفسها.

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

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

«هل تستطيع هذه الزرّ الواحد أن تعمل بصلاحيّات المسؤول بينما يبقى بقيّة التطبيق عاديّاً؟»

Windows لا يقدّم سحراً لذلك.

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

توثِّق Microsoft عدّة أنماط شائعة للتطبيقات التي تحتاج إلى صلاحيّات المسؤول:

النموذج الشكل العامّ أنسب استخدام
Administrator Broker Model واجهة المستخدم القياسيّ + helper EXE مرفَّع عمليّات إداريّة عَرَضيّة يمكن أن تطلق UAC عند الحاجة فقط
Operating System Service Model واجهة المستخدم القياسيّ + service ميزات إدارة دائمة العمل، ومراقبة في الخلفيّة، وأعمال غير مأهولة
Elevated Task Model واجهة المستخدم القياسيّ + scheduled task مرفَّعة عمليّات قصيرة قابلة للتكرار تبدأ وتنتهي بنظافة
Administrator COM Object Model واجهة المستخدم القياسيّ + COM object مرفَّع تصاميم قائمة كثيفة الاعتماد على COM ذات حدّ ميزات ضيّق

عادةً ما يبدو الاختيار العمليّ هكذا.

3.1 الـ broker EXE هو غالباً أسهل خطوة أولى

أمثلة نموذجيّة:

  • تسجيل أو إزالة تكامل Explorer
  • تغيير إعدادات على مستوى الجهاز ضمن HKLM
  • تثبيت أو إزالة خدمة التطبيق
  • إضافة أو حذف قواعد الجدار الناريّ
  • كتابة شيء ضمن Program Files

غالباً ما يكون هذا أشياء يقوم بها المستخدم من حين لآخر فقط من صفحة الإعدادات.
لذلك النوع من الأعباء، فإنّ helper EXE مرفَّعاً قصير العمر يكون أنظف عادةً من إدخال service دائم.

3.2 الخدمة منطقيّة عندما يكون العمل دائماً أو غير مأهول

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

تلك المقايضة أكثر معقوليّة لحالات مثل:

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

3.3 المهامّ مناسبة للأعمال القصيرة الثابتة

نموذج المهمّة المرفَّعة أخفّ من الخدمة ويلائم طبيعيّاً الأعمال لمرّة واحدة التي تبدأ وتقوم بالعمل وتنتهي.

3.4 COM المرفَّع أضيق ممّا يبدو لأوّل وهلة

قد يبدو COM elevation moniker جذّاباً، لكنّه عادةً أكثر تخصّصاً ممّا يظهر.
إن كان التطبيق تطبيق سطح مكتب اعتيادياً دون تصميم قويّ يتمحور حول COM موجود مسبقاً، فنادراً ما يكون أسهل مكان للبدء منه.

4. الشكل الموصى به هنا: واجهة asInvoker مع helper EXE من نوع requireAdministrator

بالنسبة لكثير من تطبيقات سطح المكتب، تكون البنية التالية افتراضيّاً جيّداً:

[ MyApp.exe ]  asInvoker
      |
      |  ShellExecute / ProcessStartInfo + Verb=runas
      v
[ MyApp.AdminBroker.exe ]  requireAdministrator
      |
      |  named pipe
      v
[ عمليّة ثابتة حصريّة للمسؤول ]

ثلاث نقاط هي الأهمّ:

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

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

5. قواعد تستحقّ الحسم قبل التنفيذ

من الأسهل ضبط هذه قبل أن تنتشر الشيفرة.

5.1 لا تجعل الـ helper منفّذاً عامّاً للأوامر

تبدو الأنماط السيّئة هكذا:

  • ترسل الواجهة reg add ... بوصفها سلسلة خام كاملة
  • ترسل الواجهة sc.exe ... بوصفها سلسلة خام كاملة
  • ترسل الواجهة مسارات سجلّ عشوائيّة أو مسارات executable عشوائيّة

ما إن يحدث ذلك، حتّى يصبح الـ helper صندوق «افعل أيّ شيء» ذا امتيازات.

نمط أفضل هو جعل العمليّة نفسها ثابتة:

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

والإبقاء على المعاملات محصورة في أمور مثل booleans، أو enums، أو أرقام، أو سلاسل ضيّقة القيود.

5.2 استخدم مسارات مطلقة، وتجنّب أن تقرّرها الواجهة كثيراً

ينبغي الإشارة إلى الـ helper EXE المُشغَّل عبر runas بـ مسار مطلق.
لا تعتمد على البحث في PATH أو على المسارات النسبيّة.

من الأفضل أيضاً أن يحلّ الـ helper هدفه الفعليّ محلّيّاً بدلاً من الوثوق بالواجهة كثيراً.
في النموذج أدناه، يجري حلّ الـ EXE المسجَّل لقائمة Explorer السياقيّة من قِبَل الـ helper بوصفه MyApp.exe في المجلّد نفسه.

5.3 إن استخدمتَ Verb="runas"، فاضبط UseShellExecute=true صراحةً

في .NET، يعمل ProcessStartInfo.Verb فقط عندما يكون UseShellExecute=true.
كما يختلف الافتراضيّ بين .NET Framework وأوقات تشغيل .NET الأحدث.

ترك ذلك للقيم الافتراضيّة طريقة جيّدة للحصول على فشل لاحق يعتمد على الإصدار.

5.4 لا يلائم runas والإدخال/الإخراج القياسيّ المعاد توجيهه بعضهما البعض

ما إن يصبح UseShellExecute=true مطلوباً، يتوقّف إعادة توجيه الإدخال/الإخراج القياسيّ عن كونه خياراً جيّداً للنقل.
ذلك أحد أسباب أنّ named pipes ملاءمتها أكثر طبيعيّة لهذا التصميم.

5.5 لا تثق بـ ACLs الافتراضيّة على named pipes

named pipes هي كائنات أمان حقيقيّة في Windows.
إن كان الـ helper المرفَّع يكشف نقطة طرفيّة IPC، فإنّ الاعتماد على ACLs الافتراضيّة فضفاض أكثر ممّا يريح.

ابنِ أمان pipe صراحةً.

5.6 PipeOptions.CurrentUserOnly ليس الإجابة هنا

يبدو هذا الخيار واعداً لأوّل وهلة، لكنّه في Windows لا يتعلّق فقط بهويّة الحساب. إنّه يهتمّ أيضاً بسياق الـ elevation.

ذلك يجعله ملاءمة سيّئة لاتّصال واجهة بمستوى integrity متوسّط ↔ helper بمستوى integrity عالٍ.

هناك أيضاً تجعّد عمليّ آخر: في بيئة المستخدم القياسيّ، قد يتحوّل UAC إلى credential prompt، وقد ينتهي الأمر بتشغيل الـ helper بحساب مسؤول مختلف. إن بنى الـ helper الـ ACL الخاصّ به من WindowsIdentity.GetCurrent() وحده، فقد لا يستطيع المستخدم الأصليّ للواجهة الاتّصال بعد ذلك.

لذلك في هذا التصميم، النهج الأنظف هو:

  • ترسل الواجهة SID الخاصّ بها إلى الـ helper
  • يمنح الـ helper وصول pipe لذلك SID مستخدم الواجهة تحديداً
  • يتحقّق الـ helper أيضاً من PID العميل بعد اتّصال الـ pipe

5.7 التحقّق من PID حدّ إضافيّ مفيد

حتّى مع اسم pipe عشوائيّ، من الأفضل ألّا تفترض أنّ أيّ عمليّة تحت المستخدم نفسه ينبغي أن تكون قادرة على السباق والاتّصال أوّلاً.

لذلك فإنّ GetNamedPipeClientProcessId مفيد هنا.
يستطيع الـ helper تأكيد أنّ العميل المتّصل هو عمليّة الواجهة المحدّدة التي شغّلته.

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

6. سيناريو النموذج

للمثال الملموس، سأستخدم تسجيل أو إزالة إدخال قائمة Explorer سياقيّة على مستوى الجهاز.

إنّه نموذج جيّد لأنّه:

  • يحتاج إلى صلاحيّات المسؤول
  • حدّ العمليّة واضح
  • لا حاجة لتمرير سلاسل أوامر عشوائيّة إلى الـ helper
  • وهو أيضاً متطلَّب واقعيّ مصدَّق جدّاً

أهداف السجلّ الثابتة هي:

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

تكشف الواجهة فقط مربّع اختيار مثل «تسجيل قائمة Explorer السياقيّة».
يقوم الـ helper المرفَّع بكتابة السجلّ.

7. هيكل الحلّ المقترح

MyApp/
  MyApp/                         UI app (asInvoker)
    app.manifest
    ElevationBrokerClient.cs
    SettingsPage.xaml.cs
  MyApp.AdminBroker/             elevated helper (requireAdministrator)
    app.manifest
    Program.cs
    BrokerLaunchOptions.cs
    ExplorerContextMenuRegistration.cs
  MyApp.BrokerProtocol/          shared contract
    BrokerProtocol.cs

الإبقاء على العقد المشترك في مشروعه الخاصّ يجعل الحفاظ على هذه الأمور متوافقة أسهل:

  • أسماء العمليّات
  • أنواع الطلب والاستجابة
  • صيغة رسالة الـ pipe

8. Manifests

8.1 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 manifest جانب الـ helper

<?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 وحده يطلب الـ elevation.
إن عُكس ذلك، فإنّ الفصل يفقد الكثير من قيمته.

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.
شكل طلب/استجابة بسيط مسبوق بالطول أصعب بكثير في سوء المعالجة.

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

لا ترسل الواجهة أوامر إداريّة خام.
إنّها تشغّل الـ helper، وتفتح الـ pipe، وترسل طلباً ذا نوع. هذا الحدّ مهمّ.

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

ينبغي أن يفشل الـ 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));
        }
    }

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool GetNamedPipeClientProcessId(
        IntPtr pipe,
        out uint clientProcessId);
}

الأفكار المهمّة هي:

  • بناء الـ ACL الخاصّة بالـ pipe صراحةً
  • منح الوصول ليس فقط لهويّة الـ helper بل لـ SID المستخدم الأصليّ للواجهة
  • التحقّق من PID العميل بعد إنشاء الاتّصال
  • التوزيع فقط عبر مفتاح عمليّات ثابت

ذلك يبقي الـ helper من الانجراف نحو صندوق أوامر مرفَّع عامّ.

13. العمليّة الإداريّة نفسها: تسجيل قائمة Explorer السياقيّة

13.1 MyApp.AdminBroker/ExplorerContextMenuRegistration.cs

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

النيّة هنا أهمّ من استدعاءات API السجلّ نفسها:

  • الواجهة لا ترسل مسار سجلّ عشوائيّاً
  • الواجهة لا ترسل سلسلة أمر عشوائيّة
  • يحلّ الـ helper الـ EXE الهدف بطريقة ثابتة
  • يحوي الطلب فقط 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. ما الذي يفرضه هذا التصميم فعلاً

الحدود المفيدة هنا هي:

  • فصل واضح للمسؤوليّة بين الواجهة والـ helper
  • لا قناة تنفيذ عامّة داخل العمليّة المرفَّعة
  • مسار تشغيل ثابت للـ helper
  • مصدر IPC مقيَّد عبر فحوصات SID و PID
  • سطح هدف إداريّ ثابت بدلاً من عمليّات سجلّ أو ملفّات عشوائيّة

ذلك ما يبعد الـ helper عن «صندوق أداة مرفَّع» ويقرّبه من «حدّ إداريّ ضيّق واحد».

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

16.1 وضع علامة على الواجهة بأكملها بـ requireAdministrator

إن كان زرّ صفحة إعدادات واحد فقط هو الذي يحتاج إلى الـ elevation، فإنّ جعل الواجهة كلّها مرفَّعة هو مجرّد تسطيح لحدّ الامتيازات.

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

أنماط كهذه هي الاتّجاه الخطأ:

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

ذلك يجعل الـ helper منفّذ أوامر ذا امتيازات.

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

«إنّه فقط IPC محلّيّ» ليس كافياً.
الـ named pipes جزء من نموذج أمان Windows، لذلك يستحقّ الـ ACL تصميماً متعمَّداً.

16.4 التهافت على CurrentUserOnly بسرعة كبيرة

يبدو مريحاً، لكنّه ليس ملائمة جيّدة لمسار واجهة بمستوى integrity متوسّط إلى helper بمستوى integrity عالٍ.

16.5 ترك الـ helper يتصرّف بناءً على مسارات أو أسماء عشوائيّة

على سبيل المثال:

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

هكذا يتحوّل الـ helper إلى سطح تنفيذ ذي امتيازات عامّ.

17. الخلاصة

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

الإجابة الأكثر استدامة عادةً هي:

  • أبقِ الواجهة عند asInvoker
  • انقل العمل الإداريّ إلى helper EXE
  • ضع علامة على ذلك الـ helper وحده بـ requireAdministrator
  • شغّله بـ runas
  • اتّصل عبر named pipes
  • اقبل فقط عمليّات ثابتة
  • قيّد عميل الـ pipe بـ SID و PID
  • تحقّق من الطلب مرّة أخرى داخل الـ helper

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

معظم تحسينات الأمان لا تتعلّق بإضافة شيء برّاق.
إنّها تتعلّق برفض ترك حدّ مهمل في مكانه.
صلاحيّات المسؤول ليست مختلفة.

18. المراجع

  • المقال السابق: قائمة تحقّق للحدّ الأدنى من الأمان في تطوير تطبيقات Windows
    https://comcomponent.com/ar/blog/2026/03/14/001-windows-app-security-minimum-checklist/
  • Administrator Broker Model - Win32 apps
    https://learn.microsoft.com/en-us/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/en-us/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/en-us/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
  • ProcessStartInfo.UseShellExecute
    https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-diagnostics-processstartinfo-useshellexecute
  • Named Pipe Security and Access Rights
    https://learn.microsoft.com/en-us/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/en-us/windows/win32/api/winbase/nf-winbase-getnamedpipeclientprocessid
  • RegistryView Enum
    https://learn.microsoft.com/en-us/dotnet/api/microsoft.win32.registryview?view=net-8.0

مقالات ذات صلة

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

كيف نستعمل Windows Sandbox لتسريع التحقّق من تطبيقات Windows - صلاحيّات المسؤول والبيئات النظيفة وإعادة إنتاج حالات نقص الصلاحيّات أو الموارد

مرشد عمليّ يبيّن كيف يسرّع Windows Sandbox التحقّق من تطبيقات Windows، عبر ملفّات .wsb لكلّ سيناريو وفحوصات المستخدم القياسيّ ومحاكاة شُح...

قراءة المقالة

أين يتصل هذا الموضوع

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

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