본문 바로가기
CVE 취약점

CVE-2026-85706 분석 — GitLab Repository Commits API 인증 우회 임의 파일 읽기 (CVSS 10.0)

by WhiteGuidance 2026. 9. 15.
반응형

개요

항목 내용
CVE CVE-2026-85706
대상 GitLab CE/EE (Self-managed)
CVSS 3.1 10.0 (Critical) — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N
CWE CWE-22 (Path Traversal), CWE-35
취약 버전 18.7 ~ 19.1.7, 19.2.0 ~ 19.2.5, 19.3.0 ~ 19.3.1
패치 버전 19.1.8 / 19.2.6 / 19.3.2 (2026-09-10)
특이사항 CISA KEV 등재, 공개 하루 만에 실제 공격 관측

 

"로그인 없이 HTTP 요청 한 번으로 GitLab 서버가 읽을 수 있는 모든 파일을 열람할 수 있는" 취약점이다. Repository Commits API와 Repository Files API 뒤에 있는 파일 업로드 처리 로직이, 인증도 거치지 않은 채 클라이언트가 지정한 절대경로 문자열을 그대로 File.read에 넘겨버리는 게 원인이다.


배경 지식 — GitLab의 요청 처리 파이프라인

이 취약점을 이해하려면 GitLab이 하나의 요청을 처리하는 순서를 먼저 봐야 한다.

Client → Nginx → GitLab Workhorse → Puma → Grape (Rails API)
  • Workhorse는 Go로 작성된 리버스 프록시로, Rails 앞단에서 대용량 Git 업/다운로드나 파일 업로드처럼 무거운 I/O 작업을 가로채 처리한다. 파일 업로드 요청을 만나면 자기가 먼저 디스크에 임시 저장한 뒤, 그 결과(경로·크기)를 서명된 헤더(Gitlab-Workhorse-Api-Request JWT)에 담아 Rails로 넘긴다.
  • Workhorse가 가로채지 않은 요청은 Puma가 그대로 받아 Grape(GitLab이 API 라우팅에 쓰는 프레임워크)로 넘긴다.

핵심은 이 두 컴포넌트가 URL을 처리하는 방식이 서로 다르다는 점이다.

  • Workhorse의 라우트 매칭 정규식은 EscapedPath(), 즉 퍼센트 인코딩이 풀리지 않은 원본 경로를 대상으로 동작하고, 끝을 \z로 앵커링한다.
  • 반면 Puma는 Grape로 넘기기 전에 %XX 형태의 퍼센트 인코딩을 먼저 디코딩한다.

근본 원인 — 퍼센트 디코딩 시점 차이(Decoding Differential)

정상적인 커밋 조회 요청은 이렇게 생겼다.

POST /api/v4/projects/1/repository/commits
 

Workhorse는 경로 끝이 정확히 commits로 끝나는 요청을 자신의 업로드 처리 로직으로 분류(intercept)한다. 그런데 공격자가 c 한 글자만 퍼센트 인코딩해서 보내면 어떻게 될까.

POST /api/v4/projects/1/repository/%63ommits
 
  • Workhorse 입장에서는 이 경로가 commits로 끝나지 않는다(%63ommits ≠ commits). 자신의 정규식이 디코딩을 하지 않기 때문에 이 요청을 업로드 라우트로 인식하지 못하고, 가로채지 않은 채 그냥 일반 API 프록시 경로로 흘려보낸다.
  • 반면 Puma는 Grape에 넘기기 전에 %63c로 디코딩하므로, Grape 입장에서는 정확히 POST :id/repository/commits 엔드포인트로 라우팅된다.

"Workhorse는 이 요청을 업로드 요청으로 취급하지 않았는데, Rails는 정상적인 업로드 엔드포인트로 처리해버리는" 상태가 만들어진다. commits뿐 아니라 repository, files 등 경로에 들어가는 정적 문자열 아무 글자나 하나만 퍼센트 인코딩해도 동일하게 재현되며, 후속 조사에서는 URL 끝에 .json 포맷 확장자를 붙이거나 슬래시(/)를 하나 더 붙이는 방식으로도 같은 효과(Workhorse의 \z 앵커 정규식 회피)를 낼 수 있는 것으로 확인됐다.


소스 코드 레벨 분석 — 왜 인증 없이 파일을 읽게 되는가

1) require_gitlab_workhorse! ≠ 인증

Repository Commits API의 커밋 생성 엔드포인트는 대략 다음과 같은 구조로 되어 있었다(공개된 익스플로잇 분석 자료를 바탕으로 재구성한 의사코드).

 

require_gitlab_workhorse!는 "이 요청이 Workhorse를 거쳐서 왔는가"만 확인하는 체크다. 즉 요청 헤더에 Workhorse가 서명한 Gitlab-Workhorse-Api-Request JWT가 붙어 있는지만 본다. 문제는 Workhorse는 자신이 프록시하는 모든 요청에 이 서명 헤더를 붙인다는 점이다. 앞서 본 퍼센트 인코딩 우회로 Workhorse의 업로드 인터셉트를 피해가더라도, 요청 자체는 여전히 Workhorse를 거쳐 프록시되므로 이 서명은 정상적으로 붙는다.

 

결과적으로 require_gitlab_workhorse!는 사실상 "인증"이 아니라 "Workhorse를 거쳤는가"만 확인하는 체크인데, 원래 이 엔드포인트는 그 뒤에 authenticate!(실제 로그인 세션·개인 액세스 토큰 검증)가 이어져야 정상이었다. 이 한 줄이 빠지면서 로그인 여부와 무관하게 핸들러 내부까지 도달할 수 있게 된 것이다.

2) 클라이언트가 보낸 값을 그대로 신뢰하는 file_params_from_body_upload

정상적인 흐름이라면 file, file.path, file.size 같은 값은 Workhorse가 파일을 실제로 임시 디스크에 써놓고 그 결과를 담은 것이어야 한다. 하지만 이 요청은 애초에 Workhorse의 업로드 처리 로직을 우회해서 온 것이므로, 이 값들은 그냥 공격자가 쿼리 파라미터로 위조해서 보낸 문자열이다.

 

requires :file, WorkhorseFile 같은 파라미터 검증 로직은 file=(빈 값)만 넘겨도 nil로 캐스팅되어 통과되는 것으로 분석되었다. 즉 공격자는 다음과 같은 요청 하나로 이 지점까지 도달할 수 있다.

POST /api/v4/projects/1/repository/%63ommits
    ?file=
    &file.path=/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml
    &file.size=1
Content-Type: application/x-www-form-urlencoded

 

여기까지만 해도 "파일이 존재하는지"는 이미 인증 없이 확인 가능한 상태(존재 여부 오라클)가 되고, 실제 내용까지 응답으로 받아내려면 한 단계가 더 필요하다.

3) 에러 메시지를 통한 콘텐츠 유출

Content-Type: application/x-www-form-urlencoded인 경우, 읽어들인 파일 내용은 다시 Rack::Utils.parse_nested_query로 넘어가 쿼리 스트링처럼 파싱을 시도한다.

일반적인 설정 파일(YAML, JSON 등)에는 % 문자가 뒤에 2자리 16진수가 따라오지 않는 형태로 섞여 있는 경우가 흔하다. 이럴 경우 Rack::QueryParser::InvalidParameterError가 발생하는데, 이 예외의 메시지 안에 파싱을 시도하던 원본 바이트가 그대로 포함된다.

Rack::QueryParser::InvalidParameterError: invalid %-encoding (<파일 원본 내용이 여기 그대로 들어감>)

 

이 예외 메시지가 그대로 HTTP 400 응답 본문에 실려서 돌아오기 때문에, 결과적으로 인증도 거치지 않은 공격자가 파일 전체 내용을 그대로 받아볼 수 있게 된다. JSON 콘텐츠 타입 분기(Oj 파서 사용)에서는 이 에러가 발생하지 않기 때문에, 반드시 urlencoded 콘텐츠 타입으로 보내야 콘텐츠 에코가 발생한다는 점이 흥미로운 디테일이다.

4) 대체 트리거 — Repository Files API

동일한 file_params_from_body_upload 싱크는 POST/PUT :id/repository/files/:file_path 엔드포인트에서도 재사용된다. 이 라우트는 퍼센트 인코딩이 아니라 경로 끝에 슬래시(/)를 하나 더 붙이는 것만으로도 Workhorse의 \z 앵커 정규식을 피해갈 수 있는 것으로 분석되었고, Commits API와 달리 접근 가능한 프로젝트 ID가 없어도 트리거가 가능한 것으로 보고되었다.


공격 영상


패치 분석 — 19.3.2에서 무엇이 바뀌었는가

  1. authenticate! 추가 — Commits/Files 관련 3개 엔드포인트와 그 앞단의 /authorize 사전 단계에 실제 사용자 인증 체크를 넣었다. 이제는 "Workhorse를 거쳤는가"가 아니라 "인증된 사용자인가"를 확인한다.
  2. 파일 메타데이터 출처 제한file.path, file.size를 요청 파라미터에서 직접 읽지 않고, Workhorse 미들웨어가 실제로 생성한 UploadedFile 객체에서만 취득하도록 바꿨다. 클라이언트가 임의의 경로 문자열을 주입할 통로 자체를 없앤 것이다.
  3. 파서 예외 메시지 비노출Rack::Utils.parse_nested_query에서 발생하는 예외 메시지를 응답 본문에 그대로 실어 보내지 않도록 처리해, 설령 유사한 파싱 경로를 다시 타더라도 콘텐츠 에코로 이어지지 않게 막았다.
반응형