هذا المقال لمستوى متوسط. يفترض إنك بتكتب regex في مشروع ويب (Node أو Python أو أي backend) وإن جزء من الإدخال جاي من المستخدم.
regex واحد غلط يقدر يطلّع المعالج لـ 100% ويوقّع الخدمة كلها بإدخال طوله 30 حرف. مش هجوم معقّد ولا اختراق، مجرد نص بسيط بيخلّي محرك الـ regex يدخل في لفّة حسابية أُسّية. في المقال ده هتعرف إزاي تكتشف الـ regex الخطير ده، وتصلّحه بسطر، وتقيس الفرق بنفسك.
ReDoS: لما تعبير regex واحد يوقّع السيرفر
المشكلة باختصار
ReDoS اختصار لـ Regular expression Denial of Service. الفكرة: في أنواع من الـ regex زمن تنفيذها بيكبر أُسّيًا مع طول الإدخال. يعني كل حرف زيادة بيقرّب يضاعف الزمن. لو الإدخال جاي من المستخدم، أي حد يقدر يبعت نص قصير يخلّي خيط المعالجة يفضل شغّال ثوانٍ أو دقايق، ويستهلك الـ CPU بالكامل. ده مش سيناريو نظري: في 2 يوليو 2019 وقعت Cloudflare عالميًا بسبب regex فيه backtracking كارثي وصل بالـ CPU لـ 100% على كل السيرفرات. وقبلها بسنين وقعت Stack Overflow 34 دقيقة بسبب regex بيشيل المسافات.
ليه بيحصل ده أصلاً؟ الفكرة بمثال بسيط
تعالى نبسّطها الأول. تخيّل إنك بتدوّر على طريقة تقسم بيها كلمة "aaaa" على مجموعتين، وكل مجموعة لازم تكون حرف واحد أو أكتر. ممكن تقسمها: (a)(aaa)، أو (aa)(aa)، أو (aaa)(a)، أو (a)(a)(aa)... وهكذا. عدد الطرق بيكبر بسرعة جنونية كل ما تزود حرف.
ده بالظبط اللي بيحصل في تعبير زي ^(a+)+$. عندك a+ جوّه (...)+ تاني. الاتنين بيقدروا "يبلعوا" نفس الحروف بطرق كتير مختلفة. طول ما النص بيطابق، المحرك بيمشي عادي. لكن أول ما يلاقي حرف في الآخر بيكسر المطابقة (زي !)، بيرجع لورا ويجرّب كل التقسيمات الممكنة قبل ما يستسلم. ده اسمه backtracking، ولما التقسيمات تكون أُسّية يبقى اسمه catastrophic backtracking.
علميًا: محرك الـ regex في JavaScript (V8) وPython وJava وPCRE بيستخدم خوارزمية مبنية على backtracking. لما يكون عندك كمّيات متداخلة أو متداخلة الحدود (nested / overlapping quantifiers)، عدد المسارات اللي المحرك بيجرّبها بيبقى رتبته O(2^n) بالنسبة لطول الإدخال n. الإدخال اللي بيطابق جزئيًا بعدين يفشل في آخر حرف هو أسوأ حالة، لأنه بيجبر المحرك يستكشف كل المسارات.
اقيسها بنفسك — كود Node شغّال
الكود ده بيوضّح الانفجار. شغّله وزوّد الرقم حرف حرف:
// regex فيه nested quantifier — خطير
const evil = /^(a+)+$/;
for (const n of [24, 26, 28, 30, 32]) {
const input = 'a'.repeat(n) + '!'; // نص بيطابق جزئيًا وبعدين يفشل
const t = process.hrtime.bigint();
evil.test(input);
const ms = Number(process.hrtime.bigint() - t) / 1e6;
console.log(`n=${n} time=${ms.toFixed(1)}ms`);
}
أرقام تقريبية مقاسة على Node 20 / V8 (بتختلف حسب الجهاز، لكن النسبة ثابتة):
- n=24 ⟵ حوالي 15 ميلي ثانية
- n=26 ⟵ حوالي 60 ميلي ثانية
- n=28 ⟵ حوالي 240 ميلي ثانية
- n=30 ⟵ حوالي ثانية كاملة
- n=32 ⟵ حوالي 4 ثوانٍ
ركز في النمط: كل حرفين زيادة بيضربوا الزمن في 4 تقريبًا. عند n=40 الزمن بيبقى دقايق. خلال الثواني دي الخيط بتاع Node متوقّف بالكامل — ولأن Node single-threaded في الـ event loop، السيرفر كله مش بيرد على أي طلب تاني.
الحل — ثلاث طرق بترتيب الأولوية
- أعد كتابة الـ regex وشيل التداخل. ده أرخص حل وأقواه. لو هدفك مطابقة سلسلة من
a، اكتب^a+$بدل^(a+)+$. النتيجة نفسها لكن خطية:
const safe = /^a+$/; // بدون nested quantifier — O(n)
const input = 'a'.repeat(100000) + '!';
const t = process.hrtime.bigint();
safe.test(input);
console.log(Number(process.hrtime.bigint() - t) / 1e6, 'ms'); // أقل من 1ms
القاعدة العملية: أي (X+)+ أو (X*)* أو (X+)* أو بدائل متداخلة بتطابق نفس الحروف، دي راية حمرا. غالبًا تقدر تبسّطها.
- استخدم محرك خطي زي RE2. لو الـ regex معقّد ومش عايز تخاطر، RE2 من Google بيستخدم automata بدل backtracking، فزمنه خطي مهما كان التعبير:
npm install re2const RE2 = require('re2');
const re = new RE2('^(a+)+$'); // نفس التعبير "الخطير"
const input = 'a'.repeat(50000) + '!';
const t = process.hrtime.bigint();
re.test(input);
console.log(Number(process.hrtime.bigint() - t) / 1e6, 'ms'); // ميلي ثانية واحدة
- افحص الـ regex آليًا قبل ما تنشر. أداة زي
recheckبتقولك إذا كان تعبير معيّن قابل للاستغلال:
npx recheck check '^(a+)+$' # بيرجّع: vulnerableوكطبقة أخيرة، حُط حد أقصى لطول الإدخال قبل ما يوصل للـ regex (مثلاً ارفض أي حقل أطول من 256 حرف). ده مش حل للجذر لكنه بيقلّل سطح الهجوم.
الـ trade-offs اللي لازم تنتبه لها
- RE2 بيكسب الأمان ويخسر مميزات. مفيهوش backreferences ولا lookbehind/lookahead بالكامل. لو regex بتاعك معتمد عليها، RE2 هيرفضه. المكسب: زمن خطي مضمون. الخسارة: جزء من تعبيرية الـ regex.
- حد طول الإدخال بسيط لكنه أعمى. ممكن يرفض إدخال صحيح طويل. اختر الحد حسب بياناتك الفعلية.
- إعادة الكتابة أرخص لكن بتحتاج فهم. لازم تتأكد إن التعبير الجديد بيطابق نفس اللغة. اختبره على حالات حقيقية قبل وبعد.
متى لا تشغّل بالك بالموضوع ده
لو الـ regex بيشتغل على إدخال انت مصدره ومتحكّم في شكله وطوله (مثلاً ثابت في الكود أو جاي من ملف config موثوق)، خطر ReDoS عمليًا صفر. كمان لو تعبيرك مفيهوش كمّيات متداخلة ولا بدائل متداخلة، فهو خطي أصلاً ومفيش داعي تعقّد حياتك بـ RE2. المشكلة بتظهر تحديدًا لما يجتمع شرطان: إدخال من المستخدم + تعبير فيه تداخل.
الخطوة التالية
افتح الـ codebase بتاعك ودوّر على أي regex بيتطبّق على إدخال مستخدم، وعدّي كل واحد على npx recheck check '...'. أي تعبير يرجع vulnerable، إمّا بسّطه أو لفّه بـ RE2. ابدأ بالـ endpoints اللي بتستقبل نصوص حرّة (بحث، تعليقات، فلاتر) لأنها الأكثر عرضة.
المصادر
- OWASP — Regular expression Denial of Service (ReDoS) — تعريف الهجوم وأنماط التعبيرات الخطيرة.
- Cloudflare — Details of the Cloudflare outage on July 2, 2019 — تعطّل عالمي بسبب catastrophic backtracking ووصول الـ CPU لـ 100%.
- Stack Overflow — Outage Postmortem, July 20, 2016 — انقطاع 34 دقيقة بسبب regex backtracking في تنظيف المسافات.
- Google RE2 وnode-re2 — محرك regex خطي مبني على automata.
- recheck — أداة كشف ReDoS الثابتة المستخدمة في الأمثلة.
- MDN — Regular expressions — مرجع سلوك محرك regex في JavaScript.