摘要

GitLab 在 2026-09-10 的例行补丁中修复了 CVE-2026-85706,GitLab 作为 CNA 给了它 CVSS 3.1 满分 10.0。漏洞在 repository commits API,未认证攻击者可以读取 GitLab 服务器上的任意文件。

但真正值得记录的,是它的成因结构:

缺陷

单独看

1

末尾斜杠导致 Workhorse 与 Rails 的路由理解不一致

只是路由差异

2

require_gitlab_workhorse! 收到的是"全量注入"的内部 JWT

只是防线的适用范围过宽

3

认证发生在文件读取之后

只是顺序问题

4

File.read 直接信任请求传入的 file.path

只是缺一个路径校验

四层里任何一层正常,这个漏洞都不成立。它之所以值 10 分,是因为四层同时失守,而且第一层的触发条件只是一个斜杠

另外两点需要提前说清楚,因为它们决定了这条漏洞在实际环境里的真实风险:

  • "任意文件读取"是有条件的。 文件确实总会被读,但内容能否被带出,取决于四个额外条件(见第四节)。gitlab.yml 是那个"必中"目标,而它恰好是整台机器上最要命的文件。

  • 同批补丁里还有一个 RCE。 CVE-2026-88765(Unicode 转换缓冲区溢出,CVSS 8.5,影响范围追溯到 12.3)能通过导入恶意项目导出包实现远程代码执行。只盯着满分的那一个,会漏掉它。

事实基线

项目

内容

CVE

CVE-2026-85706(GHSA-f47w-mrg9-g9p2)

组件

repository commits API(GitLab CE / EE)

类型

CWE-22 路径穿越 + 认证执行缺失

CVSS

10.0CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N(CNA:GitLab Inc.;NVD 尚未给出自己的评估)

影响版本

18.7 ≤ v < 19.1.8 · 19.2 ≤ v < 19.2.6 · 19.3 ≤ v < 19.3.2

修复版本

19.1.8 / 19.2.6 / 19.3.2

报告者

s3ntago(通过 HackerOne)

官方修复 commit

0d9ce3e758a85f0690be751e213625f7902c0361

CISA KEV

2026-09-11 收录,FCEB 截止 2026-09-14

EPSS

< 1%(Very Low)

时间线:

日期

事件

2026-09-10

GitLab 发布 19.3.2 / 19.2.6 / 19.1.8

2026-09-11

CISA 收录 KEV,给 FCEB 仅 3 天期限

2026-09-11 13:31 UTC

watchTowr 发布 Rapid Reaction,称已复现漏洞,且其蜜网 Attacker Eye 已捕获行为探测流量

2026-09-12

NVD 收录

2026-09-17

vulhub 合入 PR #800,提供基于官方 Omnibus 镜像 19.3.1 的完整环境与匿名 PoC

从补丁发布到蜜网捕获探测流量不到 48 小时,到公开复现环境不到一周。

四个缺陷,逐个拆解

CVE-2026-85706 四个独立缺陷叠加导致未认证任意文件读取 FIG 1 四个「差一点」叠出一个满分漏洞 POST /api/v4/projects/:id/repository/commits/ 1 末尾斜杠 → 路由歧义 Workhorse 锚定匹配失败原样转发,Rails 忽略斜杠照常分发 2 内部 JWT 是全量注入的 require_gitlab_workhorse! 本意只收 Workhorse 请求,实际全部放行 3 认证顺序颠倒 先读文件,后验身份——内容此时已经进入内存 4 File.read 无路径约束 直接信任 file.path,连 ../ 都不需要,绝对路径即可 未认证请求读到任意文件内容(能否带出见 FIG 3)

末尾斜杠:同一个 URL,两种理解

末尾斜杠导致 Workhorse 与 Rails 对同一 URL 产生不同的路由理解 FIG 2 同一个 URL,两种理解 GitLab 前端代理 Workhorse 与后端 Rails 的路由差异 POST /api/v4/projects/1/repository/commits / ← 多出来的这一个斜杠 Workhorse 的匹配 /repository/commits$ 锚定到结尾,带斜杠即不匹配 判定:非上传请求,原样转发 Rails 的路由 /repository/commits(/) 斜杠可选,匹配成功 判定:Repository Commits API 同一个 URL 得到两个答案——信任边界就在此处被让出

GitLab 的请求链路上,Workhorse 是前置代理,负责大文件上传等重活。它内部有一条严格锚定到结尾的上传路由,匹配的是 /repository/commits不带末尾斜杠)。

攻击者只要在路径末尾加一个斜杠:

  • Workhorse:锚定匹配失败 → 判定"这不是上传请求" → 原样转发给后端 Rails,不做参数重写;

  • Rails:路由不区分这个斜杠 → 照常分发到 Repository Commits API

同一个 URL,两个组件给出了不同答案。信任边界不是被绕过的,是被让出去的

这类缺陷在分层代理架构里相当常见,识别方法也很直接:当两个组件对同一个输入做独立解析、且解析规则不一致时,两者之间的缝隙就是攻击面。 结尾斜杠、大小写、路径规范化、百分号编码、重复斜杠——都是同一类缝隙的变体。

require_gitlab_workhorse! 收到的是全量 JWT

Rails 侧这个接口有一道防线叫 require_gitlab_workhorse!,本意是"只接受来自 Workhorse 的请求"。听起来能拦住直接打 API 的攻击者。

问题在于:Workhorse 在转发所有代理请求时都会附带内部 JWT,不管这个请求是不是它自己主动处理过的。第 3.1 节里,Workhorse 恰好判定"这不是我要处理的上传",于是原样转发——但 JWT 照常注入。

于是这道防线照常放行,攻击者构造的原始表单参数未经任何重写直达 Rails。

这里的教训是关于防线的作用域require_gitlab_workhorse! 校验的是"这个请求是否经过 Workhorse",而不是"这个请求是否由 Workhorse 生成"。当 Workhorse 对所有请求都盖章时,这个校验就退化成了一句恒真的断言。

认证做晚了:文件已经进内存

这个接口不是没有认证。它的问题在于顺序:

请求进入
  → 项目读权限检查
  → 读取文件            ← 文件内容进入内存
  → 调用用户认证        ← 认证在这里才发生

认证拦得住后续流程,拦不住已经完成的读取。内容已经在内存里了。

这和 OpenAM 的 sunRemoteAuthSecurityEnabled 是同一类错误——安全检查的位置晚于危险操作。评估任何安全控制时,第一个该问的问题不是"它检查什么",而是"它什么时候检查"。

File.read 直接信任 file.path

服务端直接使用请求侧传入的文件元数据(file.path)去读取本地文件,没有任何路径限制

值得注意的是:这里连经典的 ../ 遍历都不需要,直接给绝对路径就行。这也意味着基于 ../ 特征的 WAF 规则(以及大多数"路径穿越"检测规则)在这条链上是失效的——因为没有穿越,只有一次直白的本地读取。

这也解释了为什么各种预警稿里写的 GET .../commits/../../../etc/passwd 是错的。真实载荷见第六节。

回显:从"读到"到"拿到"

从文件被读取到内容实际泄露之间需要通过的四个条件 FIG 3 读到,不等于拿到 回显依赖 Rack 表单解析报错,四个条件必须同时成立 文件已被读取——这一步总会发生 内容含非法百分号编码(% 后非两位十六进制) 出错片段落在 & 截断点之前 文件小于 4 MB 解析上限 git 服务账号有读权限 内容实际泄露 任一层不过则内容不外带,但 400 / 401 / 500 的响应差异仍足以做文件存在性探测。

文件被读取是一回事,内容能被攻击者拿到是另一回事。这条链的回显机制相当"意外":

Rack 的表单解析报错。 整份文件内容被当作表单字符串解析。当解析器遇到非法的百分号编码% 后面不是两个十六进制字符)时,会抛出异常,而异常消息里会带上出错的那一段内容——这段消息被写进了 HTTP 400 的响应体。文件内容就这样从错误信息里漏了出来。

这解释了为什么 PoC 默认目标是 /var/opt/gitlab/gitlab-rails/etc/gitlab.yml这份文件由 Omnibus 自动生成,内容里必然含有带 % 的序列,必定触发 Rack 报错。 选它不是因为它是唯一重要的文件,而是因为它是唯一稳定回显的文件。

要让内容真正带出来,四个条件必须同时成立:

  1. 内容含非法百分号编码 —— 否则不触发报错,没有回显通道;

  1. 出错片段落在截断点之前 —— Rack 2.2.23 的表单解析按 &; 切分参数,异常消息只包含非法 % 所在的那一段。& 之后的内容直接被丢弃。凭据如果排在截断点之后,泄露不出来。

  1. 文件小于 4 MB —— Rack 默认解析上限 RACK_QUERY_PARSER_BYTESIZE_LIMIT=4194304,超出不走回显;

  1. git 服务账号有读权限 —— 底层是 git 进程对目标文件做 stat 和 read,读不到的照样读不到。

所以"任意文件读取"这个说法需要打折。 内容规整的纯文本(比如 /etc/passwd)确实被读了,但响应可能只有一个 401,什么都带不出来。能稳定回显的是那些天然含 % 序列的文件:gitlab.ymlredis.conf 这一类。

那为什么它仍然是 10 分? 因为 gitlab.yml 这一个"必中"目标就够了——数据库连接串、Redis 配置、secret_key / otp_key、SMTP、LDAP/SSO、对象存储密钥全在里面。拿到 secret_key 就可以伪造 Rails 会话 Cookie 直接接管管理员账号;拿到 Redis 凭据就可以接着打内网。

而且即使内容回显不出来,400 / 401 / 500 的响应差异也足够做文件存在性探测——攻击者可以借此摸清服务器文件布局、权限边界和运行环境,为下一步铺路。

复现与检测

CVE-2026-85706 的版本判断、日志指纹与临时拦截规则 FIG 4 自查三件事:版本、日志、拦截 回溯窗口建议覆盖 2026-09-10 补丁发布之前 1 版本判断 18.7 ≤ v < 19.1.8 · 19.2 ≤ v < 19.2.6 · 19.3 ≤ v < 19.3.2 查 /opt/gitlab/version-manifest.txt 或后台 Help 页;GitLab.com 与 Dedicated 无需动作 2 日志指纹 POST /api/v4/projects/{id}/repository/commits/ 末尾斜杠是最硬指纹 file.path 参数值为绝对路径 无 Authorization / PRIVATE-TOKEN 请求头 响应 400,body 中夹带本不该出现在 API 报错里的文件内容片段 3 临时拦截 ^/api/v4/projects/[^/]+/repository/commits/ 拦截匹配该模式的 POST 请求;先比对自身流量再强制,避免误伤正常上传

复现(vulhub PR #800,基于 GitLab CE 19.3.1)

利用前提是目标实例上至少存在一个公开项目——这个信息匿名可查:

# Step 1:匿名拿一个公开项目 ID
curl -s "http://target/api/v4/projects?visibility=public&simple=true&per_page=100"
# Step 2:触发文件读取
curl -i -X POST "http://target/api/v4/projects/{id}/repository/commits/" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "file=" \
  --data-urlencode "file.path=/var/opt/gitlab/gitlab-rails/etc/gitlab.yml" \
  --data-urlencode "file.size=1"

两个关键细节,写错任何一个都打不通:

  • URL 末尾的斜杠不能省。 省了就回到 Workhorse 的正常上传链路,走不通。

  • file.path 直接给绝对路径,不需要 ../

响应为 400,错误信息里包含文件内容片段。

日志指纹

在网关、Workhorse、NGINX 访问日志里检索以下特征:

特征

说明

POST /api/v4/projects/{id}/repository/commits/

末尾斜杠是最硬的指纹

请求体含 file.path 参数,值为绝对路径

正常客户端不会这样调用

Authorization / PRIVATE-TOKEN 请求头

未认证调用

响应 400,且 body 中夹带文件内容片段

命中即视为已成功利用

回溯窗口建议覆盖 2026-09-10 补丁发布之前——这个漏洞在补丁公开前就已存在,只是当时没人知道。

临时拦截

在 WAF / 反向代理层拦截路径匹配以下模式的 POST 请求:

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

建议先比对自身环境的历史流量再强制,避免误伤正常的提交上传行为。