GitLab CVE-2026-85706路径穿越漏洞,未认证读取任意文件
摘要
GitLab 在 2026-09-10 的例行补丁中修复了 CVE-2026-85706,GitLab 作为 CNA 给了它 CVSS 3.1 满分 10.0。漏洞在 repository commits API,未认证攻击者可以读取 GitLab 服务器上的任意文件。
但真正值得记录的,是它的成因结构:
四层里任何一层正常,这个漏洞都不成立。它之所以值 10 分,是因为四层同时失守,而且第一层的触发条件只是一个斜杠。
另外两点需要提前说清楚,因为它们决定了这条漏洞在实际环境里的真实风险:
"任意文件读取"是有条件的。 文件确实总会被读,但内容能否被带出,取决于四个额外条件(见第四节)。
gitlab.yml是那个"必中"目标,而它恰好是整台机器上最要命的文件。
同批补丁里还有一个 RCE。 CVE-2026-88765(Unicode 转换缓冲区溢出,CVSS 8.5,影响范围追溯到 12.3)能通过导入恶意项目导出包实现远程代码执行。只盯着满分的那一个,会漏掉它。
事实基线
时间线:
从补丁发布到蜜网捕获探测流量不到 48 小时,到公开复现环境不到一周。
四个缺陷,逐个拆解
末尾斜杠:同一个 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 是错的。真实载荷见第六节。
回显:从"读到"到"拿到"
文件被读取是一回事,内容能被攻击者拿到是另一回事。这条链的回显机制相当"意外":
Rack 的表单解析报错。 整份文件内容被当作表单字符串解析。当解析器遇到非法的百分号编码(% 后面不是两个十六进制字符)时,会抛出异常,而异常消息里会带上出错的那一段内容——这段消息被写进了 HTTP 400 的响应体。文件内容就这样从错误信息里漏了出来。
这解释了为什么 PoC 默认目标是 /var/opt/gitlab/gitlab-rails/etc/gitlab.yml:这份文件由 Omnibus 自动生成,内容里必然含有带 % 的序列,必定触发 Rack 报错。 选它不是因为它是唯一重要的文件,而是因为它是唯一稳定回显的文件。
要让内容真正带出来,四个条件必须同时成立:
内容含非法百分号编码 —— 否则不触发报错,没有回显通道;
出错片段落在截断点之前 —— Rack 2.2.23 的表单解析按
&和;切分参数,异常消息只包含非法%所在的那一段。&之后的内容直接被丢弃。凭据如果排在截断点之后,泄露不出来。
文件小于 4 MB —— Rack 默认解析上限
RACK_QUERY_PARSER_BYTESIZE_LIMIT=4194304,超出不走回显;
git 服务账号有读权限 —— 底层是 git 进程对目标文件做 stat 和 read,读不到的照样读不到。
所以"任意文件读取"这个说法需要打折。 内容规整的纯文本(比如 /etc/passwd)确实被读了,但响应可能只有一个 401,什么都带不出来。能稳定回显的是那些天然含 % 序列的文件:gitlab.yml、redis.conf 这一类。
那为什么它仍然是 10 分? 因为 gitlab.yml 这一个"必中"目标就够了——数据库连接串、Redis 配置、secret_key / otp_key、SMTP、LDAP/SSO、对象存储密钥全在里面。拿到 secret_key 就可以伪造 Rails 会话 Cookie 直接接管管理员账号;拿到 Redis 凭据就可以接着打内网。
而且即使内容回显不出来,400 / 401 / 500 的响应差异也足够做文件存在性探测——攻击者可以借此摸清服务器文件布局、权限边界和运行环境,为下一步铺路。
复现与检测
复现(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 访问日志里检索以下特征:
回溯窗口建议覆盖 2026-09-10 补丁发布之前——这个漏洞在补丁公开前就已存在,只是当时没人知道。
临时拦截
在 WAF / 反向代理层拦截路径匹配以下模式的 POST 请求:
^/api/v4/projects/[^/]+/repository/commits/建议先比对自身环境的历史流量再强制,避免误伤正常的提交上传行为。