سطر regex واحد ممكن يخلّي سيرفرك يصرف 100% CPU لدقائق على input طوله 30 حرف فقط. ده مش سيناريو نظري — ده اللي حصل لـ Cloudflare في 2 يوليو 2019 وفصل جزء كبير من الإنترنت 27 دقيقة كاملة. المقال ده هيوريك بالظبط ليه بيحصل، وإزاي تمنعه قبل ما يحصلك.
ReDoS: لما سطر Regex واحد بيقفل السيرفر
المشكلة باختصار
الـ ReDoS (Regular Expression Denial of Service) هو هجوم أو خطأ بسيط في كتابة regex بيخلّي الـ engine يدخل في عدد محاولات أسي (exponential) بدل ما يرجّع نتيجة في الوقت الطبيعي. الكود اللي بيشتغل في 2ms على input نضيف، بياخد 30 ثانية أو أكتر لما الـ input يبقى مصمم مخصوص.
مثال بسيط لأي مبتدئ: قصة رف المطابخ
تخيّل معاك رف فيه 30 علبة، وبتدور على علبة مكتوب عليها "ملح". الطريقة المنطقية: تبص على كل علبة مرة واحدة. لو لقيتها، توقف. لو خلصت الـ 30 علبة، تستنتج إنها مش موجودة. بالظبط 30 محاولة.
طيب تخيّل إن صاحب المحل غبي شوية. بدل ما يكتفي بالمحاولة الأولى، لما ميلاقيش الملح هو بيقول: "يمكن أنا بصيت بالترتيب الغلط". فبيعيد، بس المرة دي يقلب العلبتين الأولانيتين، يدور تاني. مفيش؟ يقلب العلب التلاتة الأولانيين، يدور تاني. ومع كل فشل، بيجرّب توليفة جديدة من ترتيب العلب.
عدد التوليفات الممكنة لـ 30 علبة = 2^30، يعني أكتر من مليار محاولة. هو ده بالظبط اللي بيحصل جوّا الـ regex engine لما يقابل catastrophic backtracking.
التفسير العلمي الدقيق: Catastrophic Backtracking
معظم الـ regex engines في JavaScript و Python و Java و .NET و PHP بتشتغل بطريقة اسمها NFA with backtracking. يعني الـ engine بيبني Nondeterministic Finite Automaton ويجرّب كل مسار ممكن لحد ما يلاقي match (أو يفشل).
المشكلة بتظهر لما يبقى عندك quantifier متداخل مع quantifier تاني، زي (a+)+ أو (.*)+. الـ engine هنا عنده أكتر من طريقة عشان يقسّم نفس الـ input بين الجروبات الداخلية والخارجية. لو الـ input في آخره حرف بيمنع الـ match، الـ engine بيرجع ويجرّب كل التوزيعات الممكنة قبل ما يستسلم. عدد المسارات = O(2^n) حيث n طول الـ input.
الـ engines المحصّنة (زي Google RE2 و Rust's regex crate) بتستخدم Deterministic Finite Automaton — بتديك ضمان O(n) دايماً، بس بتتنازل عن features زي \1 backreferences و lookaheads.
كود تقدر تجرّبه دلوقتي
شغّل الكود ده في Node.js وشوف بعنيك الفرق بين input نضيف وinput خبيث:
// ملف: redos-demo.js
const evilPattern = /^(a+)+$/;
function measure(input, label) {
const start = Date.now();
evilPattern.test(input);
const elapsed = Date.now() - start;
console.log(`${label}: ${elapsed}ms`);
}
// Input نضيف — match ناجح وسريع
measure("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "match ناجح (30 حرف)");
// Input خبيث — آخر حرف مختلف، الـ engine هيحاول كل تركيبة
measure("aaaaaaaaaaaaaaaaaaaaaaaaaaaaa!", "match فاشل (30 حرف + !)");
// على لاب توب عادي: > 30,000ms
المتوقّع: الأول أقل من 1ms، التاني بيوقف الـ thread أكتر من نص دقيقة. الفرق مش linear، ده exponential — كل حرف "a" تضيفه في الـ input الخبيث بيضاعف الزمن.
قصة Cloudflare: 27 دقيقة وقف فيها نص الإنترنت
في 2 يوليو 2019 الساعة 13:42 UTC، رفع مهندس في Cloudflare قاعدة WAF جديدة لمنع هجمات XSS. القاعدة دي كان فيها regex بالشكل التقريبي .*.*=.*. المعنى الحرفي: "طابق أي شيء، بعدين أي شيء، بعدين علامة يساوي، بعدين أي شيء".
لما الـ WAF استقبلت request جواها محتوى طويل من غير =، الـ regex engine (PCRE) دخل في backtracking كارثي. نتيجة: كل الـ CPUs في كل datacenters Cloudflare وصلت 100% في خلال دقيقة واحدة. 502 errors لـ 27 دقيقة على مليارات الطلبات حول العالم. مواقع زي Discord و Coinbase و Feedly وقعت بالتبعية.
الحل اللي اعتمدته Cloudflare بعدها: التحوّل لـ RE2 اللي بيدّي ضمان linear time، ومراجعة يدوية لكل 3,868 قاعدة WAF للتأكد إن مفيش فيها pattern قابل للـ backtracking.
4 طرق عملية لحماية نفسك
- اعيد كتابة الـ pattern. استبدل
(a+)+بـa+. استبدل(.*a)+بـ([^a]*a)+. الفكرة: امنع الـ engine من إنه يقسّم نفس الحرف بين جروبين. - استخدم atomic groups أو possessive quantifiers. الصيغة
(?>...)بتمنع الـ backtracking داخل الجروب. متوفرة في Java و PCRE و Python 3.11+. - حط timeout على تنفيذ الـ regex. في Python استخدم
signal.alarmأو مكتبةregexاللي بتقبل parameter اسمهtimeout. في Node.js، شغّل الـ regex في worker thread معterminate()بعد ثانية. - بدّل الـ engine. استخدم
re2في Python (pip install google-re2) أو Rustregexcrate. دول بيضمنوا linear time.
كود Python بيوضح طريقة 3 و 4:
import re2 # pip install google-re2
# آمن ضد ReDoS بضمان engine level
pattern = re2.compile(r"^(a+)+$")
result = pattern.match("aaaaaaaaaaaaaaaaaaaaaaaaaaaaa!")
# بيرجع None في مللي ثواني بدل الدقائقtrade-offs لازم تعرفها قبل ما تبدّل
لو اخترت تبدّل لـ RE2، الـ trade-off صريح: هتكسب ضمان O(n) على كل input، لكن هتخسر backreferences (\1, \2) و lookaheads/lookbehinds. في تطبيقات validation بسيطة زي emails أو slugs ده مش هيأثر. في parsers معقّدة بتعتمد على backreferences، هتحتاج refactor.
لو اخترت إعادة كتابة الـ pattern، بتكسب توافق كامل مع كل features الـ regex، بس الـ trade-off إنك محتاج تراجع يدوي، وخطأ بشري واحد ممكن يرجّع المشكلة. الحل الوسطي: استخدم linter زي safe-regex (لـ JS) أو regexploit لكشف الـ patterns الخطيرة قبل الـ deploy.
متى ميبقاش ReDoS خطر عليك
لو الـ regex بتاعتك بتشتغل على input جاي من مصدر موثوق (enum من backend، أو قيم ثابتة من config)، الـ ReDoS مش مخاطرة حقيقية. التركيز يبقى على نقاط الـ user input: form fields، query params، webhook payloads، HTTP headers. كمان لو الـ input محدود الطول (مثلاً username أقصاه 20 حرف)، الـ exponential هيفضل في حدود آمنة عمليًا — بس دي حماية بالمصادفة مش بالتصميم.
الخطوة التالية
افتح مشروعك دلوقتي وابحث بـ grep عن الأنماط دي: (.*)+, (.+)+, (a|a)+, (a|ab)+, وأي quantifier داخل quantifier. شغّل عليهم linter زي safe-regex:
npm install -g safe-regex
safe-regex "^(a+)+$"
# output: false → الـ pattern خطير
لو لقيت pattern واحد راجع، ابدأ بيه وحدّه. أهم حاجة: مش مطلوب منك تبدّل الـ engine بالكامل النهارده، المطلوب تعرف فين بالظبط الـ regex الخطير وتحطّ عليه timeout كحد أدنى.
المصادر
- Cloudflare Engineering Blog — Details of the Cloudflare outage on July 2, 2019
- OWASP — Regular expression Denial of Service (ReDoS)
- Snyk Learn — ReDoS Tutorial & Examples
- Sonar — A comprehensive guide to the dangers of Regular Expressions in JavaScript
- Wikipedia — ReDoS
- Google RE2 — Safe regex engine on GitHub