
개요
| 항목 | 내용 |
| 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에 넘기기 전에 %63 → c로 디코딩하므로, 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에서 무엇이 바뀌었는가

- authenticate! 추가 — Commits/Files 관련 3개 엔드포인트와 그 앞단의 /authorize 사전 단계에 실제 사용자 인증 체크를 넣었다. 이제는 "Workhorse를 거쳤는가"가 아니라 "인증된 사용자인가"를 확인한다.
- 파일 메타데이터 출처 제한 — file.path, file.size를 요청 파라미터에서 직접 읽지 않고, Workhorse 미들웨어가 실제로 생성한 UploadedFile 객체에서만 취득하도록 바꿨다. 클라이언트가 임의의 경로 문자열을 주입할 통로 자체를 없앤 것이다.
- 파서 예외 메시지 비노출 — Rack::Utils.parse_nested_query에서 발생하는 예외 메시지를 응답 본문에 그대로 실어 보내지 않도록 처리해, 설령 유사한 파싱 경로를 다시 타더라도 콘텐츠 에코로 이어지지 않게 막았다.
'CVE 취약점' 카테고리의 다른 글
| CVE-2026-42559: rmcp Streamable HTTP 서버의 DNS Rebinding 취약점 분석 (0) | 2026.09.20 |
|---|---|
| CVE-2026-16723 : Fastjson 1.x @JSONType 신뢰 분기를 악용한 Pre-Auth RCE (0) | 2026.09.17 |
| Feast Registry gRPC RCE — dill.loads() 하나로 뚫리는 인가 체크 (CVE-2026-56121) (0) | 2026.09.13 |
| NLTK 퍼센트 인코딩 경로 순회(Path Traversal) 취약점 분석 (CVE-2026-12243) (0) | 2026.09.11 |
| Gitea diffpatch RCE 취약점 분석 (CVE-2026-60004) (1) | 2026.09.10 |