Self-Hosted Docker Runner /cache Volume Allows Cache Poisoning Leading to RCE in Protected Branches

⚠️ Please read the process on how to fix security issues before starting to work on the issue. Vulnerabilities must be fixed in a security mirror.

HackerOne report #3163155 by aphantom on 2025-05-26, assigned to @greg:

Report | Attachments | How To Reproduce

Report

Summary

Self-hosted Docker runners automatically mount a named volume at /cache upon registration. Because this volume is reused across all refs handled by the same runner, an attacker with Developer access can:

  • Read artifacts produced by protected-branch pipelines.
  • Poison the cache so that the next protected-branch job executes attacker-controlled code.

The impact is similar to CVE-2022-1423 but in a self-hosted runner context, leading to arbitrary code execution or secret exfiltration in pipelines that should be restricted to Maintainers.

Root Cause

During gitlab-runner register --executor docker, the helper function askDocker() silently appends "/cache" to the volume list unless DisableCache is set:

func (s *RegisterCommand) askDocker() {  
    s.askBasicDocker("ruby:2.7")

    for _, v := range s.Docker.Volumes {  
        if strings.HasSuffix(v, "/cache") {  
            return              // already present  
        }  
    }  
    if !s.Docker.DisableCache {  
        s.Docker.Volumes = append(s.Docker.Volumes, "/cache")  
    }  
}

/cache is thus present even when the project’s .gitlab-ci.yml contains no cache. The volume’s lifecycle is bound to the runner slot (e.g., runner--project--concurrent-0-cache-...), not to the branch or cache-key, so protected and unprotected pipelines share the same physical directory.

image.png

Steps to reproduce
  1. Start a self-hosted Docker runner
docker volume create gitlab-runner-config  
docker run -d --name gitlab-runner --restart always \  
  -v /var/run/docker.sock:/var/run/docker.sock \  
  -v gitlab-runner-config:/etc/gitlab-runner \  
  gitlab/gitlab-runner:latest  
  1. Register it to your project:
gitlab-runner register  --url https://gitlab.com  --token <REDACTED>  

After registration /etc/gitlab-runner/config.toml contains volumes = ["/cache"] — added automatically.

  1. Run pipeline on an unprotected branch (attacker developer):
stages:  
  - test

test:  
  stage: test  
  image: python:3.12-slim  
  script:  
    - echo "malicious developer was here!!" > /cache/openme.txt  
  1. As the Owner user, run pipeline on the protected main branch (victim):
stages:  
  - test

test:  
  stage: test  
  image: python:3.13-slim  
  script:  
    - ls -la /cache  
    - cat /cache/openme.txt  

The protected-branch job mounts the same /cache volume, proving cross-branch access and demonstrating cache poisoning potential:

image.png

What is the current bug behavior?

Self-hosted Docker runners always mount the same named volume at /cache for every job slot. This volume persists across pipelines, so files written by an unprotected branch are automatically visible—and executable—in subsequent protected-branch jobs and vice versa on the same runner.

What is the expected correct behavior?

Protected-branch pipelines should never consume data originating from unprotected branches. The runner should either isolate /cache volumes per protection level/ref, or skip mounting /cache unless a project explicitly defines a cache, ensuring cross-branch separation as documented.

Relevant logs and/or screenshots

Below is a video POC of the repro steps showcased above:

<Redacted>

Output of checks

Below is the GitLab Runner version details:

Version:      18.0.2  
Git revision: 4d7093e1  
Git branch:   18-0-stable  
GO version:   go1.23.6 X:cacheprog  
Built:        2025-05-21T22:00:57Z  
OS/Arch:      linux/arm64  
Environment Info

Below is the config.toml of the runner that was auto-filled upon registration

concurrent = 1  
check_interval = 0  
shutdown_timeout = 0

[session_server]  
  session_timeout = 1800

[[runners]]  
  name = "92ed40c98d55"  
  url = "https://gitlab.com"  
  id = 47610935  
  token = <REDACTED>  
  token_obtained_at = 2025-05-26T14:16:30Z  
  token_expires_at = 0001-01-01T00:00:00Z  
  executor = "docker"  
  [runners.cache]  
    MaxUploadedArchiveSize = 0  
    [runners.cache.s3]  
    [runners.cache.gcs]  
    [runners.cache.azure]  
  [runners.docker]  
    tls_verify = false  
    image = "ruby:2.7"  
    privileged = false  
    disable_entrypoint_overwrite = false  
    oom_kill_disable = false  
    disable_cache = false  
    volumes = ["/cache"] <-- auto-configured  
    shm_size = 0  
    network_mtu = 0  

Impact

Similar impact as CVE-2022-1423, a developer on an unprotected branch can stash files in the mounted /cache volume; the same files are then mounted into protected-branch pipelines, run with maintainer-level permissions. This lets the attacker execute arbitrary code, steal secret variables (tokens, deploy keys, etc.), and read or modify build outputs that should be restricted to trusted branches.

Attachments

Warning: Attachments received through HackerOne, please exercise caution!

How To Reproduce

Please add reproducibility information to this section:

Edited by ADandy