漏洞

GitLab CVE-2026-85706:文件怎样流进错误响应

GitLab 的提交接口曾在认证之前读取请求指定的本地文件,解析异常又把其中的片段带回客户端;这项已遭利用的漏洞需要及时升级,同时沿请求日志核实实际返回了什么,才能确定后续密钥处置的范围。

浅暖纸手绘:文件抽屉里的一段纸条进入打印机构,变成标有 400 的错误响应,表现文件片段随报错流出的过程。
文章导航

一条创建提交的请求,最后被 GitLab 拒绝了,服务器里的文件内容却可能已经出现在错误响应中。CVE-2026-85706 的关键就藏在这个先后顺序里:接口先把一个路径当成上传文件来读取,再解析内容,用户身份检查排在了后面。一旦解析器把出错的内容写进异常消息,请求甚至不必走到创建提交那一步,信息就已经返回客户端。

GitLab 在 2026 年 9 月 10 日发布紧急修复,CISA 次日将它收入已知遭利用漏洞目录;watchTowr 同日披露,其蜜罐已经观察到针对该漏洞的探测。GitLab 给出的 CVSS 3.1 是 10.0。这些信息足以把修复提到前面,但处理这件事还需要回答另一个问题:服务器读过哪些文件,哪些内容确实离开了服务器?两者会影响完全不同的调查和密钥处置。

我们的建议是,受影响的自建实例立即安装所在受支持分支的修复版本,保存现有日志并行调查。发布时仍受支持的 19.3、19.2 分支分别应至少到 19.3.2、19.2.6;当前稳定小版本为 19.4,19.4.0 已包含修复。停在更旧分支的实例需要按官方升级路径迁移。下面先解释这次错误怎样发生,再讨论怎样验证修复、怎样判断泄露范围。

1 创建提交为什么要先读一个文件

GitLab 的请求通常先经过 Workhorse,再进入负责业务的 Rails。Workhorse 用 Go 实现,承担上传、下载以及一些不适合长期占用 Rails 工作进程的任务。创建提交的 API 可以一次提交多个文件,请求体可能很大。把这些字节先暂存到磁盘,再让 Rails 从暂存文件里取得参数,是一种合理的工程安排。

这套流程里,有两个很容易混淆的“路径”。提交操作中的 actions[][file_path] 是仓库内要创建或修改的文件名;漏洞涉及的 file.path 则是服务器本地的暂存文件路径。前者属于用户想提交的内容,后者本应属于上传系统的内部元数据。它们的权限来源不同。

正常流程可以在 processRequestBody() 中看到:Workhorse 保存原始请求体,取得上传结果生成的字段,把请求体替换成这些字段,并记录上传文件,再转交 Rails。Rails 收到的是对暂存文件的描述,原来的提交内容仍在文件里,等后续解析。

GitLab 也已经有一套验证这种描述的机制。Multipart 中间件从带签名的上传元数据中取值,再交给 UploadedFile.from_params() 构造上传对象。这个对象代表经过上传处理的文件,和请求里一个碰巧叫 file.path 的字符串,有实质区别。

问题出在业务代码重新取回了那个裸字符串。GitLab 19.3.1 的 file_params_from_body_upload()先读取 params['file.path'],只检查路径是否存在,然后根据内容类型选择解析器。对于表单编码的内容,它直接执行 File.read(file_path)。上传对象携带的来源保证没有进入这次读取。

# GitLab 19.3.1,相关代码节选
file_path = params['file.path']
bad_request!('local file not present') unless File.exist?(file_path)

# application/x-www-form-urlencoded 分支
Rack::Utils.parse_nested_query(File.read(file_path)).deep_symbolize_keys!

File.exist? 回答的是“这个文件是否存在”,无法回答“这是不是本次请求上传的文件”。即使请求只打算创建仓库里的一个普通文本文件,路径一旦被错误地当成服务器内部元数据,读取操作使用的仍是 GitLab 进程的文件权限。文件不存在、进程无权读取或者部署中根本没有那个文件,都会改变结果;“任意文件读取”描述的是路径选择能力,并不让进程越过操作系统的访问控制。

2 身份检查来得太晚

读取路径不可信,还不足以解释“无需登录”。继续看 19.3.1 的 提交接口:它先调用 require_gitlab_workhorse!,再调用前面的文件解析函数,之后才执行 authorize_push_to_branch!。真正要求用户登录的 authenticate! 位于最后这个函数内部

require_gitlab_workhorse! 的名字也容易让人误判。它验证的是请求经过 Workhorse。一个未登录的外部访问者也会经过这层代理。代理身份可信,不意味着代理转发的每个字段都已经被上传处理程序验证,更不意味着发起请求的人有权限读取服务器文件。

接口在前置阶段还会检查仓库是否启用、调用者是否能读取代码。因此,分析这条匿名路径时,需要一个允许匿名读取仓库的项目;公开项目的其他可见性设置仍然可能限制访问。官方新增的回归测试使用的就是公开项目。把所有项目改成私有会改变这个入口条件,但这不能修复底层的路径信任错误,也不能作为已完成补丁的证明。

正常上传为什么没有把恶意字段覆盖掉?这涉及前后两层对路径的识别。Workhorse 的专用路由用精确模式选择请求体上传处理;它匹配的是经清理的转义路径,路由选择逻辑在专用路径之外,还配置了默认转发。当请求未进入预期的上传处理,却仍被后端识别为提交接口时,业务代码对内部字段的假设便失效。GitLab 的调查说明也专门要求检索编码过的接口和参数名。仅按一条字面 URL 过滤日志,容易漏掉这类请求。

这里的危险组合是:能到达接口的请求,保留了调用者提供的本地路径;接口在确认用户身份之前消费这个路径。最后一次提交权限检查即使正确返回拒绝,也已经晚于文件读取。安全检查的位置必须覆盖此前的副作用。

提交接口修复前先读取路径、解析内容,再检查用户身份,解析错误可提前带回片段;修复后先认证、只接受 UploadedFile,且错误消息不再拼接文件内容。

左右滑动查看图中说明。

图 1:提交接口内的关键顺序。图中省略了前置的项目读取权限和代理验证;它们仍须通过,却没有替代用户认证和上传对象验证。

3 文件怎样变成错误消息

文件读进进程后,客户端还没有自动获得内容。在这里,文件被当作一份 URL 编码表单解析,而文件原本可能是配置、文本或者其他数据。这种语义错位把下一步交给了文件本身的字节。

19.3.1 的依赖锁文件固定了 Rack 2.2.23URI 1.1.1。Rack 的 parse_nested_query()先检查大小和参数数量,再分割表单:这个版本的默认分隔符包括 &;;每一段按第一个 = 分出名称和值,随后分别解码。百分号编码要求 % 后面跟两个十六进制字符。%25 合法,表示百分号;%oo 不合法。

URI 的解码函数发现非法编码时,抛出的异常消息包含当前正在解码的字符串。Rack 保留这条消息,将异常包装为 InvalidParameterError。旧版 GitLab 捕获它后,又把 e.message 拼进对外返回的错误。至此,服务器文件中的一段内容跨过了三个函数,进入 HTTP 响应。

# URI 1.1.1,解码器中的错误消息
raise ArgumentError, "invalid %-encoding (#{str})" if /%(?!\h\h)/.match?(str)

# GitLab 19.3.1,对外错误处理中的节选
rescue Rack::QueryParser::InvalidParameterError => e
  bad_request!("Invalid parameter: #{e.message}")

用一份只含虚构值的文件来跟这条路径最清楚:

note=calm&token=DEMO%oops&tail=ok

notecalm 能正常解码。接着,token 也正常,轮到值 DEMO%oops 时失败。错误消息里携带的是这个值;前面的 note=calm 没有被放进同一条错误,后面的 tail=ok 还没有执行到。若把 %oops 改成 %25oops,解码成功,结果中的值依然包含百分号,但不会触发这条异常。这解释了为什么“文件里含百分号”仍然不够精确。

虚构文件 note=calm&token=DEMO%oops&tail=ok 被分段解析,DEMO%oops 解码失败并进入错误消息,前一项已通过,后一项尚未解析。

左右滑动查看图中说明。

图 2:百分号解码失败时,错误携带当前片段。该示例只使用 &;这里核对的 Rack 版本也把分号作为默认分隔符。

我们在本地 Ruby WASM 中运行了固定版本的 Rack 解析代码,并载入 URI 1.1.1 的两个原始解码方法。四组对照分别使用非法编码、合法编码、没有分隔符的文本,以及分号分隔的文本。非法编码产生 invalid %-encoding (DEMO%oops);合法编码正常生成参数;整个输入只有 DEMO%oops 时,异常包含的就是全部输入。分号对照同样只带回失败片段。

这个 33 字节的示例输入,有 9 字节进入解码错误。按旧 helper 的消息格式构造 JSON 后,响应示例是 81 字节。多出来的字节来自错误前缀和 JSON 包装;换成只有 9 字节的无分隔符输入,构造出的响应长度仍然是 81。拿响应大小直接当泄露大小,会同时误判这两种情况。

查看本地实验范围与记录

实验使用 Ruby 3.3.3 的 WASM 运行时,执行 Rack 2.2.23 的 QueryParser 原文件;Rack::Utils.unescape 按该版本源码转交 URI,URI 1.1.1 的 decode_www_form_component_decode_uri_component 两个方法原样载入。这里验证的是解析与错误字符串,未启动 Rails、Workhorse 或完整 GitLab。JSON 响应示例由已核对的错误格式构造,不是服务器抓包。下载四组输入、运行结果与来源记录

还要给这个解释留出准确的范围:上面跟踪的是表单解码异常这一条输出路径。文件太大、参数过多、结构不合法、JSON 解析失败,走的分支和错误文字可能不同;旧 helper 还捕获另外两类 Rack 异常。不能把“没有触发这一种错误”写成整个实例从未泄露的结论。源文件当时的内容、实际部署的依赖和完整响应,都会影响逐条请求的判断。

4 补丁修正了整条信任关系

GitLab 的修复提交同时调整了提交接口、文件创建和文件更新接口。最直接的变化是在文件解析之前调用 authenticate!,上传预授权接口也提前要求用户身份。这样,匿名请求在业务层读文件之前就会结束。

第二处变化更能说明根因。新 helperparams[:file] 取出对象,明确要求它是 UploadedFile,再使用对象的路径和实际大小。缺少这种对象就拒绝。读取操作由此重新依赖上传来源验证,不再接受任意请求字符串作为文件来源。

接口此前已经声明了 WorkhorseFile 类型,为何还要加这次检查?它的parse() 对空值直接返回 nil。这个类型声明没有为 helper 随后读取的另一组 file.pathfile.size 参数提供来源保证。补丁在使用文件的地方明确要求可信上传对象,把这个缺口补上了。

# 修复后的 helper,节选
uploaded_file = params[:file]
bad_request!('file is invalid') unless uploaded_file.is_a?(::UploadedFile)
file_path = uploaded_file.path
check_large_request_rate_limit!(uploaded_file.size)

# 同一修复中的错误处理
rescue Rack::QueryParser::InvalidParameterError
  bad_request!('Invalid parameter')

使用实际大小也有理由。旧代码的 file.size 来自参数,它用于大请求限速判断,不是 File.read 的读取长度。声明一个很小的大小,没有把读取限制成几个字节。修复后的限速和 multipart 内容长度都取自上传对象;新增测试专门覆盖了“声明为 1,实际内容超过阈值”的情况。

第三处变化是删掉异常消息中的内容反射。三类被捕获的 Rack 错误现在只返回固定说明。即使已认证用户提交了畸形表单,解析器内部用于调试的原始字符串也不会再通过这几条错误分支回传。认证、对象来源、输出消息各自保护不同环节;只修改其中一处,会让另外的错误假设继续留在代码里。

这条历史可以追到两次相邻改动。2025 年 12 月 8 日的上传改造让相关接口通过暂存文件接收请求体;次日的表单支持加入了这里分析的解析及异常拼接。18.6.0 的提交路径尚未采用这套 helper,18.7.0 中已能看到它。这个发行快照对照与厂商把受影响起点列为 18.7 一致;后续是否可达仍要连同实例配置判断。

公开回归也体现了修复的意图。共享测试在临时文件中放入无害标记,分别传入存在和不存在的路径,要求匿名请求都被拒绝、既不泄露标记,也不泄露“文件不存在”这个差异;提交接口另测已认证的畸形表单,要求错误不含标记。这些是我们核对过的上游测试代码,本文没有把它们写成一次完整 GitLab 实测通过记录。

5 先关入口,再核实返回过什么

受影响对象是 GitLab CE、EE 的自建实例。厂商给出的范围为 18.7 起至 19.1.8 之前、19.2 起至 19.2.6 之前、19.3 起至 19.3.2 之前。GitLab.com 和 GitLab Dedicated 已由厂商处理。当前版本选择要结合维护策略看:19.1.8 是当时的首修版本,19.1 已不在本次检索时的三个维护分支里。

现有分支本次处置需要保留的限制
19.3升级至 19.3.2所在分支的已发布修复;恢复时不能重新暴露 19.3.1。
19.2升级至 19.2.6所在分支的已发布修复;不能退回 19.2.5 作为联网恢复方案。
18.7–19.1限制暴露,按升级路径迁入受支持分支19.1.8 仅是历史修复下限;跨版本升级须满足必经版本和后台迁移要求。
准备进入当前稳定分支19.4.0,完成兼容与迁移检查后部署9 月 17 日发布,源码已含修复;无需为等待小版本迁移而推迟现有分支补丁。

即使留在同一分支,这次升级也涉及数据库迁移。官方升级说明明确指出,单节点实例会停机,迁移完成后 GitLab 才能启动;多节点部署需要遵循零停机升级流程,才能维持服务。19.3.2 另有部署后迁移,应确认其完成状态,不能只检查进程是否重新启动。

以上是截至 2026 年 9 月 20 日 UTC 核对的版本。执行变更时从官方升级入口重新确认当前补丁和对应安装方式。无法立即升级时,优先限制整个实例的网络入口至受信访问范围,并核对仍然开放的集成、Runner 和访问路径。这样会影响外部协作、Git HTTPS 操作或自动化调用,要和业务方明确;单靠一个字面 URL 的 WAF 规则不足以覆盖前面解释的路径差异。

升级前保存 Rails 的 api_json.log、Workhorse 访问日志及相关代理日志,包含轮转记录。GitLab 的调查说明给出的关键关联字段是 correlation_id:用它把请求参数、Rails 的 api_error 和 Workhorse 的 written_bytes 连到同一次请求。错误字段可能含敏感片段,取证副本应按敏感数据管理。

把记录连起来之后,先看错误体究竟含什么,再用响应大小辅助核对。一个 400 可能是文件不存在、解析失败或其他请求错误;最终的 401 也没有记录此前所有执行动作。若错误体保存完整并含有文件片段,这比单独一条状态码更能说明输出内容。日志缺失、截断或代理改写响应时,就把这部分留作未确定范围,不用一个固定字节数替它下结论。

日志筛选应检查每个参数的值,避免把一整行中出现正常上传目录当成排除条件。同一请求可以同时带有正常和异常字段。还应保留原始编码,再另做规范化检索;只保存解码后的一个版本,会丢失判断前后两层如何解释请求所需的证据。调查范围从实例开始运行受影响版本且暴露相关入口的时间算起,不能自动从公告日开始。

发现确实返回的敏感值后,先确认它对应哪个服务、是否仍有效、使用权限是什么,再吊销或替换,并检查相关账户的后续访问。日志不完整时,应根据暴露窗口和密钥权限决定是否采取更保守的轮换。GitLab 自身的加密主密钥有额外限制。截至本文检索日,GitLab 明确说明 db_key_base 没有受支持的轮换路径,可能造成数据损失的操作应先联系 GitLab Support。恢复文档说明了密钥与加密数据的依赖;随意删换密钥可能使已有数据无法解密。

回滚也要连同数据考虑。GitLab 的备份恢复要求版本和 CE/EE 类型与备份精确对应;不能把新版本数据库直接交给任意旧程序。若恢复旧备份只能回到受影响版本,应保持隔离,完成重新升级后再开放。对已修复的 19.2 或 19.3 分支,本漏洞的安全下限分别是 19.2.6、19.3.2,这个下限不是跨分支随意降级的许可。

最后用一组有实际区别的验收关闭变更:确认所有 Rails、Workhorse 节点和容器实际运行新版本;正常的 JSON、表单及 multipart 提交仍能完成;匿名请求在文件处理前被拒绝;未经验证的上传描述被拒绝;已认证的畸形表单不会在响应里出现无害测试标记;较大的合法请求仍按实际大小受到限制。涉及异常输入的回归只在自有隔离环境进行,标记文件不含密钥。版本修复与业务恢复完成后,还要单独记录已经处理的泄露凭据和仍无法确认的时间段。

6 错误处理也在传输数据

这个漏洞把几件平时分开检查的事情连到了一起:代理转发、上传元数据、文件读取、表单解析和错误输出。每个部件单独看都很熟悉,危险发生在它们对同一个值的理解不同。请求里的字符串被当成内部路径,文件内容被当成表单,调试用异常又被当成适合返回给访问者的说明。

因此,审查类似接口时,可以沿一次请求实际经过的顺序问三个具体问题:第一次读取文件或改变状态之前,权限检查完成了吗;业务代码取到的对象是否保留了来源验证;异常消息里有没有把本应留在服务端的输入重新发出去。CVE-2026-85706 的补丁分别回答了这三个问题。对正在处置的团队,最终需要得到的也很具体:入口已修复,正常提交可用,返回过的敏感内容已查明并处理,查不清的部分仍有明确记录。

研究依据

研究依据核对 GitLab 18.6、18.7、19.3.1 及各首修标签、19.4.0 的相关源码,追踪上传路径、认证顺序和错误反射;在 Ruby WASM 中运行固定 Rack 解析源码与 URI 解码方法。未运行完整 GitLab 实例,未探测外部服务。版本与利用状态检索截至 2026-09-20 UTC。

来源GitLab 修复公告、固定发行源码与回归测试;Rack、URI 源码;CISA KEV;NVD;SOSEC 本地解析实验

证据置信度

7证据与来源

7.1时间线

  1. 上传改造与表单支持

    请求体暂存改造及后续表单解析代码进入源码,18.7.0 标签已包含相关路径。

  2. 紧急修复发布

    GitLab 发布 19.3.2、19.2.6、19.1.8。

  3. 进入 KEV

    CISA 收录该漏洞;watchTowr 报告蜜罐探测活动。

  4. 19.4 发布

    新稳定小版本继续包含相关修复。

7.2来源与材料

  1. GitLab 紧急修复公告及检测入口https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-3-2-released/
  2. GitLab 修复提交与回归测试https://gitlab.com/gitlab-org/gitlab/-/commit/0ff7b6b2911723389f2271b10362591b0a69a166
  3. Rack 2.2.23 参数解析源码https://github.com/rack/rack/blob/f2af0c8f869193fa7bb7d20b619b3003418e1055/lib/rack/query_parser.rb
  4. URI 1.1.1 解码源码https://github.com/ruby/uri/blob/f1b05c89ab38667e7564896f994d4d6cfbc67149/lib/uri/common.rb
  5. GitLab:关联响应内容与 Workhorse 日志https://support.gitlab.com/hc/en-us/articles/30371082139164-Validating-whether-a-file-was-actually-exfiltrated-via-CVE-2026-85706-using-Workhorse-written-bytes
  6. CISA KEV 官方数据https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
  7. NVD 漏洞记录https://nvd.nist.gov/vuln/detail/CVE-2026-85706
  8. watchTowr:9 月 11 日的探测观察https://watchtowr.com/intelligence/rapid-reaction-gitlab-critical-path-traversal-vulnerability-cve-2026-85706/
  9. GitLab 19.4 发布说明https://docs.gitlab.com/releases/19/gitlab-19-4-released/