Обзор
12 октября 2026 года компания GitLab выпустила версии 19.4.2, 19.3.5 и 19.2.8 для устранения CVE-2026-97229, уязвимости переполнения целого числа при обработке регулярных выражений CI/CD. О проблеме сообщил пользователь joaxcar через платформу HackerOne. Компания GitLab присвоила ей оценку 10.0 по шкале CVSS и описала возможность выполнения кода неаутентифицированным пользователем.
В advisory указано, что CVE-2026-97229 затрагивает GitLab CE и EE версий с 19.2 до 19.2.8, с 19.3 до 19.3.5 и с 19.4 до 19.4.2. По заявлению GitLab, уязвимы все типы развертывания, если не указано иное. Экземпляр GitLab.com был испавлен до публикации информации, а клиентам с Dedicated-развертыванием предпринимать какие-либо действия не требуется.
Исследователи из Rapid7 разработали сквозной PoC-эксплойт, который надежно выполняет произвольные команды от имени системного пользователя git в операционной системе GitLab на официальном Linux-образе amd64 gitlab/gitlab-ce:19.4.1-ce.0 с включенной функцией ASLR.
В данном анализе рассматривается один сквозной путь атаки. Он начинается после того, как злоумышленник отправил в публичный проект как вредоносную конфигурацию CI, так и путь с особой структурой именования; неаутентифицированным является только финальный запрос проверки синтаксиса CI (CI lint). Эта предпосылка противоречит оценке PR:N от GitLab, и поскольку исходный отчет остается конфиденциальным, нам пока неизвестно, каким образом описанная атака позволяет ее обойти.
Технический анализ
На высоком уровне цепочка атаки простирается от управляемого злоумышленником ввода в репозитории до псевдокода Onigmo и, в конечном счете, выполнения нативного кода.
Рисунок 1: От входных данных репозитория до выполнения команд.
Правила CI передают пути репозитория в Onigmo
GitLab поддерживает сопоставление с регулярными выражениями для путей репозитория в директивах rules:exists:regexp и rules:changes:regexp. Один из способов оценки такого выражения во время статической обработки конфигурации заключается в его размещении в условном включении (conditional include):
RegexpMatcher разворачивает выражение, компилирует его с помощью встроенного движка регулярных выражений Ruby и проверяет его соответствие путям из выбранного рабочего дерева Git (worktree):
К моменту достижения точки [0] GitLab проверяет только длину развернутого исходного выражения. Вызов в точке [1] передает каждое имя файла в выбранном коммите, в то время как compile_pattern в точке [2] в конечном итоге вызывает Regexp.new. Таким образом, каждая итерация в точке [3] переходит из конфигурации CI в нативный Onigmo с путем репозитория в качестве объекта сопоставления.
Внедрение крупного повторения в обход проверок
GitLab устанавливает несколько разумных ограничений для регулярных выражений CI. Интересные части его модуля-обертки показаны ниже:
Проверка длины исходного кода выполняется до этой функции. Проверка вызовов подпрограмм в точке [5] и проверка границ повторений в точке [6] защищают от структурного роста, однако сканер в точке [4] распознает только непрерывные цифры. Компиляция затем происходит дважды: [7] проверяет зонд без альтернатив, а [8] проверяет запрошенный регулярное выражение и задает тайм-аут в 50 миллисекунд. Любой из объектов отклоняется, если его размер превышает один мегабайт.
PoC скрывает границу квантификатора от сканера в точке [4] с помощью встроенного комментария: {5(?#)7805}
(?#) — это встроенный комментарий в регулярном выражении. Onigmo игнорирует его и считывает границу как 57 805. Сканер GitLab ожидает непрерывные цифры, поэтому он не видит границу, превышающую 1 000.
Этот обход позволяет применить крупное повторение, однако проверяемое выражение все еще имеет размер всего 986 байт. Шесть классов [[:word:]] расширяются при начале сопоставления с путем репозитория, содержащим допустимые четырехбайтовые символы UTF-8. Ruby повторно компилирует тот же исходный код для UTF-8, каждый класс слов POSIX расширяется до большой таблицы диапазонов Unicode, и GitLab никогда не проверяет размер этой новой программы.
Ruby выбирает или перекомпилирует регулярное выражение перед запуском сопоставителя с тайм-аутом:
В точке [9] Ruby возвращает существующую программу только тогда, когда ее кодировка совпадает с объектом сопоставления. В противном случае в точке [10] исходный код перекомпилируется для кодировки объекта с помощью Onigmo. Только после этого в точке [11] начинается сопоставление с тайм-аутом, оставляя небезопасную компиляцию вне зоны действия тайм-аута.
Onigmo компилирует регулярное выражение в байт-код, который в исходном коде называется псевдокодом (pcode). Затем виртуальная машина регулярных выражений запускает этот псевдокод для каждого пути, поэтому перекомпиляция в UTF-8 в точке [10] является моментом создания программы избыточного размера в виде псевдокода.
Повторяющаяся цель также несет в себе байты, которые выполняются после переполнения:
Шесть классов [[:word:]] служат лишь множителем размера. Полезная нагрузка размещается еще в четырех классах символов, генерируемых функцией character_class(stage). Такой класс, как [abc], компилируется в битовую маску с установленными битами для a, b и c. Вспомогательная функция работает в обратном направлении от каждой желаемой последовательности псевдокода и выбирает членов класса, чья битовая маска воспроизводит эти байты. Это помещает управляемые злоумышленником инструкции виртуальной машины в конец каждой развернутой копии.
Преодоление INT_MAX
В кодировке UTF-8 одна копия повторяющегося тела составляет 37 151 байт. Соответствующий Onigmo compiler code in Ruby 3.3.12 равен:
С учетом размера этого тела (37 151) и границы квантификатора (57 805):
37 151 × 57 805 = 2 147 513 555 = 0x800074d3
Это на 29 908 (0x74D4) байт больше подписанного максимума INT_MAX (0x7FFFFFFF). В точке [12] отрицательный результат удовлетворяет тесту компилятора «достаточно мал для развертывания». Onigmo входит в путь развертывания в точке [13] и выдает все 57 805 копий.
Символ | сразу после {5(?#)7805} делает повторяющееся тело первой альтернативой и оставляет вторую альтернативу пустой. Onigmo компилирует этот символ | в две ветви псевдокода. Инструкция OP_PUSH ближе к началу предназначена для записи пустой альтернативы в качестве цели возврата (backtracking target), но не передает выполнение по этому пути. Первым ошибочным переходом является OP_JUMP, выданный после гигантской первой альтернативы компилятором альтернатив:
Переход в точке [14] должен пропустить пустую альтернативу и приземлиться в конце альтернативы. Однако компилятор рассчитал позицию pos на основе уже обрезанной длины. Наша сборка выдает переход со смещением 2 147 513 698 со смещением -136, направляя выполнение в первую битовую маску полезной нагрузки в последней развернутой копии, в 140 байтах от конца псевдокода.
От битовой маски класса символов до system()
Обрезанная ветвь теперь покидает сгенерированный нормальный псевдокод и попадает в контролируемую битовую маску класса символов. В результате Onigmo выполняет байты, закодированные функцией character_class(), в качестве инструкций виртуальной машины, запуская последовательность, показанную здесь.
Рисунок 2: От контролируемого псевдокода Onigmo до нативного выполнения команд.
Контролируемый псевдокод перенаправляет стек возврата Onigmo, который хранит сохраненное состояние сопоставления, в путь репозитория. При неудачном сопоставлении виртуальная машина восстанавливает два значения, показанных на диаграмме: следующую инструкцию из пути и FinishCode — указатель из своего нижнего фрейма стека. Первое значение определяет, где возобновляется выполнение контролируемого псевдокода. Второе предоставляет известный адрес внутри libruby.
Инструкция пути передает управление финальной контролируемой битовой маске. Эта маска настраивает восстановленный указатель libruby на короткую последовательность инструкций, которая передает команду пути в функцию system(), а затем записывает этот адрес в слот нативного возврата функции match_at(). Когда сопоставитель завершает работу, выполнение возвращается через перезаписанный слот. FinishCode и точка вызова находятся в одном отображении libruby, поэтому расстояние между ними остается постоянным при включенном ASLR.
Эксплуатация
Доставка ветви через проверку CI lint
Как только вредоносная конфигурация и путь попадают в публичную ветку, CI lint API в GitLab предоставляет неаутентифицированный триггер. GET-форма принимает выбранный злоумышленником content_ref, загружает .gitlab-ci.yml из этого коммита и оценивает его:
Маршрут проверяет, может ли запрашивающий читать публичный репозиторий, разрешает предоставленную ссылку (ref) и передает ее CI-конфигурацию статическому линтеру:
Публичная видимость позволяет authorize_read_code! в [17] успешно завершиться без учетных данных, в то время как условие в [18] не отклоняет отсутствующий current_user. Выбранная ветка становится входными данными для статического линтера через [19] и [20], без создания пайплайна или обращения к ранеру. Именно поэтому триггер эксплойта может быть неаутентифицированным, даже если продемонстрированная установка требует прав на запись в проект.
Запуск команды
Первоначальный Python PoC клонирует публичный проект, контролируемый оператором, создает ветку с полезной нагрузкой, запускает обратный вызов (callback) и отправляет запрос линтера:
Ожидается статус HTTP 502. Команда выполняется синхронно воркером Puma. После возврата из system() поврежденный воркер аварийно завершает работу. GitLab Workhorse, обратный прокси перед Puma, видит закрытие соединения с бэкендом Rails и возвращает 502. Puma заменяет воркер. Сигналом успеха является вывод обратного вызова, а не статус HTTP.
Целевые значения в этом PoC, такие как SYSTEM_STEP_COUNT (количество шагов назад в кодировке UTF-8 от FinishCode до точки вызова system() в libruby), соответствуют официальному GitLab CE 19.4.1 на Linux amd64. Другие уязвимые пакеты или архитектуры требуют скорректированного pcode и смещений libruby. В воркере требуется чуть более 2 ГиБ, а для нашей полной одноузловой цели GitLab потребовалось около 9 ГиБ свободного пространства. Экземпляры с малым объемом памяти могут начать использовать подкачку (swap), отклонить выделение памяти или вызвать OOM-киллер.
Исправление
Бэкпорт для Ruby в GitLab заменяет умножение в [12] проверкой на основе деления, которая не может привести к переполнению, и отклоняет скомпилированные программы размером примерно более 512 МиБ:
На исправленном образе созданный квантификатор остается компактным циклом, и запрос возвращает нормальное ложное совпадение. Двойное освобождение памяти (double-free) в диапазоне повторения, исправленное в том же релизе, затрагивает путь ошибки и не является частью этой цепочки.
Рекомендации по обнаружению
Для стратегии эксплуатации, описанной в этом анализе, сопоставьте:
- /var/log/gitlab/gitlab-workhorse/current: длящийся несколько секунд GET /api/v4/projects/<project>/ci/lint?content_ref=<ref>, ошибка badgateway: failed to receive response: EOF и HTTP 502.
/var/log/gitlab/gitlab-workhorse/current: длящийся несколько секунд GET /api/v4/projects/<project>/ci/lint?content_ref=<ref>, ошибка badgateway: failed to receive response: EOF и HTTP 502.
- Воркер Puma аварийно завершает работу или перезапускается одновременно с этим; любой сохраненный трассировочный след (trace) содержит regexp_matcher.rb.
Воркер Puma аварийно завершает работу или перезапускается одновременно с этим; любой сохраненный трассировочный след (trace) содержит regexp_matcher.rb.
- Упоминаемый коммит: разделение повторения через (?#), повторяющиеся классы слов POSIX и путь, содержащий четырехбайтовые символы UTF-8.
Упоминаемый коммит: разделение повторения через (?#), повторяющиеся классы слов POSIX и путь, содержащий четырехбайтовые символы UTF-8.
Дочерний процесс оболочки или увеличение памяти воркера примерно на 2 ГиБ усиливают совпадение. Нераскрытый путь может отличаться.
Рекомендации по минимизации рисков
Клиентам GitLab с самостоятельным управлением (self-managed) следует обновиться до исправленного патча для своего релиза: 19.2.8, 19.3.5 или 19.4.2. В качестве временной меры ограничьте доступ без учетных данных к общедоступным проектам, в которые злоумышленники могут осуществлять запись, или заблокируйте этот маршрут линтера. Элементы управления маршрутами не устраняют уязвимый компилятор Ruby.
Пожалуйста, ознакомьтесь с критическим релизом патча GitLab для получения последних рекомендаций.








