هذا المقال يتطلب مستوى: محترف. لو بتكتب تعبيرات نمطية (Regex) على مدخلات جايالك من المستخدم، فيه سطر واحد ممكن يوقّف السيرفر بالكامل من غير ما حد يهاجمك عمداً.
ReDoS: لما تعبير نمطي واحد يقفل السيرفر
في 2 يوليو 2019، خدمات Cloudflare وقعت عالمياً حوالي 27 دقيقة. السبب مكانش هجوم؛ كان تعبير نمطي واحد اترفع في قاعدة الـ WAF، خلّى استهلاك المعالج يقفز لـ100% على كل سيرفراتهم في نفس اللحظة. المقال ده هيوريك بالظبط إزاي بيحصل ده، وإزاي تكتشفه وتصلحه قبل ما يحصل عندك.
المشكلة باختصار
التعبير النمطي المفروض أداة بحث سريعة. لكن نوع معين من الأنماط بيتحول من بحث خطي إلى انفجار أُسّي في عدد الخطوات. المدخل الصغير — 30 حرف يمكن — بياكل المعالج ثواني أو دقائق ويجمّد الـ thread بالكامل. ده اسمه ReDoS: حجب خدمة عن طريق تعبير نمطي (Regular expression Denial of Service).
المفهوم الأساسي: التراجع (Backtracking) بمثال بسيط
تخيّل قفل حقيبة بـ 3 عجلات أرقام. اللص بيجرب توليفة، لو غلط بيرجع خطوة ويغيّر آخر عجلة، يجرب تاني، وهكذا. طول ما فيه احتمالات متبقية، بيرجع ويعيد المحاولة. ده بالظبط اللي محرك الـ Regex بيعمله لما النمط بيسمح بأكتر من طريقة لتقسيم نفس النص: بيجرب تقسيمة، تفشل، يرجع (backtrack) ويجرب تقسيمة تانية.
دلوقتي المفهوم بدقة: محركات regex التقليدية في Python و JavaScript و PCRE بتشتغل بالـ backtracking. نمط زي (a+)+ بيسمح بتقسيم سلسلة الـ a بأكتر من طريقة، وعدد التقسيمات الممكنة بيكبر أُسّياً مع طول السلسلة. طول ما النص بيطابق، المحرك بيلاقي حل بسرعة. المشكلة بتحصل لما النص يفشل في المطابقة النهائية — مثلاً حرف مش a في الآخر. ساعتها المحرك مجبور يجرب كل التقسيمات الممكنة قبل ما يتأكد إن مفيش مطابقة. ده بيخلي التعقيد O(2^n) بدل O(n).
اكتشفها وقيسها بنفسك
الكود ده بيبني القنبلة ويقيس الزمن مع طول المدخل. جرّبه:
import re, time
evil = re.compile(r"(a+)+$") # النمط الخطر: كمية متداخلة
for n in (20, 24, 28, 30):
s = "a" * n + "!" # a كتير + حرف بيفشل المطابقة
t = time.perf_counter()
evil.search(s)
print(n, round(time.perf_counter() - t, 3), "s")
النتيجة على جهاز اختبار عندي (أرقام تقديرية، بتختلف حسب المعالج وإصدار Python):
- 20 حرف ← حوالي 0.01 ثانية
- 24 حرف ← حوالي 0.2 ثانية
- 28 حرف ← حوالي 3.4 ثانية
- 30 حرف ← حوالي 14 ثانية
ركز في النمط: كل حرف زيادة بيضاعف الزمن تقريباً. 30 حرف بس عملوا 14 ثانية تجميد. تخيّل السطر ده في handler بيرد على request، والمدخل جاي من فورم على النت.
الحل: تلات مستويات بترتيب الأولوية
- أعد صياغة النمط. القنبلة دايماً كمية متداخلة بتقبل نفس الحرف:
(a+)+،(a*)*،(a+)*، أو(a|a)+. النمط(a+)+$بيتبسّط لـa+$بنفس المعنى وبزمن خطي. ده أرخص حل وأسرعه لو النمط بسيط. - حدّد طول المدخل قبل الـ regex. ارفض أي مدخل أطول من حد منطقي (مثلاً 1KB لحقل بريد) قبل ما توصّله للمحرك أصلاً. بيوقف الغالبية العظمى من الهجمات بسطر واحد.
- استخدم محرك خطي. مكتبة زي RE2 من Google بتضمن زمن O(n) لأنها مبتعملش backtracking خالص. في Python فيه حزمة
google-re2، وفي Go الـregexpالقياسي مبني على RE2 من الأساس.
# 1) نفس النمط بعد التبسيط — زمن خطي
good = re.compile(r"a+$")
# 2) أو استخدم RE2 لو محتاج تسيب النمط زي ما هو
# pip install google-re2
import re2
p = re2.compile(r"(a+)+$")
p.search("a" * 100000 + "!") # بيرجع فوراً، من غير تجميد
ملاحظة للـ Node.js: محرك V8 مفيهوش timeout للـ regex افتراضياً، فالـ thread بيتجمّد. الحل العملي: انقل الأنماط الخطرة لمكتبة re2 في Node، أو شغّل المطابقة في worker مع مهلة تقتله لو عدّى حد معين.
الـ trade-offs
- إعادة الصياغة: بتكسب حل مجاني وسريع، بتخسر إنه مش كل نمط سهل تبسيطه، ولازم تفهم نية النمط الأصلي كويس عشان متكسرش سلوكه.
- RE2: بتكسب ضمان الزمن الخطي، بتخسر مزايا زي الـ backreferences والـ lookahead — RE2 مبيدعمهمش. لو نمطك محتاجهم، RE2 مش خيار وهتضطر تعتمد على تحديد الطول والـ timeout.
- تحديد طول المدخل: بتكسب دفاع بسيط وفعّال، بتخسر إنه ممكن يرفض مدخل شرعي طويل. حدد الحد حسب حالتك الفعلية مش رقم عشوائي.
الافتراض هنا إنك بتطبّق regex على مدخل مصدره المستخدم. لو المدخل داخلي وثابت وطوله محدود، الخطر أقل بكتير.
متى لا تستخدم هذه الاحتياطات
لو التعبير النمطي بتطبّقه على نصوص إنت مسيطر عليها بالكامل — ثوابت في الكود، طول محدود، مصدر موثوق — نقل كل حاجة لـ RE2 مبالغة وبيضيّع وقتك في تعقيد مش محتاجه. الأولوية للمدخلات الخارجية بس: حقول الفورم، الـ query params، الـ headers، وأسماء الملفات المرفوعة.
المصادر
- Cloudflare — Details of the Cloudflare outage on July 2, 2019 (تفاصيل العطل وسببه: نمط فيه
.*(?:.*=.*)سبّب backtracking كارثي). - OWASP — Regular expression Denial of Service (ReDoS).
- Google RE2 (محرك خطي بدون backtracking).
- توثيق Python — وحدة re.
- MDN — RegExp.
الخطوة التالية
افتح الكود بتاعك ودوّر على أنماط فيها كمية متداخلة: (X+)+، (X*)*، (X+)*، أو (a|a)+. جرّب عليها سكربت القياس اللي فوق بمدخل 30 حرف مطابق جزئياً. لو الزمن قفز فجأة، انت لقيت قنبلة موقوتة. صلّحها بإعادة الصياغة أو انقلها لـ RE2، وابعتلي النمط لو محتاج مساعدة في تبسيطه.