
Салют, Хабр!
Я Алексей, занимаюсь ассистентом в SberDevices. А в свободное время занимаюсь дискретной математикой, поэтому обожаю регулярные выражения — они по сути довольно близки к предмету моих интересов и делают код удобоваримее. В этой статье хочу рассказать о математике регулярных выражений и их интересной особенности, которая возникает внезапно.
Можно не писать самому регулярные выражения. Вообще не использовать их в коде. Но они всё равно там окажутся. Допустим, мы хотим написать проверку email-адреса, чтобы пускать людей с нужным корпоративным аккаунтом. Написали заглушку на FastAPI:
from fastapi import FastAPI
from pydantic import EmailStr
app = FastAPI()
@app.get("/")
async def validate_domains(email: EmailStr):
return {"valid": True}
Выглядит легитимно, но, если выкатить, прод упадёт. Причина в ReDoS. Почему он возникает? Смотри ниже.
Откуда берётся backtracking?
Проблема в так называемых возвратах регулярных выражений — backtracking. На некоторых текстах одни и те же символы обрабатываются очень много раз. В 2019 году эта невинная особенность регулярок на 27 минут положила CloudFlare. Вот он, преступник:
(?:(?:\"|'|\]|\}|\\|\d|(?:nan|infinity|true|false|null|undefined|symbol|math)|\`|\-|\+)+[)]*;?((?:\s|-|~|!|{}|\|\||\+)*.*(?:.*=.*)))
Паттерн .*.*=.*. породил бесконечный backtracking и обрушил систему. Полная история тут.
Причину возвратов нельзя понять без небольшого погружения в теорию. Регулярные выражения почти без изменений можно перерисовать в виде схем; для этого есть множество инструментов, например, regexper.com. И де‑факто эти схемы очень похожи на реальную математическую модель регулярок, недетерминированные конечные автоматы (НКА). НКА можно представить в виде графа, где каждая вершина — это некоторое состояние, каждое ребро — чтение символа (а пустые переходы — чтение пустого символа), плюс также зафиксированы начальное состояние и конечные.

Картинка выглядит более громоздко, но это уже полноценная модель. При этом сопоставляется с исходной схемой почти 1-в-1. Матч получается, если мы можем из начала попасть в конец хотя бы одним путём. Например, рассмотрим простой негативный пример, abc:

А вот простой позитивный пример, xy:

Дошли до конца, при этом символы тоже кончились. Матч найден, успех.
Возвраты возникают, когда при выборе пути появляется развилка. Если путь не приводит к успеху, то нам придётся возвращаться, а возвращение приводит к чтению этого символа дважды. Или даже к очень большому количеству чтений и, как следствие, низкой производительности.
Можно рассмотреть ту же регулярку (x+)+y и пример xxxx. Он очевидно не подходит, но программа об этом ещё не знает. Регулярки матчат группы жадно (пытаются забрать как можно больше), значит, можно построить первые итерации:
(xxxx) → нет y, остановка
(xxx)(x)
(xx)(xx)
(xx)(x)(x)
(x)(xxx) и другие
Количество вариантов растёт экспоненциально, а это значит, что даже небольшая, менее 40 символов, строка заблокирует этот поток полностью.
Теперь можно возвращаться к исходному сниппету FastAPI.
from fastapi import FastAPI
from pydantic import EmailStr
app = FastAPI()
@app.get("/")
async def validate_domains(email: EmailStr):
return {"valid": True}
Внутри матрёшка:
FastAPI принял запрос;
валидирует pydantic;
для валидации используется email-validator.
Можно предположить, что проблема лежит где‑то в валидации email. Но всё гораздо тривиальнее. Перед валидацией email надо извлечь из строк с именем, например, Whatever <foobar@example.com>. Проблема именно на этом этапе.
Покажу кусок PR (#10601):
- email_group = r<\s*(.+)\s*>
+ email_group = r<(.+)>
(лишних пробелов не будет, потому что позже всё равно делается .strip())
В изначальной версии регулярного выражения пробел может быть «съеден» в любом из трёх разных мест: \s*, (.+), \s*. Они идут подряд, поэтому в худшем случае это может привести как раз к кубической сложности O(N3).
PR влит, в свежих версиях проблема исправлена. Почти случайно нашлась проблема в очень популярном проекте. Что делать дальше? Нужно посмотреть на другие! Для анализа, после доработки Regexploit напильником, (ссылка в конце текста) проверяем три разных списка Python‑проектов с GitHub: Awesome Python, Top 100 Stars in Python и персональный Awesome Python. Они пересекаются, но не слишком сильно. Предупреждение: оценка может быть завышена, так как результаты проверены на ложные срабатывания выборочно.
Вывод: проблемы есть, и их много. Удобнее всего разбить их на два класса:
Кубические (O(N3) и выше) — 12%, то есть каждый восьмой проект содержит потенциально проблемные регулярки! Задержка — примерно секунда на 2000 символах, как в pydantic.
Экспоненциальные (O(eN)), как
(x+)+y. По моим оценкам от 2 до 5%, немного по-разному для разных списков. Backtracking в них — почти бесконечность на твите, 140 символов.
В конце статьи оставил список проектов с моими PRами. На текущий момент все вмержены, так что можно аккуратно предположить, что проблема действительно была. Рассуждать о её серьёзности сложно — много экспоненциальных срабатываний на тестах, которые не так честно учитывать.
Почему ошибок так много, но о них не говорят? Причин несколько. Часто строки сильно ограничены по размеру, это нивелирует большую сложность. Есть много примеров, когда пользователь не мог влиять на вход регулярки — например, в случае выводов системных утилит. Кроме того, если программа работает на локальном компьютере одного человека, проблема с производительностью регулярных выражений останется незамеченной почти всегда.
Как проще всего решить проблему регулярок?
Решение, внезапно, почти такое же старое, как сами недетерминированные автоматы: автоматы детерминированные. Для иллюстрации также возьмём регулярку [ab]*[ac]*a+[ac]+ и построим её схему. И сразу строим детерминированный автомат (ДКА):
![Схема и НДКА для [ab]*[ac]*a+[ac]+ Схема и НДКА для [ab]*[ac]*a+[ac]+](https://habrastorage.org/r/w780/getpro/habr/upload_files/d3f/cdb/184/d3fcdb1846c1f86aa7316522191213e3.png)
Очень сильно отличается от схемы, но выглядит схоже с прошлым автоматом: такие же состояния и переходы. Потому что это такой же НКА, но альтернативных путей тут нет! Из каждого состояния только один путь.
С точки зрения математики любой НКА можно перевести в ДКА. Вместе с тем ДКА не используют всюду по трём с половиной причинам. Во‑первых, из‑за того, что связь с исходной схемой (=выражением) в целом теряется, у нас гораздо меньше контроля над процессом (об этом написано в .NET доках). Во‑вторых, перестаёт работать часть фич — например, бекреференсы (backreference) использовать не получится. В‑третьих, с оптимизациями сложнее, поэтому могут быть медленнее.
Кроме того, существует фундаментальная теоретическая проблема, хотя я не ловил её на реальных примерах: в худшем случае конвертация НКА → ДКА будет экспоненциальной, а значит, может потребовать очень много памяти. Дело в особенности процесса конвертации НКА в ДКА: это происходит через конвертацию каждого набора состояний НКА в одно состояние ДКА. Число наборов состояний — это экспонента 2N, где N — число состояний НКА. Вместе с тем реальной жизни эти состояния почти всегда пустые, то есть в ДКА не попадают.
Далее берём несколько доступных для Python движков регулярных выражений. Их много, но production‑ready гораздо меньше.
pcre2 — биндинг к PCRE2. Много оптимизаций, JIT. НКА (где‑то внутри есть ДКА, но в биндингах не видел).
RE2 — создана Google в 2010, используется в Sheets и др. ДКА. Есть много биндингов для всего.
Regex. In-place замена Python re. Добавляет фичей.
Теперь можно посмотреть НКА и ДКА на реальных примерах. Чтобы показать разницу, посмотрим на максимально простом примере, IP-адреса. И возьмём самый простой вариант, (\d+\.)+\d+ — матчит просто числа через точку.

(\d+\.)+\d+Чётко видно, что ReDoS мы получили даже на примере, где его как будто и нет вовсе. Получается, проблема может быть специфична именно для одного движка.
Сделаем регулярку, наоборот, максимально точной: будем матчить только валидные адреса максимально простым способом. Например, таким:
x = (0|1|2|3|...|255)
(x\.){3}x
(выражение конструируется, но читается/валидируется максимально легко)
Скрытый текст
Очевидно, никаких катастрофических возвратов быть не может — пример явно лишний. Но всё-таки проверим:

Движки с ДКА (обозначены пунктиром) лежат гораздо ниже остальных. Почему так? Дело в том, что возвраты всё равно возникают, просто их не больше 255 на каждую группу. Это даёт ещё одну причину выбирать ДКА‑движки. Они могут быть медленнее, но производительность остаётся стабильной всегда.
Но это были совсем простые примеры, хочется чего‑то сложнее. Строим бенчмарк для движков! Хотя тут есть ряд оговорок. Как было показано выше, реально оценить тяжело — всё зависит от входных данных и параметров. Время компиляции паттернов минимально даже при наличии JIT, я его не учитывал. Также для тестирования использованы плохие и неудобные примеры — как раз те, что я нашёл при ручном анализе репозиториев.
Ниже графики перцентилей времени работы: чем линия ниже, тем лучше. ДКА движки показаны пунктиром.

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

Согласно бенчмарку, в лучших запусках re2 значительно уступает всем, кроме pcre2. А вот встроенный движок re очень плохо справляется с возвратами: в случае сомнений лучше его вообще не рассматривать. ДКА движки на втором графики сливаются вместе, и это ожидаемо — они нечувствительны к возвратам. regex справляется значительно лучше pcre2, классические движки по-разному уязвимы к возвратам. Быстрее всех, на удивление, оказался flpc (rust-regex).
Как ещё стоит работать с регулярными выражениями?
Если регулярки не приходят от пользователей, то достаточно их проверять.
Сверхжадные модификаторы (++, *+; python 3.11+) всех спасут, так как блокируют возвраты.
Возможно, лучше перейти на движки с ДКА. Для Python проще всего взять RE2: он может быть медленнее, но полностью защищает от попадания на ReDoS.
Rust Regex очень‑очень хорош, но актуальных биндингов я не нашёл (flpc и rure мертвы?). Если нужно много производительности на Python, то 100% заслуживает внимания.
Где узнать больше?
Ресурсы для проверки и валидации
Онлайн для быстрой проверки нескольких примеров вручную: https://makenowjust‑labs.github.io/recheck/playground/
Regexploit — для проверки в одну команду. У меня потребовало доработки для запуска, но потом запустилось бодро: https://github.com/doyensec/regexploit
ReDoSHunter. Не самый удобный вариант (плохо подходит для прода), но похоже на state of the art. Шикарная статья: https://www.usenix.org/system/files/sec21-li‑yeting.pdf плюс репозиторий: https://github.com/yetingli/ReDoSHunter
Актуальные бенчмарки
Хороший набор — можно сравнивать производительность между движками и языками более глубоко, а также на разных задачах: https://github.com/BurntSushi/rebar
Под спойлером PRы с фиксами, которые я сделал при подготовке. Также указана сложность регулярок.
Скрытый текст
Pydantic: https://github.com/pydantic/pydantic/pull/10601 — 25k, O(N3)
Psutil: https://github.com/giampaolo/psutil/pull/2457 — 11k, O(eN)
Poetry: https://github.com/python‑poetry/poetry/pull/9770 — 34k, O(N3)
transformers: https://github.com/huggingface/transformers/pull/34298 — 148k, O(N3)
jc: https://github.com/kellyjonbrazil/jc/pull/609 — 8k, O(eN), много O(N3)
Комментарии (42)

apevzner
08.10.2025 16:26Я просто оставлю это здесь:
P.S. У Go реализация регулярных выражений из стандартной библиотеки гарантирует, что время сопоставления растёт, как O(n), где n - размер входного текста.

h1pp0 Автор
08.10.2025 16:26Видел эту статью, когда готовил материал. НКА vs ДКА тема очень старая, есть даже на Хабре статья про ReDoS. Но для меня стало неожиданностью, что
Даже в самых звёздных проектах на GitHub уязвимых регулярок много.
Далеко не во всех языках используется безопасная версия по умолчанию.
Простых инструментов для автоматического поиска проблем я не нашёл.

apevzner
08.10.2025 16:26Регулярки, которые умеют в обратные ссылки (backreference) не являются регулярными грамматиками по Хомскому и не могут быть преобразованы в регулярный ДКА. Наверное, есть граждане, которым обратные ссылки действительно нужны...
С другой стороны, синтаксис e-mail адреса (per RFC 822) не описывается регулярной грамматикой...

h1pp0 Автор
08.10.2025 16:26Регулярки, которые умеют в обратные ссылки (backreference) не являются регулярными грамматиками по Хомскому и не могут быть преобразованы в регулярный ДКА
На практике этот смысл потерялся очень давно. Справка Python, например, вообще не упоминает Хомского или "регулярный язык". Не могу сказать, что это было часто, но продвинутые фичи включая бекреференсы я встречал, насколько помню, в ~3-5% регулярок от общего числа просмотренных.
Я бы сказал, что реально это нужно редко, но когда нужно, то возможность остаться со знакомым инструментом подкупает. Последний раз что-то сложное я собирал на Boost.Spirit ~10 лет назад.

nin-jin
08.10.2025 16:26Это что же там не описывается регуляркой?

h1pp0 Автор
08.10.2025 16:26К своему стыду, не читал этот RFC (822) раньше, но, например, определение комментария само по себе регуляркой не парсится
comment = "(" *(ctext / quoted-pair / comment) ")"(пункт 3.3), т.к. тут вложенные скобки произвольной глубины.
apevzner
08.10.2025 16:26Более того, есть две формы адреса:
vasya@pupkin.com (Vasya Pupkin) Vasya Pupkin <vasya@pupkin.com>Я не смог придумать, как сделать парсер, который обходится без backtracking. Когда парсер только прочёл слово Vasya, он не может знать, какой из двух вариантов используется.

nin-jin
08.10.2025 16:26Раздел 3 про парсинг самих сообщений, про адреса раздел 6, там нет никаких комментариев.

apevzner
08.10.2025 16:26Примеры валидных адресов:
vasya@pupkin.com (Vasya Pupkin) Vasya Pupkin <vasya@pupkin.com>есть варианты и позаковыристее. Не надо думать, что e-mail address, это только user@example.com

nin-jin
08.10.2025 16:26Вы путаете собственно адрес, который идентифицирует мейлбокс с синтаксисом полей почтового сообщения, которые которые могут вообще ни одного мейлбокса не содержать, а только имя группы:
foobar:;
apevzner
08.10.2025 16:26В RFC5822, на который вы ссылаетесь,
addressопределён со всеми этими наворотами, а то, что имеете ввиду вы, это кусок адреса,addr-spec.Более того, RFC6068, определяющий схему
mailto, специально оговаривает, чтоaddress, это как в RFC5822, со следующими оговорками: (...), фактически сводяaddressкaddr-spec.addr-spec, наверное да, регулярен. Но со всеми интернационализационными наворотами это не точно.

Alexandroppolus
08.10.2025 16:26бекреференсы (backreference) использовать не получится.
Видимо, есть какой-то обходной путь, потому что например регексовый движок в Сафари умеет в бекреференсы, но не боится плохих регулярок (даже если они содержат backreference)

apevzner
08.10.2025 16:26Можно упростить. Там в целом довольно понятная алгоритмика, хоть аккуратно её реализовать - муторно, аж жуть :)

winorun
08.10.2025 16:26У меня давно зреет вопрос: Почему до сих пор не избавились от регулярных выражений? Их трудно читать, их трудно писать, в них очень легко ошибиться, их трудно оптимизировать и отлаживать. Почему не придумать какой нибудь язык описание конечного автомата? Чтобы он включал тесты, предупреждения и т.д. Пусть этот язык занимает больше текста, но он будет понятнее. Пусть бы он был компилирующимся.
Почему такое еще не придумали, идея то на поверхности?

apevzner
08.10.2025 16:26Придумали, конечно. ABNF, например, и всякое такое.
Вопрос, почему пользователи предпочитают неудобоваримые регулярки вместо более понятных инструментов.
Регулярки хороши, чтобы разбивать текст на лексемы, и классический парсер, он двухуровневый: лексемы выделяются регулярными выражениями, потом делается уже синтаксический анализ по грамматике, описанной каким-нибудь вариантом BNF (Backus–Naur form)

nin-jin
08.10.2025 16:26Многие языки невозможно разбить на лексемы отдельно от парсинга. Тот же HTML, например.

winorun
08.10.2025 16:26Что вам мешает разбить XML на лексемы?

nin-jin
08.10.2025 16:26html - не xml

winorun
08.10.2025 16:26С каких пор? Что в html 5 появилось такого что он перестал быть подмножеством xml? Да еще на уровне лексем. Это не наезд, я реально спрашиваю, так как темы не касался довольно давно.

nin-jin
08.10.2025 16:26Он никогда и не был подмножеством xml, это подмножество sgml. А вот xhtml - подмножество xml.

winorun
08.10.2025 16:26Это всё очень интересно и дискусионно. Но меня больше интересует практический вопрос. Что мешает разбить html на лексемы? Пример можно? Я не могу придумать примера в который утнется конечный автомат при лексическом разборе. При синтаксическом он очевиден. Вы этот пример, по Вашим словам, знаете.

nin-jin
08.10.2025 16:26<html><body> <script> alert(1) // <script> alert(2) // </script> alert(3) </script> </body></html>
winorun
08.10.2025 16:26Если я Вас правильно понял, Вы имели ввиду что токены должны быть:
html, body, script, text(который будет содержать примерно такой текст: "alert(1)// <script>alert(2) // </script>alert(3)"), /script, /body, /htmlВ то время как лексический анализатор выдаст:
html, body, script, text("alert(1)//<script>alert(2) //"),/script, text("alert(3)") /script, /body, /htmlПример классный. Но у меня он на практике не сработал. Браузер выдает второй вариант.

winorun
08.10.2025 16:26В браузере оба, но не в этом суть. Суть в том что пример не работает. То есть ваш пример можно обработать обычным конечным автоматом точно также как это сделает браузер. Но идея с комметарием в скрипте рабочая.
Спасибо

nin-jin
08.10.2025 16:26В браузере оба
Нет. По озвученной ранее причине - без синтаксического анализа невозможно разбить на лексемы.

winorun
08.10.2025 16:26Так приведи пример. Если бы предыдущий пример работал, я бы с Вами согласился, покрайне мере в озвученной выше причине. Но он не работает.

winorun
08.10.2025 16:26Впрочем Ваш пример действительно рабочий, там косяк будет после первого комментария.

winorun
08.10.2025 16:26А нет, не рабочая. можно сделать, условно
<script>*</script>
Извините что спамлю комментариями.

nin-jin
08.10.2025 16:26Думаю вам стоит самому попробовать реализовать лексер HTML - только тогда поймёте в чём проблема.

winorun
08.10.2025 16:26Дело в том что парсер html я как раз делал. И делал я его лет 15 назад. Но так как читать стандарт мне было лень, а парсер я делал довольно ограниченный. Я ограничился проверкой на довольно маленькой выборки. И с лексическим анализом я проблем не встретил. В синтаксическом там да проблемы были.
Но Вы утверждаете что с лексическим анализом на регулярках будут проблемы. Вот мне и интересно в чём там может быть проблема. Но всё Ваши утверждения сводятся к: "Я так считаю". Пример ограниченности возможности использовании конечного автомата как раз любят показывать на синтаксисе html. Но если бы были проблемы с лексическим анализом, почему не показать на нем. Это же было бы нагляднее и проще.

winorun
08.10.2025 16:26Вы имеете ввиду атрибуты тега? Так не зависит. Атрибуты у всех тегов одинаково записываются. Там проблема не забыть что кавычки разные и экранированный символ. Если вы имеете ввиду другие теги которые меняют логику работы парсера. То тут пример с тегом скрипт был бы идеальным, если бы работал. Из тегов меняющих логику парсера остаётся только style. Сейчас проверю, но думаю и тут не сработает.
Проверил. Не сработало.

user-book
появление нейронок было лучшим, что было сделано для регулярок!
h1pp0 Автор
Попросил DeepSeek V3 сгенерировать самую простую, для IPv4. При включённых размышлениях он зациклился, при отключённых предложил два таких:
^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$Проверяет базовый формат: 4 числа от 0-255, разделенные точками
и
^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$25[0-5] - числа 250-255
2[0-4][0-9] - числа 200-249
[01]?[0-9][0-9]? - числа 0-199
Это удобно и понятно, но некорректно, к сожалению. Первый вариант будет матчить
333.121.422.112, а второй вариант матчит ведущие нули012.3.02.100.После этого верить более сложным регуляркам не очень хочется. Гораздо надёжнее для популярных скопировать со SO, а непопулярные собрать вручную, например, тут.
user-book
хз, скорее всего или вы запрос как то странно построили или нейронка такая. Я с chatGPT уже давно пользуюсь для регулярок и никаких проблем, максимум изредка руками допилить
то что вы предложили, оно удобно для проверки и отладки
нормальный визуальный интерфейс для сборки регулярок я за 15 лет практики до сих пор не встречал нигде
nin-jin
А как вы его себе представляете? Что-то типа Scratch?
riv9231
Не знаю, я почему-то не сталкивался, например делал парсинг конфига VM proxmox регуляркой и все работало, правда это был не deepseek, а что-то из старых не больших нейросетей, типа Command R+ которые я запускал у себя.
Последнее время, наблюдая за тем как не стабильно deepseek пишет код, я начал задумываться, что может быть дело в том что мы не видим на какой точности запущена модель, а даже если точность хорошая, результат может портить техника предсказания ответа маленькой моделью. Deepseek отвечает то неправдоподобно быстро, то очень медленно и когда он пишет весь ответ медленно ошибок меньше, если же скорость неожиданно увеличилась, то ляпнет что-то невпопад.