
تقنية تنفيذ/حقن الأكواد باستخدام التلاعب ببنية وحدة DLL في PEB
تنفيذ كود خفي عبر تعديل EntryPoint للوحدات المحمّلة وقت التشغيل.
تمتلك عمليات Windows وحدات (modules) مختلفة محمّلة وقت التشغيل. لكل وحدة من هذه الوحدات دالة DllMain() معرفة، ويتم استدعاؤها عند إنشاء/إنهاء عملية أو مؤشر ترابط (أربعة سيناريوهات محتملة).
من أجل استدعاء هذه الدوال بشكل صحيح طوال عمر العملية، تشير دوال محمّل Windows (ntdll!Ldrp*) إلى قائمة من الإدخالات التي تحتوي على معاملات رئيسية (بما في ذلك حقل EntryPoint) لكل وحدة.
من خلال الكتابة فوق EntryPoint لوحدة DLL، نضمن إعادة توجيه تنفيذ الكود إلى مكان نختاره.
يمكن استخدام هذا كأداة بدائية لتنفيذ الكود (code execution primitive)، وللوساطة في استدعاءات API (API proxying)، أي لتشغيل بعض واجهات برمجة التطبيقات مع مكدس استدعاءات (callstack) غير مريب، حيث سيتم استدعاؤها بواسطة دوال Windows شرعية.
يمكن أيضًا استخدامه لبدء التنفيذ في عملية بعيدة، بشرط أن تكون لدى المهاجم القدرة على قراءة وكتابة الذاكرة في العملية الهدف. وبالمثل إلى Threadless Injection، يوفر هذا القدرة على تنفيذ كود في عملية دون استدعاء واجهات API التقليدية المرتبطة بالتنفيذ (CreateRemoteThread, QueueUserAPC).
إن تحميل/إلغاء تحميل الوحدات داخل عملية Windows موضوع معقد يطرح الكثير من التحديات، واحتمال عدم الاستقرار، وسباقات التوقيت (race conditions)، والتعطل. من العقبات المعروفة المتعلقة بتشغيل كود كجزء من دالة DllMain() مثلاً، حقيقة وجود قفل المحمّل (Loader Lock) وأننا نعمل في مؤشر ترابط لم يتم إعداده بالكامل، أو أنه في طور الإنهاء.
لذلك، حاولت توثيق ما هو ممكن وما هو غير ممكن بشكل صحيح. على سبيل المثال، في حين يمكن تنفيذ معظم استدعاءات API المعتادة، فإن تشغيل beacon كامل يتطلب أن يكون في عملية منفصلة، لتجنب حالات الجمود (deadlocks) الناجمة عن الدوال المستخدمة في wininet.dll أو winhttp.dll.
تحتفظ كل عملية بقائمة من بنى _LDR_DATA_TABLE_ENTRY وقت التشغيل. تحتوي هذه البنى على تفاصيل كثيرة تتعلق بوحدة DLL، مثل EntryPoint (الذي سنقوم بالكتابة فوقه)، واسمها، وبعض التجزئات (hashes)، والطوابع الزمنية، وعدة أعلام (flags)، وما إلى ذلك. بعض هذه البنى موثّق وبعضها غير موثّق.
يمكن تصور ذلك عبر أمر WinDbg التالي:
dt nt!_LDR_DATA_TABLE_ENTRY 0xdeadbeef
0:006> dt nt!_LDR_DATA_TABLE_ENTRY 0x18d1c4838c0
ntdll!_LDR_DATA_TABLE_ENTRY
+0x000 InLoadOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485de0 - 0x0000018d`1c4832b0 ]
+0x010 InMemoryOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485df0 - 0x0000018d`1c4832c0 ]
+0x020 InInitializationOrderLinks : _LIST_ENTRY [ 0x0000018d`1c4832d0 - 0x0000018d`1c482c80 ]
+0x030 DllBase : 0x00007ffe`e87e0000 Void
+0x038 EntryPoint : 0x00007ffe`e8838d00 Void
+0x040 SizeOfImage : 0x2fe000
+0x048 FullDllName : _UNICODE_STRING "C:\WINDOWS\System32\KERNELBASE.dll"
+0x058 BaseDllName : _UNICODE_STRING "KERNELBASE.dll"
+0x068 FlagGroup : [4] "???"
+0x068 Flags : 0x8a2cc
+0x068 PackagedBinary : 0y0
+0x068 MarkedForRemoval : 0y0
+0x068 ImageDll : 0y1
(...)
+0x0e0 MappingInfoIndexNode : _RTL_BALANCED_NODE
+0x0f8 OriginalBase : 0x00007ffe`e87e0000
+0x100 LoadTime : _LARGE_INTEGER 0x01db5f86`2fa735fc
+0x108 BaseNameHashValue : 0x235bec4
+0x10c LoadReason : 0 ( LoadReasonStaticDependency )
+0x110 ImplicitPathOptions : 0x4000
+0x114 ReferenceCount : 1
+0x118 DependentLoadFlags : 0x800
+0x11c SigningLevel : 0 ''
يمكن الحصول على عنوان البنية من خلال اجتياز بنية مرتبطة بشكل مضاعف (doubly-linked) مرجعية في PEB الخاص بالعملية داخل بنية PEB_LDR_DATA.
dt nt!_PEB_LDR_DATA 0xb4b4c3c3
لاحظ العلامة DontCallForThreads. كما يشير الاسم، إذا تم تعيين هذه العلامة، لن يقوم نظام التشغيل باستدعاء DllMain() لهذه الوحدة لأحداث المؤشرات (أي DLL_THREAD_ATTACH أو DLL_THREAD_DETACH).
عند إنشاء وحدة DLL، يجب اتباع القالب التالي للعمل جنبًا إلى جنب مع دوال محمّل نظام التشغيل:
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // handle to DLL module
DWORD fdwReason, // reason for calling function
LPVOID lpvReserved ) // reserved
{
// Perform actions based on the reason for calling.
switch( fdwReason )
{
case DLL_PROCESS_ATTACH:
// Initialize once for each new process.
// Return FALSE to fail DLL load.
break;
case DLL_THREAD_ATTACH:
// Do thread-specific initialization.
break;
case DLL_THREAD_DETACH:
// Do thread-specific cleanup.
break;
case DLL_PROCESS_DETACH:
if (lpvReserved != nullptr)
{
break; // do not do cleanup if process termination scenario
}
// Perform any necessary cleanup.
break;
}
return TRUE; // Successful DLL_PROCESS_ATTACH.
}
كما هو موضح أعلاه، تقوم التقنية مؤقتًا بالكتابة فوق EntryPoint لوحدة DLL لإعادة توجيه التنفيذ. وبما أنه ليس لدينا أي تحكم يتجاوز إعادة توجيه التنفيذ، يجب اتخاذ بعض الترتيبات على الجانب الآخر للتعامل مع ما نريد تشغيله، وبأي وسائط، وكيفية استعادة القيمة المرتجعة.
يتم ذلك عبر تعريف بنية DATA_T في الكومة (heap)، بحيث تظل قابلة للوصول خلال الخطوات المختلفة.
تُعرَّف هذه البنية كما يلي:
typedef struct _DATA_T {
// LDR structures manipulation
ULONG_PTR runner; // malicious entry point to execute
ULONG_PTR bakOriginalBase; // backup of overwritten OriginalBase
ULONG_PTR bakEntryPoint; // backup of overwritten EntryPoint
HANDLE event; // event signalling that the Runner has executed
// function call
ULONG_PTR ret; // return value
DWORD createThread; // run this API call in a new thread (required for wininet/winhttp)
ULONG_PTR function; // Windows API to call
DWORD dwArgs; // number of args
ULONG_PTR args[MAX_ARGS]; // array of args
} DATA_T, * PDATA_T;
لإعداد تنفيذ API، يجب تجهيز هذه الحقول. قيمة ret هي التي ستجمع القيمة المرتجعة بعد التنفيذ. أما event فيُستخدم للمزامنة، للإشارة إلى اكتمال التنفيذ. جميع الحقول الأخرى هي مدخلات تحدد واجهة API المراد استدعاؤها (function)، وبأي وسائط (dwArgs و args[])، وعنوان دالة Runner() التي يُعاد توجيه التنفيذ إليها، ونسخ احتياطية لإدخالات DLL الأصلية التي تمت الكتابة فوقها (bakOriginalBase و bakEntryPoint).
يجب تعيين حقل createThread إلى 1 لوظائف API المعقدة التي لن تعمل بشكل جيد في إعداد DllMain() (وهذا يشمل العديد من مكتبات wininet و winhttp).
إليك مثال لإعداد استدعاء للدالة MessageBoxA() كما يظهر في الإثبات المفاهيمي (PoC):
pDataT->dwArgs = 4;
pDataT->runner = (ULONG_PTR)Runner;
pDataT->function = (ULONG_PTR)MessageBoxA;
pDataT->args[0] = (ULONG_PTR)0;
pDataT->args[1] = (ULONG_PTR)"Hello";
pDataT->args[2] = (ULONG_PTR)"LDRSHUFFLE";
pDataT->args[3] = (ULONG_PTR)MB_OKCANCEL;
pDataT->event = CreateEventA(NULL, FALSE, FALSE, "ExecEvt");
تتولى دالة UpdateLdr() مسؤولية إجراء التعديل الصحيح في _LDR_DATA_TABLE_ENTRY للوحدة الهدف.
ستقوم دالة RestoreLdr() باستعادة تلك التغييرات في مرحلة لاحقة (يتم استدعاؤها بواسطة Runner()).
تحدد هذه الدوال موقع PEB وتجتاز بنى الوحدات لتحديد وحدة DLL الصحيحة وحقولها. في ملفات الرؤوس، أعيد استخدام تعريفات استخدمها Batsec في DarkLoadLibrary، وأشجع القراء على الاطلاع على هذا المشروع ومقال MDSec المرتبط للاستفادة من العمل الرائع الذي قام به حول دواخل تحميل الوحدات في Windows.
ملاحظة: يقوم هذا الإثبات المفاهيمي بتحميل وحدة DLL تضحية (SACRIFICIAL_DLL_NAME) وينفذ تلك التغييرات على هذه الوحدة. ومع ذلك، من الممكن تمامًا تعديل وحدة DLL محمّلة بالفعل. في الواقع، هذا هو الأسلوب المتبع للحقن عبر العمليات. لأغراض الاستقرار، أوصي بتجنب التعامل مع وحدات DLL المهمة مثل ntdll أو kernel32، والتي تميل أيضًا إلى أن تكون أكثر تدقيقًا من قبل حلول الأمان.
عند إنشاء مؤشر الترابط أو إنهائه، يُعاد توجيه التنفيذ إلى Runner()، التي تعمل كـ DllMain() مزيفة للوحدة. ستقوم هذه الدالة بعد ذلك بما يلي:
PDATA_T)RestoreLdr() إلى حالته الأصليةDllMain() العادي (أي وساطة استدعاء DLL العادي).عند هذه النقطة، تم تنفيذ "التنفيذ" الطبيعي لنظام التشغيل. ثم تستمر مع حمولاتنا:
DATA_T. إذا تم تحديد أن واجهة API هذه ستعمل في مؤشر ترابط جديد (createThread = 1)، فسيتم تنفيذ هذا الاستدعاء في مؤشر ترابط جديد.pDataT->event) حتى يعرف الكود الرئيسي الخاص بنا أنه تم تنفيذ الاستدعاء.عندما ينتهي Windows إلى استدعاء EntryPoint المزيف الخاص بنا (وهو عنوان دالة Runner())، يبدو مكدس الاستدعاءات كما يلي:

يحتوي الإثبات المفاهيمي المقدم على مثال لاستدعاء MessageBoxA().
كما يحتوي على عرض توضيحي لتنزيل HTTP باستخدام wininet. عرّف المتغير HTTP لتفعيل ذلك الكود.

المبادئ الموضحة أعلاه تختصر في القراءة والكتابة في مساحة ذاكرة العملية، من أجل التسبب في تنفيذ كود في نقطة زمنية اعتباطية في المستقبل.
مع بعض التعديلات، يمكن تطبيق عمليات القراءة والكتابة هذه على عملية بعيدة من أجل الكتابة فوق EntryPoint لإحدى وحدات DLL الخاصة بها.
أحد المتطلبات الأساسية هو القدرة على القراءة والكتابة في مساحة ذاكرة العملية، أي:
OpenProcess(PROCESS_VM_READ, FALSE, dwPid) وOpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION, FALSE, PID)
يوجد مشروع إضافي في الإثبات المفاهيمي، اسمه LdrInject، يوضح كيفية تنفيذ هذه الخطوات. باختصار، يقوم بما يلي:
في ReadPEB()، يجتاز قائمة _LDR_DATA_TABLE_ENTRY في العملية الهدف لتحديد وحدة DLL مناسبة للكتابة فوقها. لاحظ أن وحدة DLL هذه يجب أن يكون فيها DontCallForThreads == 0 لأننا نريد من Windows استدعاء EntryPoint الخاص بوحدة DLL عند إنشاء مؤشر ترابط. كما أننا لا نختار أول وحدات DLL في القائمة لأنها تميل إلى أن تكون أكثر تدقيقًا من قبل منتجات الأمان (ntdll.dll, kernel32.dll...).
تُخزَّن تفاصيل وحدة DLL هذه في بنية بيانات PEBINJ_DATA.
تتم كتابة الشيل كود (shellcode) (في هذه الحالة beacon) في مساحة العملية البعيدة باستخدام InjectShellcodeToRemoteProcess().
استدعاءان للدالة WriteProcessMemory() يقومان بالكتابة فوق EntryPoint الخاص بوحدة DLL وحفظ نسخة احتياطية منه في OriginalBase بحيث يمكن استعادته لاحقًا.
عند تلك النقطة، سيؤدي حدث DLL_THREAD_ATTACH أو DLL_THREAD_DETACH التالي إلى استدعاء الشيل كود. يأتي هذا مع بعض القيود والتحذيرات في سياق تشغيل beacon، وهي مفصلة في القسم التالي.
تؤدي هذه التقنية إلى تنفيذ شيل كود (shellcode) في موقف محدد للغاية. قفل المحمّل (Loader Lock) نشط (لأن نظام التشغيل يعتقد أنه في عملية تحميل/إلغاء تحميل وحدة DLL)؛ ويتم إما إنشاء مؤشر ترابط أو إنهاؤه؛ وبشكل عام، هناك احتمالية لمشاكل مزامنة مؤشرات الترابط وحالات الجمود وما إلى ذلك.
أثناء الاختبار، لوحظ تحدّيان:
تشغيل beacon نموذجي لـ Cobalt Strike قد يؤدي إلى حالة جمود (deadlock) عند استخدام واجهات API في wininet.dll أو winhttp.dll.
التشغيل عند إنهاء مؤشر الترابط يسبب مشاكل في الاستقرار لأننا نعمل في مؤشر ترابط في طور الإنهاء.
لزيادة الاستقرار، يتعين علينا:
التأكد من أن الـ beacon سيعمل في مؤشر ترابط جديد. لذلك، سيقوم UDRL باستدعاء CreateThread قبل استدعاء نقطة الدخول المعتادة لوحدة DLL الانعكاسية الخاصة بـ Cobalt Strike.
التشغيل فقط في مؤشر ترابط يتم إنشاؤه وليس في مؤشر ترابط يحتضر. للقيام بذلك، نضمن أنه عندما يستدعي نظام التشغيل EntryPoint، يكون السبب المستدعى هو fdwReason == DLL_THREAD_ATTACH:
winApi.CreateThread(NULL, 4096, (LPTHREAD_START_ROUTINE)&runner, (LPVOID)&ct_data, 0, &dwThId);
بدلاً من الاستدعاء المعتاد:
((DLLMAIN)entryPoint)((HINSTANCE)loaderStart, 4, NULL);
ULONG_PTR __cdecl ReflectiveLoader(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
// only run for a Thread creation event
if (fdwReason != DLL_THREAD_ATTACH) {
return TRUE;
}
...
}
تم دمج هاتين الخطوتين الإضافيتين في عرض توضيحي لـ UDRL.
VirtualAlloc
VirtualProtect
CreateThread
Sleep
MessageBoxA
InternetOpenW (يحتاج إلى التشغيل مع createThread = 1)
InternetOpenUrlA (يحتاج إلى التشغيل مع createThread = 1)