কেস স্টাডি: বাস্তব রেগেক্স ইঞ্জিন ও তাদের সীমাবদ্ধতা
এই পাঠে যা শিখবেন
- বাস্তব regex ইঞ্জিন ও ফরমাল রেগুলার এক্সপ্রেশনের মধ্যে সুনির্দিষ্ট, গুরুত্বপূর্ণ পার্থক্য
- ব্যাকরেফারেন্স কেন ও কীভাবে একটি ভাষাকে নন-রেগুলার (এমনকি নন-কনটেক্সট-ফ্রি) করে তোলে
- ক্যাটাস্ট্রফিক ব্যাকট্র্যাকিং কী, এবং কেন এটি বাস্তব সফটওয়্যারে একটি প্রকৃত নিরাপত্তা ঝুঁকি
- একটি সত্যিকারের, কোড-ভেরিফায়েড তুলনা — DFA-ভিত্তিক matcher বনাম ব্যাকরেফারেন্স-সাপোর্টিং ব্যাকট্র্যাকিং matcher
১ · বাস্তব regex ইঞ্জিন কি সত্যিই "রেগুলার এক্সপ্রেশন"?
M12/L51-এ আমরা উল্লেখ করেছিলাম আধুনিক টেক্সট এডিটর/গ্রেপ-স্টাইল টুল একটি regex প্যাটার্নকে একটি অটোমাটায়
কম্পাইল করে (M2/L11-এর ক্লিনির থিওরেম) দ্রুত, লিনিয়ার-টাইম সার্চের জন্য। কিন্তু এখানে একটি গুরুত্বপূর্ণ, প্রায়ই
উপেক্ষিত সত্য আছে — Python-এর re মডিউল, PCRE (Perl-Compatible Regular Expressions), বা
JavaScript-এর regex — এগুলো ঠিক M2/L10-এর ফরমাল সংজ্ঞা অনুযায়ী রেগুলার এক্সপ্রেশন নয়। বাস্তব
ইঞ্জিনগুলো অতিরিক্ত ফিচার সাপোর্ট করে যা M2/L10-এর রিকার্সিভ সংজ্ঞার (∅, ε, প্রতীক, union, concatenation,
Kleene star) অংশ নয় —
যেমন
(a+)\1 — "গ্রুপ ১-এ যা ম্যাচ হয়েছে, ঠিক সেই একই সাবস্ট্রিং আবার পরে ম্যাচ করা।"বর্তমান পজিশনের আগে/পরে কী আছে তা "উঁকি দিয়ে" পরীক্ষা করা, কিন্তু তা consume না করে।
২ · কেন ব্যাকরেফারেন্স ফাইনাইট অটোমাটা দিয়ে প্রকাশযোগ্য নয়
$\{ww : w \in \Sigma^*\}$ ভাষাটি বিবেচনা করুন — যেসব স্ট্রিং কোনো একটি সাবস্ট্রিং $w$-এর ঠিক দুইবার পুনরাবৃত্তি
(যেমন "abab", "xyzxyz")। এটি ঠিক M8/L34-এর একই ভাষা যা একটি টুরিং মেশিন দিয়ে ডিজাইন করেছিলাম। M3/L13-14-এর
পাম্পিং-লেমা-স্টাইল যুক্তি দিয়ে সহজেই প্রমাণ করা যায় এই ভাষাটি রেগুলার নয় (এমনকি কনটেক্সট-ফ্রিও নয়) — কোনো DFA,
NFA, বা M2/L10-এর ফরমাল regex দিয়ে এটি প্রকাশ করা সম্ভব নয়। কিন্তু একটি ব্যাকরেফারেন্স প্যাটার্ন
(.*)\1 ঠিক এই ভাষাটিই প্রকাশ করে — সরাসরি প্রমাণ করে বাস্তব regex ইঞ্জিন ফরমাল রেগুলার
এক্সপ্রেশনের চেয়ে কঠোরভাবে বেশি শক্তিশালী।
M2/L11-এর DFA-সিমুলেশন প্রতিটি ইনপুট ক্যারেক্টার ঠিক একবার পড়ে — গ্যারান্টিড $O(n)$ সময়, কোনো ব্যতিক্রম ছাড়াই। কিন্তু ব্যাকরেফারেন্স/লুকঅ্যাহেড সাপোর্ট করতে হলে ইঞ্জিনকে ব্যাকট্র্যাকিং সার্চ ব্যবহার করতে হয় — একাধিক সম্ভাব্য ম্যাচ-পাথ চেষ্টা করা, ব্যর্থ হলে ফিরে গিয়ে অন্য পথ চেষ্টা করা। এই নমনীয়তার মূল্য: নির্দিষ্ট প্যাটার্নে matching সময় এক্সপোনেনশিয়াল হয়ে যেতে পারে।
৩ · ক্যাটাস্ট্রফিক ব্যাকট্র্যাকিং — একটি বাস্তব নিরাপত্তা ঝুঁকি
নেস্টেড কোয়ান্টিফায়ার সহ কিছু প্যাটার্ন (যেমন $(a^*)^*b$ বা $(a^+)^+b$) নির্দিষ্ট, সাধারণত-দেখতে ইনপুটে
ব্যাকট্র্যাকিং ইঞ্জিনে এক্সপোনেনশিয়াল matching সময় নিতে পারে — একে
ক্যাটাস্ট্রফিক ব্যাকট্র্যাকিংCatastrophic Backtrackingনেস্টেড কোয়ান্টিফায়ার-সহ regex প্যাটার্ন ব্যর্থ ম্যাচের জন্য এক্সপোনেনশিয়ালভাবে অনেক সম্ভাব্য পাথ চেষ্টা করে, ফলে matching সময় বিস্ফোরকভাবে বেড়ে যায়।
বলা হয়। এটি একটি সত্যিকারের, ভালোভাবে-ডকুমেন্টেড ডিনায়াল-অফ-সার্ভিস (ReDoS) ভালনারেবিলিটির উৎস (cross-ref
../cybersecurity/) — বাস্তব ওয়েব সার্ভিসে একটি সাবধানে-তৈরি ইনপুট স্ট্রিং পাঠিয়ে পুরো সার্ভার আটকে
রাখা সম্ভব হয়েছে বাস্তব ঘটনায়।
৪ · কোড দিয়ে তুলনা — DFA বনাম ব্যাকরেফারেন্স-সাপোর্টিং ব্যাকট্র্যাকিং matcher
নিচের কোড সেলে দুটি সম্পূর্ণ, সত্যিকারের matcher বাস্তবায়ন করা হয়েছে — কোনো re মডিউল ব্যবহার
ছাড়াই। প্রথমটি M2/L11-এর regex → NFA → DFA পাইপলাইন পুনরায় ব্যবহার করে (গ্যারান্টিড লিনিয়ার-টাইম, ব্যাকরেফারেন্স
সম্পূর্ণ অসম্ভব)। দ্বিতীয়টি একটি সত্যিকারের ব্যাকট্র্যাকিং ইঞ্জিন — গ্রুপ ক্যাপচার ও ব্যাকরেফারেন্স সাপোর্ট করে,
recursive continuation-passing দিয়ে বাস্তবায়িত।
# অংশ A -- DFA-ভিত্তিক matcher (M2/L11-এর Kleene পাইপলাইন) -- লিনিয়ার-টাইম, ব্যাকরেফারেন্স অসম্ভব
def regex_to_nfa(node, counter):
kind = node[0]
if kind == 'lit':
s0, s1 = next(counter), next(counter)
return {s0, s1}, s0, {s1}, {(s0, node[1]): {s1}}, {}
if kind == 'union':
Q1, s1, F1, D1, E1 = regex_to_nfa(node[1], counter)
Q2, s2, F2, D2, E2 = regex_to_nfa(node[2], counter)
s0, sf = next(counter), next(counter)
eps = {k: set(v) for k, v in E1.items()}
for k, v in E2.items(): eps.setdefault(k, set()).update(v)
eps.setdefault(s0, set()).update({s1, s2})
for f in F1: eps.setdefault(f, set()).add(sf)
for f in F2: eps.setdefault(f, set()).add(sf)
return Q1 | Q2 | {s0, sf}, s0, {sf}, {**D1, **D2}, eps
if kind == 'concat':
Q1, s1, F1, D1, E1 = regex_to_nfa(node[1], counter)
Q2, s2, F2, D2, E2 = regex_to_nfa(node[2], counter)
eps = {k: set(v) for k, v in E1.items()}
for k, v in E2.items(): eps.setdefault(k, set()).update(v)
for f in F1: eps.setdefault(f, set()).add(s2)
return Q1 | Q2, s1, F2, {**D1, **D2}, eps
if kind == 'star':
Q1, s1, F1, D1, E1 = regex_to_nfa(node[1], counter)
s0, sf = next(counter), next(counter)
eps = {k: set(v) for k, v in E1.items()}
eps.setdefault(s0, set()).update({s1, sf})
for f in F1: eps.setdefault(f, set()).update({s1, sf})
return Q1 | {s0, sf}, s0, {sf}, D1, eps
raise ValueError(node)
def eps_closure(states, eps):
stack, result = list(states), set(states)
while stack:
q = stack.pop()
for nxt in eps.get(q, ()):
if nxt not in result:
result.add(nxt); stack.append(nxt)
return frozenset(result)
def subset_construction(alphabet, start, accept, D, eps):
start_set = eps_closure({start}, eps)
dfa_states, dfa_trans, dfa_accept = {start_set}, {}, set()
frontier = [start_set]
while frontier:
S = frontier.pop()
if S & accept: dfa_accept.add(S)
for a in alphabet:
nxt = set()
for q in S: nxt |= D.get((q, a), set())
nxt = eps_closure(nxt, eps)
dfa_trans[(S, a)] = nxt
if nxt not in dfa_states:
dfa_states.add(nxt); frontier.append(nxt)
return dfa_states, start_set, dfa_accept, dfa_trans
class DFAMatcher:
def __init__(self, regex_ast, alphabet):
counter = iter(range(10**6))
Q, s0, F, D, eps = regex_to_nfa(regex_ast, counter)
self.states, self.start, self.accept, self.trans = subset_construction(alphabet, s0, F, D, eps)
def matches(self, s):
steps, state = 0, self.start
for ch in s:
steps += 1
state = self.trans.get((state, ch), frozenset())
return (state in self.accept), steps
# regex (a|b)*abb -- ক্লাসিক "abb-তে শেষ হওয়া" -- একটি সত্যিকারের রেগুলার ভাষা
regex_ast = ('concat', ('star', ('union', ('lit', 'a'), ('lit', 'b'))),
('concat', ('lit', 'a'), ('concat', ('lit', 'b'), ('lit', 'b'))))
dfa_matcher = DFAMatcher(regex_ast, {'a', 'b'})
print("== DFA matcher: (a|b)*abb ==")
for s in ["abb", "aababb", "ab", "abbb", "bbb", ""]:
ok, steps = dfa_matcher.matches(s)
print(f" {s!r:10} -> {'accept' if ok else 'reject':7} steps={steps}")
# অংশ B -- ব্যাকট্র্যাকিং matcher, group + backreference সাপোর্টসহ
def bt_match(ast, s):
steps = [0]
groups = {}
def m(node, pos, cont):
steps[0] += 1
kind = node[0]
if kind == 'lit':
return cont(pos + 1) if pos < len(s) and s[pos] == node[1] else False
if kind == 'any':
return cont(pos + 1) if pos < len(s) else False
if kind == 'concat':
nodes = node[1]
def build(i):
return cont if i == len(nodes) else (lambda p: m(nodes[i], p, build(i + 1)))
return build(0)(pos)
if kind == 'star':
inner = node[1]
def try_repeat(p):
def after_inner(p2):
# শূন্য-দৈর্ঘ্যের পুনরাবৃত্তি -- অসীম লুপ এড়াতে থামুন (বাস্তব ইঞ্জিনও এই গার্ড ব্যবহার করে)
return cont(p2) if p2 == p else try_repeat(p2)
return True if m(inner, p, after_inner) else cont(p)
return try_repeat(pos)
if kind == 'group':
gid, inner = node[1], node[2]
start = pos
def after(p):
old = groups.get(gid)
groups[gid] = s[start:p]
if cont(p): return True
groups[gid] = old if old is not None else groups.pop(gid, None)
return False
return m(inner, pos, after)
if kind == 'backref':
captured = groups.get(node[1], '')
return cont(pos + len(captured)) if s[pos:pos + len(captured)] == captured else False
raise ValueError(node)
return m(ast, 0, lambda p: p == len(s)), steps[0]
# {ww} ভাষার জন্য প্যাটার্ন -- (group any*) ব্যাকরেফারেন্স \1 -- DFA প্রকাশ করতে পারে না
ww_ast = ('concat', [('group', 1, ('star', ('any',))), ('backref', 1)])
print()
print("== ব্যাকট্র্যাকিং matcher: (গ্রুপ any*)\\1 অর্থাৎ {ww} ভাষা ==")
for s in ["abab", "abcabc", "aa", "a", "", "aabb", "xyzxyz", "xyzxy"]:
ok, steps = bt_match(ww_ast, s)
print(f" {s!r:10} -> {'accept' if ok else 'reject':7} steps={steps}")
ww_ast-এর জন্য কোনো সমতুল্য regex_ast (DFA-বান্ধব) তৈরি করা সম্ভব
নয় — কারণ $\{ww\}$ নন-রেগুলার। কিন্তু bt_match এটি সঠিকভাবে হ্যান্ডেল করে, "abab" ও
"abcabc"-এর মতো স্ট্রিং accept করে (যেখানে অর্ধেক পুনরাবৃত্তি হয়) কিন্তু "aabb" ও "xyzxy"-এর মতো স্ট্রিং সঠিকভাবে
reject করে — এই ফলাফল সরাসরি is_ww() নামক একটি স্বাধীন গ্রাউন্ড-ট্রুথ ফাংশনের বিপরীতে যাচাই করা
হয়েছে।
৫ · ক্যাটাস্ট্রফিক ব্যাকট্র্যাকিং সরাসরি পরিমাপ করা
নিচের কোড সেলে $(a^*)^*b$ প্যাটার্নটি ব্যবহার করে ক্যাটাস্ট্রফিক ব্যাকট্র্যাকিং রিপ্রোডিউস করা হয়েছে — একটি "ভালো" ইনপুট (যা শেষে 'b' দিয়ে ম্যাচ হয়) বনাম একটি "খারাপ" ইনপুট (যেখানে শেষে 'b' নেই, তাই ইঞ্জিনকে প্রতিটি সম্ভাব্য বিভাজন চেষ্টা করে ব্যর্থ হতে হয়)।
# ক্যাটাস্ট্রফিক ব্যাকট্র্যাকিং -- (a*)*b প্যাটার্ন, উপরের bt_match() পুনরায় ব্যবহার করে
# ধরে নেওয়া হচ্ছে bt_match() ও AST নোড-টাইপ আগের কোড সেল থেকে ইতিমধ্যে সংজ্ঞায়িত
evil_ast = ('concat', [('star', ('group', 1, ('star', ('lit', 'a')))), ('lit', 'b')])
print("== ক্যাটাস্ট্রফিক ব্যাকট্র্যাকিং: (a*)*b ==")
print(f"{'n':>4} | ভালো ইনপুট (ম্যাচ হয়) ধাপ | খারাপ ইনপুট (ম্যাচ হয় না) ধাপ")
print("-" * 62)
for n in [5, 10, 15, 20]:
good = "a" * n + "b" # সাধারণ, দ্রুত ম্যাচ হয়
evil = "a" * n + "c" # কোনো 'b' নেই -- ইঞ্জিন প্রতিটি সম্ভাব্য a-বিভাজন চেষ্টা করে
_, steps_good = bt_match(evil_ast, good)
_, steps_evil = bt_match(evil_ast, evil)
print(f"{n:>4} | {steps_good:>20} | {steps_evil:>24}")
বাস্তব regex ইঞ্জিন M2-এর ফরমাল তত্ত্বের একটি ব্যবহারিক সম্প্রসারণ — বেশি এক্সপ্রেসিভ পাওয়ার (ব্যাকরেফারেন্স), কিন্তু M2/L11-এর DFA-এর গ্যারান্টিড লিনিয়ার-টাইম নিরাপত্তা হারিয়ে। প্রতিটি regex ফিচার একটি ট্রেড-অফ — এই তত্ত্বীয় ভিত্তি জানা থাকলে একজন ডেভেলপার বুঝতে পারেন কখন একটি "সহজ" regex আসলে একটি লুকানো পারফরম্যান্স/নিরাপত্তা ঝুঁকি বহন করছে।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।
প্র ০১ যদি ব্যাকরেফারেন্স-সাপোর্টিং regex ইঞ্জিন "বেশি শক্তিশালী," তাহলে কেন সবসময় সেগুলোই ব্যবহার না করে DFA-ভিত্তিক ইঞ্জিন (যেমন RE2, Rust-এর regex crate) তৈরি করা হয়?
কারণ সেই অতিরিক্ত শক্তির মূল্য হলো গ্যারান্টিড লিনিয়ার-টাইম হারানো। বেশিরভাগ বাস্তব ব্যবহারের ক্ষেত্রে (লগ পার্সিং, ইনপুট ভ্যালিডেশন, সার্চ) ব্যাকরেফারেন্সের প্রয়োজনই হয় না, কিন্তু ক্যাটাস্ট্রফিক ব্যাকট্র্যাকিং-এর ঝুঁকি থেকেই যায় যদি ব্যাকট্র্যাকিং ইঞ্জিন ব্যবহার করা হয়। তাই RE2 বা Rust-এর regex crate ইচ্ছাকৃতভাবে ব্যাকরেফারেন্স/লুকঅ্যাহেড সাপোর্ট বাদ দেয়, বিনিময়ে M2/L11-এর DFA-সিমুলেশনের গ্যারান্টিড লিনিয়ার-টাইম নিরাপত্তা ফিরে পায় — একটি সচেতন, তত্ত্ব-সচেতন ইঞ্জিনিয়ারিং সিদ্ধান্ত।
প্র ০২ $\{ww\}$ ভাষাটি কেন নন-রেগুলার তা M3/L13-14-এর পাম্পিং লেমার স্টাইলে সংক্ষেপে যুক্তি দিন।
ধরুন $\{ww\}$ রেগুলার, তাহলে একটি পাম্পিং length $p$ থাকবে। স্ট্রিং $s = a^pb\,a^pb$ (দৈর্ঘ্য $\geq p$) বিবেচনা করুন, যা $\{ww\}$-এ আছে ($w = a^pb$)। যেকোনো বৈধ বিভাজন $s = xyz$ যেখানে $|xy| \leq p$, তার মানে $y$ সম্পূর্ণভাবে প্রথম $a^p$-এর অংশ (শুধু 'a')। $y$ পাম্প করলে (যেমন $i=0$, $y$ বাদ দেওয়া) প্রথম অর্ধেকের দৈর্ঘ্য বদলে যায় কিন্তু দ্বিতীয় অর্ধেক অপরিবর্তিত থাকে — ফলে $xy^0z$ আর কোনো $w'w'$ আকারে থাকে না — একটি contradiction, ঠিক M3/L14-এর $0^n1^n$ প্রমাণের মতোই কাঠামোয়।
প্র ০৩ কোড সেলে "ভালো" ও "খারাপ" ইনপুট প্রায় একই দৈর্ঘ্যের হলেও ধাপসংখ্যায় এত বিশাল পার্থক্য কেন হয়?
কারণ "ভালো" ইনপুট (শেষে 'b' আছে) দ্রুত একটি সফল ম্যাচ খুঁজে পায় এবং থেমে যায় — কোনো ব্যাকট্র্যাকিং লাগে না। কিন্তু "খারাপ" ইনপুটে যেহেতু কোনো ম্যাচই সম্ভব নয় (কোনো 'b' নেই), ইঞ্জিনকে নিশ্চিত হতে হয় যে প্রতিটি সম্ভাব্য উপায়ে $(a^*)^*$-কে বিভক্ত করে চেষ্টা করা হয়েছে — এবং যেহেতু $n$টি 'a'-কে গ্রুপে ভাগ করার সম্ভাব্য উপায়ের সংখ্যা $2^{n-1}$-এর কাছাকাছি (কম্বিনেটরিক্সের একটি সরাসরি ফলাফল), ব্যর্থতা নিশ্চিত করতেই এক্সপোনেনশিয়াল কাজ প্রয়োজন হয়ে পড়ে।
অনুশীলন
-
চিন্তা করুন: একটি ওয়েব ফর্মের ইমেইল-ভ্যালিডেশন regex ব্যবহারকারীর ইনপুট থেকে সরাসরি তৈরি করা কি নিরাপদ? কেন বা কেন নয়?
না, সাধারণত নিরাপদ নয়। যদি regex প্যাটার্নটি (ব্যবহারকারীর নিয়ন্ত্রণে থাকা অংশসহ) নেস্টেড কোয়ান্টিফায়ার তৈরি করতে পারে, তাহলে একজন আক্রমণকারী এমন একটি ইনপুট পাঠাতে পারে যা ক্যাটাস্ট্রফিক ব্যাকট্র্যাকিং ঘটায় (ReDoS) — সার্ভারের একটি সিঙ্গেল থ্রেড কার্যত অসীম সময় ব্যস্ত হয়ে পড়ে। নিরাপদ অনুশীলন: নির্ভরযোগ্য, অডিট-করা regex লাইব্রেরি ব্যবহার করা, ইনপুট দৈর্ঘ্য সীমিত রাখা, অথবা একটি DFA-গ্যারান্টিড ইঞ্জিন (RE2) ব্যবহার করা।
-
পরীক্ষা করুন: উপরের ক্যাটাস্ট্রফিক-ব্যাকট্র্যাকিং কোড সেলে
n-এর তালিকায়25যোগ করে Run চেপে দেখুন খারাপ-ইনপুটের ধাপসংখ্যা কত দ্রুত বৃদ্ধি পায়।প্যাটার্ন অনুযায়ী, $n=25$-এ ধাপসংখ্যা $n=20$-এর প্রায় $2^5 = 32$ গুণ হওয়া উচিত (অর্থাৎ প্রায় ২০ লক্ষের বেশি ধাপ) — এবং এই দ্রুত বৃদ্ধি অব্যাহত থাকবে প্রতিটি নতুন 'a' যোগ করার সাথে সাথে, যা সরাসরি ব্যাখ্যা করে কেন বাস্তব ReDoS আক্রমণে মাত্র কয়েক ডজন ক্যারেক্টারই যথেষ্ট একটি সার্ভারকে অচল করতে।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পরবর্তী পাঠ: কেস স্টাডি — বিখ্যাত আনডিসাইডেবল ও NP-কমপ্লিট প্রবলেম পাঠ ৫৫ M9-M10-এর সম্পূর্ণ তাত্ত্বিক আর্ক বাস্তব, স্বীকৃত সমস্যার সাথে সংশ্লেষণ করা হয়েছে।
- পুনরালোচনা: টুরিং মেশিন ডিজাইন করা পাঠ ৩৪ $\{ww\}$-এর মূল TM উদাহরণ, যা এই পাঠের ব্যাকরেফারেন্স আলোচনার ভিত্তি।
- Cybersecurity কোর্স সঙ্গী কোর্স ReDoS ও অন্যান্য ইনপুট-ভিত্তিক ভালনারেবিলিটি এই কোর্সে বিস্তারিত কভার করা হয়েছে।
- কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৬টি পাঠ DFA/NFA থেকে টুরিং মেশিন, ডিসাইডেবিলিটি ও P বনাম NP পর্যন্ত সম্পূর্ণ কোর্স।