
مكتبة تسهّل استخدام استدعاءات النظام غير المباشرة (indirect syscalls). تقنية مثيرة للاهتمام لتجاوز أنظمة مكافحة الفيروسات وEDR كإثبات للمفهوم (PoC).
يقدّم هذا المشروع طريقة مريحة وحديثة لاستخدام دوال NTAPI عبر استدعاءات النظام غير المباشرة، مقترنةً بأسلوب FreshyCalls مع لمسة إضافية للاسترجاع الديناميكي لأرقام استدعاءات النظام. كما يستخدم تقنية لم أرَ من ذكرها لتجاوز فحص الذاكرة في ويندوز ديفندر. ينفّذ المشروع حاقن عمليات تجريبيًا (PoC) باستخدام المكتبة. وتتوفر أيضًا دوال مناسبة للتشفير.

لبناء العينة استخدم -DNULLGATE_BUILD_SAMPLE=ON.
إذا بنيت nullgate مباشرةً، سيكون الملف التنفيذي في <build_dir>/sample.exe، وإذا بنيته كاعتمادية (dependency) فسيكون في <build_dir>/_deps/nullgate-build/sample.exe.
على ويندوز، نظرًا لأن مسارات البناء غريبة، فغالبًا سيكون في نفس الدلائل الأساسية لمواقع العينات لكن متداخلًا أكثر.
يستقبل البرنامج معرف العملية (PID) التي تريد حقن الشيل كود فيها كوسيط.
[!WARNING] إذا كنت تستخدم لينكس، يجب تثبيت المترجم المختلط mingw. على Arch مثلًا يمكنك تنفيذ
pacman -S mingw-w64-gcc. ثم استخدم الخيار-DNULLGATE_CROSSCOMPILE=ONلتعيين mingw كمترجم افتراضي.
[!TIP] يُنصح أيضًا بإزالة الرموز من الملف الثنائي الناتج (strip) لتقليل احتمالية الاكتشاف.
git clone https://github.com/0xsch1zo/NullGate
cd NullGate
cmake . -B build -DNULLGATE_BUILD_SAMPLE=ON
cmake --build build/
يمكن بناؤه باستخدام العلم -DNULLGATE_DEPRECATED_HASHER.
يُدعم CMake FetchContent. إليك مثال على ملف CMakeLists.txt بسيط:
cmake_minimum_required(VERSION 3.25)
include(FetchContent)
FetchContent_Declare(nullgate
GIT_REPOSITORY https://github.com/0xsch1zo/NullGate
GIT_TAG 1.2.0
)
FetchContent_MakeAvailable(nullgate)
project(test)
add_executable(test
main.cpp
)
target_link_libraries(test
PRIVATE nullgate
)
الربط يتم بشكل ثابت (statically) لذلك لا داعي للقلق بشأن ظهور الرموز.
[!NOTE] الأمثلة التالية ستستخدم
namespace ng = nullgate
الاستخدام مباشر جدًا، إليك مقتطف يوضح الوظيفة الرئيسية:
ng::syscalls syscalls;
typedef NTSTATUS NTAPI NtAllocateVirtualMemory(
_In_ HANDLE ProcessHandle,
_Inout_ _At_(*BaseAddress,
_Readable_bytes_(*RegionSize) _Writable_bytes_(*RegionSize)
_Post_readable_byte_size_(*RegionSize)) PVOID *BaseAddress,
_In_ ULONG_PTR ZeroBits, _Inout_ PSIZE_T RegionSize,
_In_ ULONG AllocationType, _In_ ULONG PageProtection);
NTSTATUS status = syscalls.SCall<NtAllocateVirtualMemory>(
ng::obfuscation::fnv1Const("NtAllocateVirtualMemory"), processHandle,
&buf, 0, ®ionSize, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
هناك أمان أنواع مدمج. كل ما عليك هو توفير تعريف دالة nt التي تريد استدعاءها! يمكنك الحصول عليه بسهولة من ntdoc. هذه هي الطريقة الموصى بها لاستخدام المكتبة.
لمن لا يحبون السحر الأسود لقالب C++ أو ما شابه، الواجهة السابقة ما تزال متاحة:
NTSTATUS status = syscalls.Call(ng::obfuscation::fnv1Const("NtAllocateVirtualMemory"),
processHandle, (PVOID)&buf, (ULONG_PTR)0, ®ionSize,
(ULONG)(MEM_RESERVE | MEM_COMMIT), (ULONG)PAGE_EXECUTE_READWRITE);
باستخدام هذه الواجهة تحتاج إلى تحويل (cast) الوسائط إلى النوع الصحيح، وعدم القيام بذلك قد يسبب مشاكل.
constexpr uint64_t hash = ng::obfuscation::fnv1Const("NtAllocateVirtualMemory");
طريقة fnv1Const الموضحة سابقًا تجلب متعة لغة C++ الحديثة إلى عالم maldev. وهي دالة consteval، لذا من المضمون أن تُقيَّم في وقت الترجمة، لتحل محل اسم الدالة القابل للقراءة بتجزئة fnv1.
يوجد أيضًا مكافئ وقت تشغيل يُدعى fnv1Runtime لكنه بالطبع لا يضيف فائدة إخفاء أسماء دوالنا. يستخدمه التنفيذ الداخلي للتحقق من أي دالة داخل ntdll يحصل منها على رقم استدعاء النظام.
هناك ثلاث روتينات لتشفير xor:
constexpr ng::obfuscation::ConstData xored = ng::obfuscation::xorConst("some string");
std::cout << xored.string();
مخرج محتمل:
<Y:-E&X0
هذه الروتين، على غرار دالة fnv1Const، تُقيَّم في وقت الترجمة، لذلك لن يظهر "some string" أبدًا في الملف الثنائي، لأن السلسلة ستُخزَّن بصيغتها المشفرة.
لكن مهلاً، نحتاج فعلًا لاستخدام تلك البيانات! ConstData هو غلاف رفيع حول البايتات الخام بحجم ثابت.
لديه طريقتان: raw() و string().
raw() تُرجع std::vector<unsigned char> و string كما يوحي الاسم تُرجع std::string.
[!NOTE] إذا كنت بحاجة إلى إنشاء
ConstDataمباشرةً من سلسلة حرفية فاستخدمstd::to_arrayلإنشاء مصفوفة وسيطة تُمرَّر إلىConstData. لاحظ مع ذلك أن هذا الأسلوب سيجعلConstDataيخزّن حرف النهاية الفارغ الإضافي للسلسلة الحرفية.
بالطبع نحتاج إلى طريقة لفك تشفير هذه البيانات واستخدامها، وهذه هي الدالة التالية التي سنغطيها.
xorRuntime هو الروتين الثاني المتاح.
كما يوحي الاسم، فهو مكافئ وقت التشغيل لـ xorConst.
لاحظ أنه لا يُستخدم لفك التشفير فقط، بل يعمل في الاتجاهين، رغم أن استخدامه للتشفير لا يوفر فوائد إخفاء السلسلة في وقت الترجمة.
ng::obfuscation::ConstData xored = ng::obfuscation::xorConst("some string");
ng::obfuscation::ConstData data = ng::obfuscation::xorRuntime(xored);
assert(data.string() == "some string");
std::string dynamicString = "some data of dynamic size";
auto dynamicData = ng::obfuscation::DynamicData(dynamicString);
auto xoredDynamic = ng::obfuscation::xorRuntime(dynamicData);
auto unxoredDynamic = ng::obfuscation::xorRuntime(xoredDynamic);
assert(unxoredDynamic.string() == dynamicString);
DynamicData لديه نفس طرق ConstData مع بعض المنشئات الإضافية للتوافق، وكما يوحي الاسم يمكن أن يكون بحجم غير معروف في وقت الترجمة.
xorRuntime يقبل كلاً من DynamicData و ConstData.
بينما يمكننا دائمًا فك التشفير في وقت التشغيل باستدعاء xorConst أولاً ثم تمرير النتيجة إلى xorRuntime، إلا أن ذلك ممل نوعًا ما.
هنا يأتي حل هذه المشكلة، وهو الجزء الأجمل: xorRuntimeDecrypted، دالة مفيدة للسلسلة الحرفية التي لا تحتاج إلى معالجة وهي في الحالة المشفرة.
إنها تشفر السلسلة الحرفية في وقت الترجمة ثم تفك تشفيرها في وقت التشغيل، كل ذلك في استدعاء واحد. أليس رائعًا!
ng::obfuscation::ConstData text = ng::obfuscation::xorRuntimeDecrypted<"some string">();
assert(data.string() == "some string");
قد يقول أحدهم: كل شيء رائع، لكن أين المفتاح؟ في nullgate 1.2 يتم توليد المفتاح عشوائيًا في كل بناء جديد! (أي عند تشغيل أمر cmake) هذا يقلل من فرصة رصد التوقيعات أكثر.
جوهر المشكلة هو أنه عندما نستدعي NtCreateRemoteThreadEx أو NtCreateProcess، يتم تشغيل فحص للذاكرة ويتم اكتشاف حمولة msfvenom التي تحمل توقيعات واضحة جدًا.
أحد الحلول المعروفة هو أنه عند استدعاء NtAllocateVirtualMemory نضبط صلاحيات الصفحة إلى PAGE_NOACCESS أولاً، ثم ننشئ الخيط في حالة معلّقة (suspended).
عندها سيفشل ويندوز ديفندر في فحص ذاكرة عمليتنا.
يمكننا بعد ذلك استئناف تنفيذ الخيط باستخدام NtResumeThread.
هذا يعمل، لكن ماذا لو كان الحل الأمني المستخدم أكثر كفاءة؟ ماذا سيفعل؟
بالطبع سيستخدم VirtualProtect لتغيير صلاحيات صفحتنا واكتشاف msfvenom.
لتجاوز ذلك غيّرت الاستراتيجية قليلاً. بدلاً من ضبط الصفحة على PAGE_NOACCESS، أثناء كتابتنا الأولى إلى ذاكرة العملية يمكننا ببساطة وضع بعض البيانات غير المهمة (junk data) في العملية (نعم هذا مطلوب، أو أنا فقط غبي جدًا لأجد طريقة تعمل بدونه).
ثم ننشئ خيطًا في حالة معلّقة.
بعد ذلك نكتب الشيل كود المطلوب إلى العملية، وأخيرًا نستأنف الخيط باستخدام NtResumeThread.
بهذه التقنية لا داعي للقلق من الوصول إلى ذاكرتنا بعد استدعاء NtCreateThreadEx لأنه لا يوجد شيء هناك.
فقط بعد ذلك يُكتب الشيل كود المفكوك التشفير ويُستأنف التنفيذ.
صُنعت هذه المكتبة لأغراض أكاديمية فقط. المؤلفون غير مسؤولين عما يُقدَّم لهذه المكتبة، وبالتالي نحن معفيون من أي مسؤولية تنشأ عن سوء استخدامها.