
تنفيذ لإثبات المفهوم لتقنية مراوغة تهدف إلى إنهاء الخيط الحالي واستعادته قبل استئناف التنفيذ، مع تنفيذ تغييرات في حماية الصفحات أثناء عدم التنفيذ.
██████╗ ███████╗ █████╗ ████████╗██╗ ██╗███████╗██╗ ███████╗███████╗██████╗
██╔══██╗██╔════╝██╔══██╗╚══██╔══╝██║ ██║██╔════╝██║ ██╔════╝██╔════╝██╔══██╗
██║ ██║█████╗ ███████║ ██║ ███████║███████╗██║ █████╗ █████╗ ██████╔╝
██║ ██║██╔══╝ ██╔══██║ ██║ ██╔══██║╚════██║██║ ██╔══╝ ██╔══╝ ██╔═══╝
██████╔╝███████╗██║ ██║ ██║ ██║ ██║███████║███████╗███████╗███████╗██║
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚══════╝╚═╝
تنفيذ إثبات المفهوم (PoC) لتقنية تهرب تهدف إلى إنهاء الخيط الحالي واستعادته قبل استئناف التنفيذ، مع تطبيق تغييرات حماية الصفحة أثناء عدم التنفيذ.

طرق السبات والإخفاء معروفة جيدًا في مجتمع تطوير البرامج الضارة (maldev)، مع تطبيقات مختلفة، تهدف إلى الاختباء من ماسحات الذاكرة أثناء السبات، عادة عن طريق تغيير حماية الصفحات وحتى إضافة ميزات رائعة مثل تشفير الشيل كود، ولكن هناك نقطة مهمة أخرى لإخفاء الشيل كود الخاص بنا، وهي إخفاء خيط التنفيذ الحالي.
انتحال المكدس (stack spoofing) أمر رائع، ولكن بعد التفكير قليلاً، خطر لي أنه ليست هناك حاجة لانتحال المكدس… إذا لم يكن هناك مكدس :)
يُترك تقييم قابلية استخدام هذه التقنية للقارئ، ولكن على أي حال، أعتقد أنها طريقة رائعة لمراجعة بعض المواضيع، وتعلم بعض تقنيات maldev لمن هم، مثلي، بدأوا للتو في هذا العالم.
التنفيذ الرئيسي المعروض هنا يحتفظ بكل ما نحتاج لإخراجه من المكدس في قسم البيانات، كمتغيرات عامة، ولكن سيتم نشر تنفيذ ينقل كل شيء إلى الكومة (heap) قريبًا. يهدف إلى إظهار بعض التعديلات الرئيسية التي يجب إجراؤها لجعل هذا الكود مستقلاً عن الموقع (PIC) وقابلاً للحقن.
هذا المستودع منعكس بين GitHub و GitLab.
كل ما ورد هنا يأتي من فهمي للمواضيع المختلفة المطروحة، سواء من القراءة أو الخبرة أثناء التطوير. أنا مدرك أنني لست خبيرًا وآخر ما أريده هو نشر معلومات خاطئة، لذا إذا كنت تعتقد أن هناك شيئًا غير صحيح، سأكون سعيدًا بإبلاغي بذلك، يمكنك الاتصال بي على تويتر، أو فتح مشكلة (issue) في هذا المستودع. شكرًا جزيلاً لتفهمك. :)
الهدف الرئيسي من هذه التقنية واضح، وهو إنهاء الخيط الحالي واستعادته قبل استئناف التنفيذ، ولكن ماذا يعني هذا بالضبط، وما القيود الجديدة التي يفرضها؟
لكي نتمكن من استعادة التنفيذ، نحتاج إلى حفظ شيئين قبل إنهاء الخيط: أولاً، حالة وحدة المعالجة المركزية (CPU state)، وثانيًا المكدس، ثم إعادة ضبطهما بشكل فعال بعد تشغيل الخيط الجديد.
تحدثت عن قيود جديدة ستظهر في هذه التقنية، وهناك اثنان رئيسيان: أولاً، نحتاج إلى تخزين أي شيء مطلوب خارج المكدس من لحظة إنهاء الخيط حتى استعادة المكدس، وكما سترى، هذا يخلق تحديات جديدة.
ثانيًا، نحتاج دائمًا إلى وجود خيط آخر على الأقل قيد التشغيل في عمليتنا، لأننا بإنهاء خيطنا، إذا لم تكن هناك خيوط أخرى، ستنتهي العملية. لا أعتقد أن هذه مشكلة كبيرة، نظرًا لأن معظم العوامل (agents) تُحقن في عمليات أخرى، يمكننا افتراض أن هذه العملية ستحتفظ بخيط واحد على الأقل قيد التشغيل.
يمكننا رؤية 4 وظائف أساسية في هذا الإثبات:
عندما نكون على وشك حفظ المكدس، يطرح سؤال: ما مقدار المكدس الذي يجب حفظه؟
دعنا نراجع أولاً ما هو موجود في المكدس بعد استدعائنا لوظيفة DeathSleep (هذه هي الوظيفة التي تحفظ السياق والمكدس وتعد كل شيء للإخفاء والاستعادة).

كما نرى، كل وظيفة تتكون من ثلاثة أجزاء:
الحد الأدنى من المكدس الذي نحتاج بالتأكيد لحفظه هو كل شيء داخل برنامجنا الرئيسي، وهذا يعني مساحة Shadow space الخاصة به، وعنوان العودة الخاص به، وكل شيء حتى وظيفة DeathSleep. أي شيء قبل ذلك ليس مطلوبًا حقًا (حفظ المكدس المستخدم بواسطة وظيفة الدخول له مزايا، لكننا سنناقش ذلك لاحقًا)، لأن هذا هو المكدس المستخدم بواسطة إجراءات Windows لتشغيل خيطنا الجديد. بالإضافة إلى ذلك، قررت أيضًا تخزين مساحة Shadow space الخاصة بوظيفة DeathSleep (ليس ضروريًا حقًا، لكنه يجعل حساب Rsp في لحظة الاستيقاظ أسهل).
لذا في النهاية، نحن نحفظ هذا:

كل وظيفة في الترجمة القياسية يجب أن تتكون من 3 أجزاء: المقدمة (prologue)، كود الوظيفة، والخاتمة (epilogue).
يجب تعديل Rsp (مؤشر المكدس) فقط في مقدمة وخاتمة الوظيفة. المقدمة تزيد مؤشر المكدس (تذكر أن زيادة المكدس تعني تقليل العناوين، لأنها تسير في اتجاهين متعاكسين)، لحفظ السجلات، لاحتواء جميع متغيراتها المحلية ثم لاحتواء Shadow space، والخاتمة تفعل العكس تمامًا.
هذا يعني أن مؤشر المكدس داخل كود الوظيفة يجب أن يشير دائمًا إلى نهاية Shadow space (الأرجواني في الصورة أعلاه)، ومجموع حجم مكدس الوظيفة و Shadow space يمكن العثور عليه عن طريق حساب مقدار زيادة مؤشر المكدس في المقدمة. يمكن حساب هذه القيمة بسهولة باستخدام المعلومات المحمولة في جداول فك اللف (unwind tables)، شرح استخدامها ليس شيئًا سنغطيه هنا، ولكن كملخص، تُستخدم هذه الجداول للسماح لأي خيط أو عملية أخرى بالتحرك بشكل صحيح عبر المكدس لرؤية محتوياته، أو للتعامل مع الاستثناءات أو تحليلها.
التقاط السياق هو على الأرجح أسهل شيء يمكن القيام به، حيث يمكننا ببساطة تنفيذ RtlCaptureContext() في أول سطر من DeathSleep، قبل أي تعديل على السجلات غير المتطايرة (non-volatile registers). لا يزال يتعين علينا إجراء تعديلين على السياق الذي سنستعيد فيه التنفيذ.
أول تعديل سيكون تعديل Rip، كما تتذكر، هذا هو السجل الذي يحمل التعليمات التالية للتشغيل، وإذا تركناه دون تعديل، فسيستأنف التنفيذ داخل وظيفة DeathSleep. ما سنفعله هو تغيير Rip ليحمل عنوان العودة الخاص بـ DeathSleep، والذي يشير إليه Rsp الحالي بعد إزاحة مقدار الزيادة في الخاتمة (المناطق الخضراء + الأرجوانية في الصور أعلاه).
التعديل الثاني سيتم عند استعادة الخيط، وهو يتضمن تعيين Rsp ليشير إلى أعلى المكدس المستعاد؛ سيتم ذلك خلال مرحلة الاستعادة، لأننا لا نعرف أين سيتم وضع مكدسنا الجديد. ستكون القيمة ببساطة هي عنوان نهاية المكدس المستعاد، لأنه كما ناقشنا لاحقًا، قمنا بنسخ أيضًا مساحة Shadow space المحجوزة بواسطة متصل DeathSleep، وهذه هي بالضبط قيمة RSP قبل استدعاء DeathSleep.
بمجرد أن نصل إلى نقطة الاستيقاظ، قبل استئناف التنفيذ مباشرة، نحتاج إلى وضع المكدس المحفوظ في مكانه. كما نعلم بالفعل، يبدأ المكدس المحفوظ لدينا عند العنوان الذي التقطته وظيفة الاستيقاظ، لذا فإن العنوان الجديد الملتقط سيكون نقطة البداية حيث سنضع المكدس المحفوظ، ولكن هذا يثير مشكلة، أي استدعاء لوظيفة بعد وضع المكدس القديم سيعدله ويكسره، والقيام بالتنظيف هنا مناسب حقًا، خاصة تحرير الكومة المستخدمة لحفظ نسخة المكدس الاحتياطية. هذا يعني أننا بحاجة إلى نقل Rsp الحالي وكذلك أجزاء المكدس التي نستخدمها حاليًا إلى مكان خارج المكان الذي سنضع فيه المكدس المستعاد. محاولة توضيح ذلك أكثر، هذه هي المشكلة:

وهذا هو حلي، فقط انقل كل شيء بعيدًا:

بعد أن قمنا بكل العمل الشاق، آخر شيء يجب فعله هو استخدام NtContinue، هذه الوظيفة تسمح لنا بتغيير السياق الحالي بسياقنا الملتقط والمعدل مسبقًا، مما يضع RIP مباشرة بعد استدعاء DeathSleep، يجب أن تحتوي جميع السجلات على نفس القيم التي كانت عليها عند استدعاء DeathSleep، ويجب أن يشير RSP إلى أعلى المكدس.
حسنًا، نحن نعرف أساسيات ما نحتاج لفعله لتخزين واستعادة الخيط الحالي، لكننا نحتاج بطريقة ما إلى أن نكون قادرين على تشغيل كل هذا حتى عندما لا يكون لدينا خيوط. هنا نلتقي بواجهة برمجة تطبيقات تجمع الخيوط الجميلة (Thread Pool API)، وهي أداة مقدمة من Windows، تسمح لنا بوضع المهام (وظائف بحد أقصى وسيط واحد) في قائمة انتظار لمجموعة من الخيوط (تجمع) ستتم إدارتها بالكامل بواسطة نظام التشغيل. إذا كنت قد رأيت Ekko، يمكنك أن ترى أنه يستخدم هذه الواجهة، لذا… دعنا ننفذها بنفس الطريقة.
كل شيء سار بشكل جيد، ولكن كانت هناك مشكلة واحدة، كان العامل (worker) لا يزال قائمًا حتى بعد انتهاء تنفيذ المهام التي تم وضعه في قائمة الانتظار. كانت هذه مشكلة، لأنني أردت تدمير جميع الخيوط التي يمكن لبرنامجنا إنشاؤها، لذلك لم تكن هذه هي الطريقة الصحيحة.
بعد البحث قليلاً، اكتشفت أن واجهة برمجة تطبيقات تجمع الخيوط المستخدمة في Ekko كانت نسخة قديمة، وكانت هناك نسخة جديدة مع بعض الإمكانيات الإضافية، ومن بينها وظيفة ستحل مشكلتنا بفعالية: CloseThreadPool(). تسمح لنا هذه الواجهة الجديدة بإنشاء التجمع الخاص بنا، وتدميرها بعد استخدامها، مما ينهي جميع العاملين المستخدمين. كما تقدم ميزتين أخريين: تعيين الحد الأقصى لعدد الخيوط ومجموعات التنظيف. تعيين الحد الأقصى لعدد الخيوط سيسمح لنا بتنفيذ جميع مهامنا بشكل تسلسلي، طالما تم وضعها في قائمة الانتظار مع أي فارق زمني. مجموعات التنظيف مفيدة لتسهيل التنظيف بعد الانتهاء من كل شيء.
لذا… كل شيء منتهي؟ حسنًا، في هذه المرحلة، تم إنهاء الخيط، ونحن نضع وظيفة إعادة الميلاد (Rebirth) في قائمة الانتظار والتي تنشئ الخيط الجديد مع Awake كنقطة دخول له، وتستعيد الحالة السابقة وتغلق التجمع، حتى الآن كل شيء على ما يرام!
عندما انتهيت من كل ما ناقشناه من قبل، اعتقدت أن الجزء الصعب قد تم حله، لأن هذا الجزء تم حله بالفعل بواسطة التقنيات السابقة، لكن يا للروعة، لم أكن أعرف ما الذي سيأتي.
المشكلة الرئيسية هي أننا بحاجة إلى تفريغه خارج الكود الخاص بنا، لأننا نغير حماية الذاكرة إلى RW (قراءة-كتابة)، إذا استدعينا VirtualProtect()، عندما تعود الوظيفة، ستتعطل عمليتنا (لا يمكننا تنفيذ تعليمات في صفحات RW)، لذا نحتاج إلى إيجاد طريقة لتنفيذ هذا من مكان آخر، وجعله يعود أيضًا إلى صفحات RX (قراءة-تنفيذ) (ويحدث نفس الشيء عند العودة). من الواضح أننا سنستخدم واجهة برمجة تطبيقات تجمع الخيوط أيضًا لهذا، ولكن هناك مشكلة، يمكننا فقط إعطاء وسيط واحد لمهامنا، و VirtualProtect() تأخذ 4 وسائط.
لهذا سنستخدم NtContinue() مرة أخرى، أول مرة رأيت هذا الاستخدام لهذه الوظيفة كان في Foliage، ولكنه يستخدم أيضًا في Ekko. NtContinue()، كما رأينا من قبل، يسمح لنا بتعيين سياق للخيط الذي يستدعيه، ومع بعض التعديلات الذكية، يمكنه "استدعاء" وظيفة بعدة وسائط، باستخدام وسيط واحد فقط (مناسب جدًا لواجهة برمجة تطبيقات تجمع الخيوط). الفكرة الرئيسية هي تعيين RIP إلى عنوان بداية الوظيفة، وبما أن اصطلاح استدعاء Windows x64 يمرر الوسائط الأربعة الأولى في السجلات (rcx، rdx، r8، r9، بهذا الترتيب)، فقط ضع وسائطك في هيكل السياق الذي ستمرره إلى NtContinue، وسيحاكي بشكل فعال استدعاء لوظيفة. آخر شيء يجب أن نكون حريصين عليه عند استخدام NtContinue هو Rsp، لأنه كما رأينا من قبل، يجب أن يحمل هذا العنوان عنوان العودة عند استدعاء وظيفة.
لذا أول شيء نحتاجه لكي يعمل NtContinue هو الحصول على سياق، يمكننا صياغته يدويًا، لكننا سنواجه مشكلة، إيجاد قيمة Rsp، التي عند تمريرها إلى وظيفتنا، ستشير إلى العنوان الذي سيستخدمه RET للعودة. ستعمل مهامنا في خيط مختلف، لذا لا نعرف أين سيتم وضع مكدسها. الحل (المسروق بعناية من Ekko، شكرًا جزيلاً :P) هو أخذ نسخة من السياق داخل عامل باستخدام RtlCaptureContext()، وزيادة مؤشر المكدس للسياق الذي تم الحصول عليه بمقدار 8، بحيث يشير إلى العنوان الذي تم إدخاله في المكدس بواسطة CALL RtlCaptureContext()، والذي هو عنوان العودة لهذه الوظيفة الأخيرة، ويمكننا استخدامه كعنوان عودة لجميع وظائفنا.
حسنًا هذا جميل، لكن ماذا يحدث عندما لا نستطيع إجراء هذا التعديل على Rsp؟ هذا ما يحدث عندما نقوم بإزالة الإخفاء، سنكون في خيط جديد، لذا فإن Rsp للسياق القديم غير مجدٍ. نحتاج إلى سياق جديد، مأخوذ من الخيط الجديد، لكن لا يمكننا استخدام الحيلة القديمة بتعديل Rsp ليشير إلى العنوان الصحيح.
لذا لا يمكننا تعديل السياق الذي تم الحصول عليه، ولكن هذا لا يعني أنه غير مجدٍ، في الواقع سنستخدمه، ولكن بطريقة مختلفة. إذا استعدنا هذا السياق مع NtContinue()، دون تعديل Rip، فسيوجه التنفيذ ببساطة إلى التعليمات التالية بعد استدعاء RtlCaptureContext()، ومع Rsp صحيح، لذا يمكننا استخدامه بعد استدعاءاتنا لـ NtContinue() مع سياقات معدلة، لكي نتمكن من إنهاء تنفيذ مهامنا بشكل صحيح. للقيام بذلك، سنستخدم سلسلة Rop، عن طريق تعيين Rsp لسياقنا الأول ليشير إلى مكدس مصمم يدويًا، سيحتوي على كل ما نحتاج لإعادة توجيه التنفيذ حتى استدعاء NtContinue() الثاني الذي سيعين السياق الصحيح للإنهاء.
هذا هو الشكل الذي يجب أن يبدو عليه مكدسنا المصمم يدويًا:

نحن نستخدم اثنين من أدوات Rop gadget، واحدة لإصلاح أو "القفز" فوق Shadow space لوظيفتنا، والثانية مسؤولة عن وضع الوسيط لـ NtContinue في rcx، ثم العودة إليه.
العثور على هاتين الأداتين سهل للغاية، الأداة لإصلاح Shadow space هي مجرد خاتمة أي وظيفة تقريبًا (وجدت أكثر من 500 نتيجة فقط في Ntdll)، لأنه كما رأينا من قبل، الخاتمات مصممة بشكل أساسي لتقليل Rsp، والثانية هي مجرد pop rcx; ret; والتي هي 2 بايت، ووجدت أيضًا اثنتين بين ملفات Ntdll و Kernel32.
كما رأينا، استخدام NtContinue يحتاج فقط إلى ملء وسيطه الأول ليعمل، وهذا مثالي مع واجهة برمجة تطبيقات تجمع الخيوط القديمة، ولكن في واجهة برمجة تطبيقات تجمع الخيوط الجديدة، يتم تمرير الوسائط في الموضع الثاني، لذا نعم، هذا وحده لن يعمل.
بعد بضع ساعات دون معرفة كيفية حل هذه المشكلة الأخيرة، خطر ببالي أن كلا الواجهتين تستخدمان نفس الوظائف في بعض الحالات، مما جعلني أعتقد أنهما قد تكونان أكثر تشابهًا مما تبدوان، لذا قررت التحقق من العلاقة بينهما.
بالنسبة للواجهة القديمة، نستخدم CreateTimerQueueTimer() لوضع مهامنا في قائمة الانتظار، وفي الجديدة، نحتاج إلى وظيفتين لفعل الشيء نفسه: CreateThreadpoolTimer()، التي ستأخذ وظيفة الاستدعاء (callback) والوسيط الذي سيتم تمريره إليها، وستعيد مؤشرًا إلى هيكل TP_TIMER الذي يصف المهمة، ووظيفة ثانية لوضع المهمة في قائمة الانتظار: SetThreadpoolTimer()، التي ستأخذ المؤشر السابق ومؤشرًا إلى هيكل FILETIME الذي يصف متى سيتم تنفيذ المهمة.
إذا قمنا بعكس هذه الوظائف، سنجد هذا:

لذا كما نرى، CreateThreadpoolTimer() هي مجرد غلاف فاخر لـ TpAllocTimer()، و SetThreadpoolTimer() هي مجرد موجه (forwarder) إلى TpSetTimer().
الآن دعنا نتحقق من داخل CreateTimerQueueTimer(). في البداية، هي مجرد غلاف فاخر آخر لوظيفة في Ntdll، RtlCreateTimer()، وهنا يحدث السحر. هذه وظيفة أكبر، ولكن هنا الكنز الذي كنا نبحث عنه:

كما ترى، داخل هذه الوظيفة هناك فعليًا استدعاء لـ TpAllocTimer() و TpSetTimer()، والذي يشبه القول إنها تستدعي CreateThreadpoolTimer() و SetThreadpoolTimer() داخلها. كما نرى، الوظيفة التي نضعها في قائمة الانتظار ليست مباشرة الاستدعاء الذي أعطيناه للوظيفة، إنها تعيين RtlpTpTimerCallback() كاستدعاء. إذا لم تكن قد أدركت بعد ما يعنيه كل هذا، فهو أننا نستخدم CreateThreadpoolTimer() لوضع وظيفة تتلقى وسائطها في الموضع الثاني، RtlpTpTimerCallback()، والتي ستنفذ وظيفة أخرى بوسائطها في الموضع الأول.
لذا الشيء الوحيد الذي لا يزال يتعين علينا فهمه هو كيف يتم تمرير معلومات الاستدعاء إلى RtlpTpTimerCallback()، وبعد بعض الهندسة العكسية، انتهيت بالهيكل التالي، الذي، يا للمفاجأة، يعمل!

الآن يمكننا استدعاء وظائف تتلقى وسائطها في الموضع الأول وفي نفس الوقت نكون قادرين على إغلاق تجمعاتنا، وعدم ترك أي خيوط قيد التشغيل، ربح مزدوج. من المهم ملاحظة أن هذه الوظيفة غير مصدرة (exported) في Ntdll، لذا قررت العثور عليها من خلال شكلها الثنائي داخل dll.
لذا هذه هي النهاية، ومع كل ما تمت مراجعته، أعتقد أنني أعطيت الأفكار الأساسية التي مرت في ذهني أثناء تطوير إثبات المفهوم هذا، ولماذا تم كل شيء بالطريقة التي فعلتها.
تم اختبار هذا الكود فقط باستخدام مترجم MSCV وموصله (linker)، نظرًا لأن إثبات المفهوم هذا يعتمد بشكل كبير على كيفية تجميعه، أوصي باستخدام نفس هذه الأداة، ولا أضمن أنه سيعمل مع أدوات تجميع أخرى مباشرة.