Катастрофический бэктрекинг (ReDoS)

Узнаём, почему «безобидная» регулярка может зависнуть на секунды и как этого избежать.

Катастрофический бэктрекинг — лавинообразный рост числа вариантов перебора, из-за которого регулярка работает экспоненциально долго на коротком вводе.

Как движок ищет совпадение

Классический движок регулярок (как в Python, JS, Java) работает с возвратами (backtracking): если выбранный вариант не привёл к совпадению, он откатывается и пробует другой. Обычно это незаметно. Но при неудачных паттернах число вариантов растёт экспоненциально, и поиск зависает.

Опасный шаблон

Беда возникает, когда квантор стоит над группой, которая сама содержит квантор и допускает один и тот же текст многими способами. Классический пример (НЕ запускайте на длинном вводе):

^(a+)+$

Здесь a+ внутри (...)+ — текст вроде aaaa можно разбить на группы десятками способов: (aaaa), (a)(aaa), (aa)(aa) и так далее. Пока совпадение есть, всё хорошо. Но если в конец добавить символ, ломающий совпадение, — например "aaaaaaaaaaaaaaaaaaaa!", — движок переберёт все разбиения, прежде чем сдаться. Для 30 символов это миллиарды вариантов и зависание на секунды.

ReDoS — это уязвимость

Если такой паттерн применяется к пользовательскому вводу (например, валидация поля на сервере), злоумышленник пришлёт короткую «ядовитую» строку и повесит процесс. Это называется ReDoS (Regular expression Denial of Service) — отказ в обслуживании через регулярку. Реальные инциденты случались у крупных сервисов.

Безопасная демонстрация

Чтобы прочувствовать рост без зависания, посчитаем, на сколько способов можно разбить строку из n одинаковых букв на непустые группы — это число Фибоначчи-подобной природы, и оно растёт стремительно. Именно столько вариантов перебирает уязвимый паттерн:

def splits(n):
    # число способов разбить n символов на непустые подряд группы = 2**(n-1)
    return 2 ** (n - 1)

for n in [5, 10, 20, 30]:
    print(n, "символов ->", splits(n), "вариантов разбиения")

Вывод:

5 символов -> 16 вариантов разбиения
10 символов -> 512 вариантов разбиения
20 символов -> 524288 вариантов разбиения
30 символов -> 536870912 вариантов разбиения

Полмиллиарда вариантов для 30 символов — вот почему движок «думает» секундами. Каждый лишний символ удваивает работу.

Как защититься

  • Избегайте вложенных кванторов: (a+)+, (a*)*, (.*)* — красные флаги.
  • Уточняйте классы: вместо жадной .* используйте конкретный класс, который не пересекается с соседями, например [^"]* вместо .* между кавычками.
  • Якорьте паттерн (^...$) и ограничивайте длину ввода до применения регулярки.
  • Для проверки паттернов на уязвимость есть онлайн-анализаторы и линтеры.

Итог

  • Backtracking-движок при плохих паттернах перебирает экспоненциально много вариантов.
  • Вложенные кванторы вроде (a+)+ на «ядовитом» вводе вешают поиск — это ReDoS.
  • Избегайте вложенных кванторов, уточняйте классы, якорьте паттерн и ограничивайте длину ввода.
Проверьте себя
1. Что такое катастрофический бэктрекинг?
AОшибка синтаксиса в паттерне
BЭкспоненциальный рост числа перебираемых вариантов, из-за которого поиск зависает на коротком вводе
CОткат изменений в файле
DСлишком короткий паттерн
2. Какой паттерн — типичный красный флаг для ReDoS?
A\d{4}-\d{2}-\d{2}
B(a+)+ — вложенные кванторы
C^[a-z]+$
D\bcat\b
3. Что такое ReDoS?
AУскоренная версия regex
BАтака отказа в обслуживании через регулярку: короткий «ядовитый» ввод вешает процесс
CФормат записи паттернов
DФлаг компиляции
Поддержать проект