Чёрная дыра бэктрекинга

Некоторые регулярные выражения, с виду являясь простыми, могут выполняться оооочень долго, и даже «подвешивать» интерпретатор JavaScript.

Рано или поздно, с этим сталкивается любой разработчик, потому что нечаянно создать такое регулярное выражение – легче лёгкого.

Типична ситуация, когда регулярное выражение до поры до времени работает нормально, и вдруг на каком-то тексте как начнёт «подвешивать» интерпретатор и есть 100% процессора.

Это может стать уязвимостью. Например, если JavaScript выполняется на сервере, то при разборе данных, присланных посетителем, он может зависнуть, если использует подобный регэксп. На клиенте тоже возможно подобное, при использовании регэкспа для подсветки синтаксиса.

Такие уязвимости «убивали» почтовые сервера и системы обмена сообщениями и до появления JavaScript, и наверно будут «убивать» и после его исчезновения. Так что мы просто обязаны с ними разобраться.

Пример

План изложения у нас будет таким:

  1. Сначала посмотрим на проблему в реальной ситуации.
  2. Потом упростим реальную ситуацию до «корней» и увидим, откуда она берётся.

Рассмотрим, например, поиск по HTML.

Мы хотим найти теги с атрибутами, то есть совпадения вида <a href="..." class=doc ...>.

Самый простой способ это сделать – <[^>]*>. Но он же и не совсем корректный, так как тег может выглядеть так: <a test="<>" href="#">. То есть, внутри «закавыченного» атрибута может быть символ >. Простейший регэксп на нём остановится и найдёт <a test="<>.

Соответствие:

<[^>]*....>
<a test="<>" href="#">

А нам нужен весь тег.

Для того, чтобы правильно обрабатывать такие ситуации, нужно учесть их в регулярном выражении. Оно будет иметь вид <тег (ключ=значение)*>.

Если перевести на язык регэкспов, то: <\w+(\s*\w+=(\w+|"[^"]*")\s*)*>:

  1. <\w+ – начало тега
  2. (\s*\w+=(\w+|"[^"]*")\s*)* – произвольное количество пар вида слово=значение, где «значение» может быть также словом \w+, либо строкой в кавычках "[^"]*".

Мы пока не учитываем все детали грамматики HTML, ведь строки возможны и в „одинарных“ кавычках, но на данный момент этого достаточно. Главное, что регулярное выражение получилось в меру простым и понятным.

Испытаем полученный регэксп в действии:

var reg = /<\w+(\s*\w+=(\w+|"[^"]*")\s*)*>/g;

var str='...<a test="<>" href="#">... <b>...';

alert( str.match(reg) ); // <a test="<>" href="#">, <b>

Отлично, всё работает! Нашло как длинный тег <a test="<>" href="#">, так и одинокий <b>.

А теперь – демонстрация проблемы.

Если запустить пример ниже, то он может подвесить браузер:

var reg = /<\w+(\s*\w+=(\w+|"[^"]*")\s*)*>/g;

var str = "<tag a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  \
a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b";

// Этот поиск будет выполняться очень, очень долго
alert( str.match(reg) );

Некоторые движки регулярных выражений могут в разумное время разобраться с таким поиском, но большинство – нет.

В чём дело? Почему несложное регулярное выражение на такой небольшой строке «виснет» наглухо?

Упростим ситуацию, удалив тег и возможность указывать строки в кавычках:

// только атрибуты, разделённые пробелами
var reg = /<(\s*\w+=\w+\s*)*>/g;

var str = "<a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  \
a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b";

// Этот поиск будет выполняться очень, очень долго
alert( str.match(reg) );

То же самое.

На этом мы закончим с демонстрацией «практического примера» и перейдём к разбору происходящего.

Бектрекинг

В качестве ещё более простого регулярного выражения, рассмотрим (\d+)*$.

В большинстве движков регэкспов, например в Chrome или IE, этот поиск выполняется очень долго (осторожно, может «подвесить» браузер):

alert( '12345678901234567890123456789123456789z'.match(/(\d+)*$/) );

В чём же дело, что не так с регэкспом?

Внимательный читатель, посмотрев на него, наверняка удивится, ведь он «какой-то странный». Квантификатор * здесь выглядит лишним.

Если хочется найти число, то с тем же успехом можно искать \d+$.

Да, этот регэксп носит искусственный характер, но, разобравшись с ним, мы поймём и практический пример, данный выше. Причина их медленной работы одинакова.

В целом, с регэкспом «всё так», синтаксис вполне допустимый. Проблема в том, как выполняется поиск по нему.

Посмотрим, что происходит при поиске в строке 123456789z:

  1. Первым делом, движок регэкспов пытается найти \d+. Плюс + является жадным по умолчанию, так что он хватает все цифры, какие может:

    \d+.......
    (123456789)z
  2. Затем движок пытается применить звёздочку вокруг скобок (\d+)*, но больше цифр нет, так что звёздочка не даёт повторений.

    Затем в шаблоне идёт символ конца строки $, а в тексте – символ z.

               X
    \d+........$
    (123456789)z

    Соответствия нет.

  3. Так как соответствие не найдено, то «жадный» плюс + отступает на один символ (бэктрекинг).

    Теперь \d+ – это все цифры, за исключением последней:

    \d+.......
    (12345678)9z
  4. После бэктрекинга, \d+ содержит всё число, кроме последней цифры. Движок снова пытается найти совпадение, уже с новой позиции (9).

    Звёздочка (\d+)* теперь может быть применена – она даёт число 9:

    \d+.......\d+
    (12345678)(9)z

    Движок пытается найти $, но это ему не удаётся – на его пути опять z:

                 X
    \d+.......\d+
    (12345678)(9)z

    Так как совпадения нет, то поисковой движок отступает назад ещё раз.

  5. Теперь первое число \d+ будет содержать 7 цифр, а остаток строки 89 становится вторым \d+:

                 X
    \d+......\d+
    (1234567)(89)z

    Увы, всё ещё нет соответствия для $.

    Поисковой движок снова должен отступить назад. При этом последний жадный квантификатор отпускает символ. В данном случае это означает, что укорачивается второй \d+, до одного символа 8, и звёздочка забирает следующий 9.

                   X
    \d+......\d+\d+
    (1234567)(8)(9)z
  6. …И снова неудача. Второе и третье \d+ отступили по-максимуму, так что сокращается снова первое число, до 123456, а звёздочка берёт оставшееся:

                 X
    \d+.......\d+
    (123456)(789)z

    Снова нет совпадения. Процесс повторяется, последний жадный квантификатор + отпускает один символ (9):

                   X
    \d+.....\d+ \d+
    (123456)(78)(9)z
  7. …И так далее.

Получается, что движок регулярных выражений перебирает все комбинации из 123456789 и их подпоследовательности. А таких комбинаций очень много.

На этом месте умный читатель может воскликнуть: «Во всём виноват бэктрекинг? Давайте включим ленивый режим – и не будет никакого бэктрекинга!»

Что ж, заменим \d+ на \d+? и посмотрим (аккуратно, может подвесить браузер):

alert( '12345678901234567890123456789123456789z'.match(/(\d+?)*$/) );

Не помогло!

Ленивые регулярные выражения делают то же самое, но в обратном порядке.

Просто подумайте о том, как будет в этом случае работать поисковой движок.

Некоторые движки регулярных выражений содержат хитрые проверки и конечные автоматы, которые позволяют избежать бесконечного перебора или кардинально ускорить его, но не все движки и не всегда.

Возвращаясь к примеру выше – при поиске <(\s*\w+=\w+\s*)*> в строке <a=b a=b a=b a=b происходит то же самое.

Поиск успешно начинается, выбирается некая комбинация из \s*\w+=\w+\s*, которая, так как в конце нет >, оказывается не подходящей. Движок честно отступает, пробует другую комбинацию – и так далее.

Что делать?

Проблема – в сверхмноговариантном переборе.

Движок регулярных выражений перебирает кучу возможных вариантов скобок там, где это не нужно.

Например, в регэкспе (\d+)*$ нам (людям) очевидно, что в (\d+) откатываться не нужно. От того, что вместо одного \d+ у нас два независимых \d+\d+, ничего не изменится.

Без разницы:

\d+........
(123456789)z

\d+...\d+....
(1234)(56789)z

Если вернуться к более реальному примеру <(\s*\w+=\w+\s*)*> то сам алгоритм поиска, который у нас в голове, предусматривает, что мы «просто» ищем тег, а потом пары атрибут=значение (сколько получится).

Никакого «отката» здесь не нужно.

В современных регулярных выражениях для решения этой проблемы придумали «possessive» (сверхжадные? неоткатные? точный перевод пока не устоялся) квантификаторы, которые вообще не используют бэктрегинг.

То есть, они даже проще, чем «жадные» – берут максимальное количество символов и всё. Поиск продолжается дальше. При несовпадении никакого возврата не происходит.

Это, с одной стороны, уменьшает количество возможных результатов, но, с другой стороны, в ряде случаев очевидно, что возврат (уменьшение количество повторений квантификатора) результата не даст. А только потратит время, что как раз и доставляет проблемы. Как раз такие ситуации и описаны выше.

Есть и другое средство – «атомарные скобочные группы», которые запрещают перебор внутри скобок, по сути позволяя добиваться того же, что и сверхжадные квантификаторы,

К сожалению, в JavaScript они не поддерживаются.

Однако, можно получить подобный эффект при помощи предпросмотра. Подробное описание соответствия с учётом синтаксиса сверхжадных квантификаторов и атомарных групп есть в статьях Regex: Emulate Atomic Grouping (and Possessive Quantifiers) with LookAhead и Mimicking Atomic Groups, здесь же мы останемся в рамках синтаксиса JavaScript.

Взятие максимального количества повторений a+ без отката выглядит так: (?=(a+))\1.

То есть, иными словами, предпросмотр ?= ищет максимальное количество повторений a+, доступных с текущей позиции. А затем они «берутся в результат» обратной ссылкой \1. Дальнейший поиск – после найденных повторений.

Откат в этой логике в принципе не предусмотрен, поскольку предпросмотр «откатываться» не умеет. То есть, если предпросмотр нашёл 5 штук a+, и в результате поиск не удался, то он не будет откатываться на 4 повторения. Эта возможность в предпросмотре отсутствует, а в данном случае она как раз и не нужна.

Исправим регэксп для поиска тега с атрибутами <\w+(\s*\w+=(\w+|"[^"]*")\s*)*>, описанный в начале главы. Используем предпросмотр, чтобы запретить откат на меньшее количество пар атрибут=значение:

// регэксп для пары атрибут=значение
var attr = /(\s*\w+=(\w+|"[^"]*")\s*)/

// используем его внутри регэкспа для тега
var reg = new RegExp('<\\w+(?=(' + attr.source + '*))\\1>', 'g');

var good = '...<a test="<>" href="#">... <b>...';

var bad = "<tag a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b\
  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b  a=b";

alert( good.match(reg) ); // <a test="<>" href="#">, <b>
alert( bad.match(reg) ); // null (нет результатов, быстро)

Отлично, всё работает! Нашло как длинный тег <a test="<>" href="#">, так и одинокий <b>.

Карта учебника

Комментарии

перед тем как писать…
  • Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.